From a12ab0496cb450fa6ea235a612cd15d2dc4937bc Mon Sep 17 00:00:00 2001 From: Mayukha Vadari Date: Thu, 16 Jul 2026 11:18:26 -0400 Subject: [PATCH 01/52] fix: Add missing pseudo-account checks --- .../tx/transactors/account/AccountDelete.cpp | 2 ++ .../tx/transactors/payment/DepositPreauth.cpp | 9 +++++- src/test/app/AccountDelete_test.cpp | 24 ++++++++++++++-- src/test/app/DepositAuth_test.cpp | 28 +++++++++++++++++-- 4 files changed, 56 insertions(+), 7 deletions(-) diff --git a/src/libxrpl/tx/transactors/account/AccountDelete.cpp b/src/libxrpl/tx/transactors/account/AccountDelete.cpp index 0055fce403..ce027f4cad 100644 --- a/src/libxrpl/tx/transactors/account/AccountDelete.cpp +++ b/src/libxrpl/tx/transactors/account/AccountDelete.cpp @@ -241,6 +241,8 @@ AccountDelete::preclaim(PreclaimContext const& ctx) if (!ctx.tx.isFieldPresent(sfCredentialIDs)) { // Check whether the destination account requires deposit authorization. + // This also checks if destination is a pseudo-account, since pseudo-accounts have the + // lsfDepositAuth flag set by default if (sleDst->isFlag(lsfDepositAuth)) { if (!ctx.view.exists(keylet::depositPreauth(dst, account))) diff --git a/src/libxrpl/tx/transactors/payment/DepositPreauth.cpp b/src/libxrpl/tx/transactors/payment/DepositPreauth.cpp index d3e2af86ef..aa2c6e42bd 100644 --- a/src/libxrpl/tx/transactors/payment/DepositPreauth.cpp +++ b/src/libxrpl/tx/transactors/payment/DepositPreauth.cpp @@ -103,9 +103,16 @@ DepositPreauth::preclaim(PreclaimContext const& ctx) { // Verify that the Authorize account is present in the ledger. AccountID const auth{ctx.tx[sfAuthorize]}; - if (!ctx.view.exists(keylet::account(auth))) + auto const sleAuth = ctx.view.read(keylet::account(auth)); + if (!sleAuth) return tecNO_TARGET; + if (ctx.view.rules().enabled(fixCleanup3_3_0) && isPseudoAccount(sleAuth)) + { + JLOG(ctx.j.debug()) << "Authorized account is a pseudo-account."; + return tecNO_PERMISSION; + } + // Verify that the Preauth entry they asked to add is not already // in the ledger. if (ctx.view.exists(keylet::depositPreauth(account, auth))) diff --git a/src/test/app/AccountDelete_test.cpp b/src/test/app/AccountDelete_test.cpp index 399696ec0d..8fbb786caf 100644 --- a/src/test/app/AccountDelete_test.cpp +++ b/src/test/app/AccountDelete_test.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include @@ -31,6 +32,7 @@ #include #include #include +#include #include #include #include @@ -687,7 +689,7 @@ public: } void - testDest() + testDest(FeatureBitset features) { testcase("Destination Constraints"); @@ -698,7 +700,7 @@ public: Account const carol{"carol"}; Account const daria{"daria"}; - Env env{*this}; + Env env{*this, features}; env.fund(XRP(100000), alice, becky, carol); env.close(); @@ -711,6 +713,16 @@ public: env(fset(carol, asfRequireDest)); env.close(); + // Need to create a pseudo-account + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = alice, .asset = xrpIssue()}); + env(tx); + env.close(); + auto const sleVault = env.le(keylet); + if (!BEAST_EXPECT(sleVault)) + return; + Account const vaultPseudo{"vaultPseudo", sleVault->at(sfAccount)}; + // Close enough ledgers to be able to delete becky's account. incLgrSeqForAccDel(env, becky); @@ -730,6 +742,10 @@ public: env(acctdelete(becky, alice), Fee(acctDelFee), Ter(tecNO_PERMISSION)); env.close(); + // becky attempts to delete her account using a pseudo-account as the + // destination, which fails since pseudo-accounts have deposit auth enabled. + env(acctdelete(becky, vaultPseudo), Fee(acctDelFee), Ter(tecNO_PERMISSION)); + // alice preauthorizes deposits from becky. Now becky can delete her // account and forward the leftovers to alice. env(deposit::auth(alice, becky)); @@ -1076,6 +1092,7 @@ public: void run() override { + auto const all{jtx::testableAmendments()}; testBasics(); testDirectories(); testOwnedTypes(); @@ -1083,7 +1100,8 @@ public: testImplicitlyCreatedTrustline(); testBalanceTooSmallForFee(); testWithTickets(); - testDest(); + testDest(all); + testDest(all - fixCleanup3_3_0); testDestinationDepositAuthCredentials(); testDeleteCredentialsOwner(); } diff --git a/src/test/app/DepositAuth_test.cpp b/src/test/app/DepositAuth_test.cpp index c75bdeaf3a..5943f5ca95 100644 --- a/src/test/app/DepositAuth_test.cpp +++ b/src/test/app/DepositAuth_test.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include @@ -28,6 +29,7 @@ #include #include #include +#include #include #include #include @@ -444,7 +446,7 @@ struct DepositPreauth_test : public beast::unit_test::Suite } void - testInvalid() + testInvalid(FeatureBitset features) { testcase("Invalid"); @@ -453,7 +455,7 @@ struct DepositPreauth_test : public beast::unit_test::Suite Account const becky{"becky"}; Account const carol{"carol"}; - Env env(*this); + Env env(*this, features); // Tell env about alice, becky and carol since they are not yet funded. env.memoize(alice); @@ -559,6 +561,25 @@ struct DepositPreauth_test : public beast::unit_test::Suite env.close(); env.require(Owners(alice, 0)); env.require(Owners(becky, 0)); + + { + // alice attempts to authorize a pseudo-account. + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = becky, .asset = xrpIssue()}); + env(tx); + env.close(); + + auto const sleVault = env.le(keylet); + if (!BEAST_EXPECT(sleVault)) + return; + Account const vaultPseudo{"vault", sleVault->at(sfAccount)}; + + auto const expectedResult = + features[fixCleanup3_3_0] ? Ter(tecNO_PERMISSION) : Ter(tesSUCCESS); + env(deposit::auth(alice, vaultPseudo), expectedResult); + env.close(); + env.require(Owners(alice, features[fixCleanup3_3_0] ? 0 : 1)); + } } void @@ -1419,8 +1440,9 @@ struct DepositPreauth_test : public beast::unit_test::Suite run() override { testEnable(); - testInvalid(); auto const supported{jtx::testableAmendments()}; + testInvalid(supported); + testInvalid(supported - fixCleanup3_3_0); testPayment(supported - featureCredentials); testPayment(supported); testCredentialsPayment(); From d569f7db9e5869df2cfa7d365275ac25ef2852b2 Mon Sep 17 00:00:00 2001 From: Mayukha Vadari Date: Thu, 16 Jul 2026 11:42:27 -0400 Subject: [PATCH 02/52] fix: Prevent credentials from being created for pseudo-accounts --- .../credentials/CredentialCreate.cpp | 10 +++++++- src/test/app/Credentials_test.cpp | 24 ++++++++++++++++--- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/libxrpl/tx/transactors/credentials/CredentialCreate.cpp b/src/libxrpl/tx/transactors/credentials/CredentialCreate.cpp index e902ee73a6..29e262d9ec 100644 --- a/src/libxrpl/tx/transactors/credentials/CredentialCreate.cpp +++ b/src/libxrpl/tx/transactors/credentials/CredentialCreate.cpp @@ -84,7 +84,9 @@ CredentialCreate::preclaim(PreclaimContext const& ctx) auto const credType(ctx.tx[sfCredentialType]); auto const subject = ctx.tx[sfSubject]; - if (!ctx.view.exists(keylet::account(subject))) + auto const subjectSle = ctx.view.read(keylet::account(subject)); + + if (!subjectSle) { JLOG(ctx.j.trace()) << "Subject doesn't exist."; return tecNO_TARGET; @@ -96,6 +98,12 @@ CredentialCreate::preclaim(PreclaimContext const& ctx) return tecDUPLICATE; } + if (ctx.view.rules().enabled(fixCleanup3_3_0) && isPseudoAccount(subjectSle)) + { + JLOG(ctx.j.trace()) << "Subject is a pseudo-account."; + return tecNO_PERMISSION; + } + return tesSUCCESS; } diff --git a/src/test/app/Credentials_test.cpp b/src/test/app/Credentials_test.cpp index 1f6ec012c8..a26ceb261c 100644 --- a/src/test/app/Credentials_test.cpp +++ b/src/test/app/Credentials_test.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include @@ -24,6 +25,7 @@ #include #include #include +#include #include #include #include @@ -425,7 +427,6 @@ struct Credentials_test : public beast::unit_test::Suite Account const subject{"subject"}; { - using namespace jtx; Env env{*this, features}; env.fund(XRP(5000), subject, issuer); @@ -566,10 +567,27 @@ struct Credentials_test : public beast::unit_test::Suite // End test env.close(); } + + { + testcase("Credentials fail, subject is a pseudo-account."); + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = subject, .asset = xrpIssue()}); + env(tx); + env.close(); + + auto const sleVault = env.le(keylet); + if (!BEAST_EXPECT(sleVault)) + return; + Account const vaultPseudo{"vault", sleVault->at(sfAccount)}; + auto const expectedResult = + features[fixCleanup3_3_0] ? Ter(tecNO_PERMISSION) : Ter(tesSUCCESS); + + env(credentials::create(vaultPseudo, issuer, credType), expectedResult); + env.close(); + } } { - using namespace jtx; Env env{*this, features}; env.fund(XRP(5000), issuer); @@ -583,7 +601,6 @@ struct Credentials_test : public beast::unit_test::Suite } { - using namespace jtx; Env env{*this, features}; auto const reserve = drops(env.current()->fees().reserve); @@ -1157,6 +1174,7 @@ struct Credentials_test : public beast::unit_test::Suite testCredentialsDelete(all); testCreateFailed(all); testCreateFailed(all - fixDirectoryLimit); + testCreateFailed(all - fixCleanup3_3_0); testAcceptFailed(all); testDeleteFailed(all); testFeatureFailed(all - featureCredentials); From 846369cbe7935ff172777a91a8e5c68e3ee1cc27 Mon Sep 17 00:00:00 2001 From: Timothy Banks Date: Thu, 16 Jul 2026 17:16:09 +0100 Subject: [PATCH 03/52] fix: Use hashmap for quicker lookup in assembleAdd --- include/xrpl/protocol/STPathSet.h | 35 +++++++--- src/libxrpl/protocol/STPathSet.cpp | 24 +++---- src/test/app/Path_test.cpp | 99 +++++++++++++++++++++++++++++ src/xrpld/rpc/detail/Pathfinder.cpp | 8 +-- 4 files changed, 139 insertions(+), 27 deletions(-) diff --git a/include/xrpl/protocol/STPathSet.h b/include/xrpl/protocol/STPathSet.h index 23f4e653c4..d527e2479f 100644 --- a/include/xrpl/protocol/STPathSet.h +++ b/include/xrpl/protocol/STPathSet.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -108,6 +109,9 @@ public: [[nodiscard]] bool isType(Type const& pe) const; + [[nodiscard]] size_t + getHash() const; + bool operator==(STPathElement const& t) const; @@ -171,12 +175,23 @@ public: reserve(size_t s); }; +template +void +hash_append(Hasher& h, STPath const& p) noexcept +{ + for (auto const& e : p) + { + beast::hash_append(h, e.getHash()); + } +} + //------------------------------------------------------------------------------ // A set of zero or more payment paths class STPathSet final : public STBase, public CountedObject { std::vector value_; + xrpl::hardened_hash_set seenHashes_; public: STPathSet() = default; @@ -205,9 +220,6 @@ public: std::vector::const_reference operator[](std::vector::size_type n) const; - std::vector::reference - operator[](std::vector::size_type n); - [[nodiscard]] std::vector::const_iterator begin() const; @@ -227,6 +239,9 @@ public: void emplaceBack(Args&&... args); + [[nodiscard]] bool + contains(STPath const& path) const; + private: STBase* copy(std::size_t n, void* buf) const override; @@ -515,12 +530,6 @@ STPathSet::operator[](std::vector::size_type n) const return value_[n]; } -inline std::vector::reference -STPathSet::operator[](std::vector::size_type n) -{ - return value_[n]; -} - inline std::vector::const_iterator STPathSet::begin() const { @@ -549,6 +558,7 @@ inline void STPathSet::pushBack(STPath const& e) { value_.push_back(e); + seenHashes_.emplace(value_.back()); } template @@ -556,6 +566,13 @@ inline void STPathSet::emplaceBack(Args&&... args) { value_.emplace_back(std::forward(args)...); + seenHashes_.emplace(value_.back()); +} + +inline bool +STPathSet::contains(STPath const& path) const +{ + return seenHashes_.contains(path); } } // namespace xrpl diff --git a/src/libxrpl/protocol/STPathSet.cpp b/src/libxrpl/protocol/STPathSet.cpp index 8987d05f1e..658aaa65dd 100644 --- a/src/libxrpl/protocol/STPathSet.cpp +++ b/src/libxrpl/protocol/STPathSet.cpp @@ -51,6 +51,12 @@ STPathElement::getHash(STPathElement const& element) return (hashAccount ^ hashCurrency ^ hashIssuer); } +[[nodiscard]] size_t +STPathElement::getHash() const +{ + return STPathElement::getHash(*this); +} + STPathSet::STPathSet(SerialIter& sit, SField const& name) : STBase(name) { std::vector path; @@ -126,21 +132,15 @@ STPathSet::move(std::size_t n, void* buf) bool STPathSet::assembleAdd(STPath const& base, STPathElement const& tail) { // assemble base+tail and add it to the set if it's not a duplicate - value_.push_back(base); + STPath combined = base; + combined.pushBack(tail); - auto it = value_.rbegin(); - - STPath& newPath = *it; - newPath.pushBack(tail); - - while (++it != value_.rend()) + if (!seenHashes_.insert(combined).second) { - if (*it == newPath) - { - value_.pop_back(); - return false; - } + return false; } + + value_.push_back(std::move(combined)); return true; } diff --git a/src/test/app/Path_test.cpp b/src/test/app/Path_test.cpp index 8f19a419a0..d6e4fec60b 100644 --- a/src/test/app/Path_test.cpp +++ b/src/test/app/Path_test.cpp @@ -53,6 +53,7 @@ #include #include #include +#include #include #include @@ -1866,6 +1867,103 @@ public: BEAST_EXPECT(same(st, stpath(gw_, ipe(xrpIssue())))); } + void + testAssembleAddDeduplication() + { + testcase("STPathSet::assembleAdd deduplication — O(N^2) regression"); + + static constexpr std::string_view kAccount1 = "A3F19C7B2E5D08146FB93A7C0E2D5184BC6F3A09"; + static constexpr std::string_view kAccount2 = "1D7E4B90C2A6F3851E0B9D47A2C5F8136E0A4B7D"; + static constexpr std::string_view kAccount3 = "F08C36A1D95E27B40CA1F63E8D204B7950E1C3A6"; + static constexpr std::string_view kAccount4 = "4B6209E7F1A3C85D0E94B27Af3D6018C5A7E92B4"; + static constexpr std::string_view kAccount5 = "9E2D7041BCA3F6589D013E7B2A4C6F80159D3E7A"; + static constexpr std::string_view kAccount6 = "7C5A91E384F2D06BA19C4E73D820F516B3A9C0E4"; + static constexpr std::string_view kAccount7 = "2F8B043C6A1E9D75B0C38E14F6A2D509731BC4E8"; + static constexpr std::string_view kAccount8 = "E61D9A30F47C285BA0D31E96C7B4F802513A8D6F"; + + static constexpr AccountID kAccountID1{kAccount1}; + static constexpr AccountID kAccountID2{kAccount2}; + static constexpr AccountID kAccountID3{kAccount3}; + static constexpr AccountID kAccountID4{kAccount4}; + static constexpr AccountID kAccountID5{kAccount5}; + static constexpr AccountID kAccountID6{kAccount6}; + static constexpr AccountID kAccountID7{kAccount7}; + static constexpr AccountID kAccountID8{kAccount8}; + + auto ps = STPathSet{}; + + auto createPathElements = [](auto const& account1, auto const& account2) { + auto base = STPath{}; + base.pushBack( + STPathElement{STPathElement::TypeAccount, account1, xrpCurrency(), account1}); + auto tail = + STPathElement{STPathElement::TypeAccount, account2, xrpCurrency(), account2}; + return std::make_pair(base, tail); + }; + + { + auto [base, tail] = createPathElements(kAccountID1, kAccountID2); + + for (auto i = 0uz; i < 10000; ++i) + { + ps.assembleAdd(base, tail); + } + + BEAST_EXPECT(ps.size() == 1); + } + + { + auto [base, tail] = createPathElements(kAccountID3, kAccountID4); + ps.assembleAdd(base, tail); + } + + { + auto [base, tail] = createPathElements(kAccountID5, kAccountID6); + ps.assembleAdd(base, tail); + } + + { + auto [base, tail] = createPathElements(kAccountID7, kAccountID8); + + auto before = ps.size(); + + for (auto i = 0uz; i < 10000; ++i) + { + ps.assembleAdd(base, tail); + } + + BEAST_EXPECT(ps.size() - before == 1); + } + + { + auto [base, tail] = createPathElements(kAccountID1, kAccountID3); + auto copy = base; + copy.pushBack(tail); + + auto before = ps.size(); + + ps.pushBack(copy); + ps.assembleAdd(base, tail); + + BEAST_EXPECT(ps.size() - before == 1); + } + + { + auto [base, tail] = createPathElements(kAccountID2, kAccountID4); + auto copy = base; + copy.pushBack(tail); + + auto before = ps.size(); + + ps.emplaceBack(copy); + ps.assembleAdd(base, tail); + + BEAST_EXPECT(ps.size() - before == 1); + } + + BEAST_EXPECT(ps.size() == 6); + } + void run() override { @@ -1878,6 +1976,7 @@ public: issuesPathNegativeRippleClientIssue23Smaller(); issuesPathNegativeRippleClientIssue23Larger(); qualityPathsQualitySetAndTest(); + testAssembleAddDeduplication(); trustAutoClearTrustNormalClear(); trustAutoClearTrustAutoClear(); norippleCombinations(); diff --git a/src/xrpld/rpc/detail/Pathfinder.cpp b/src/xrpld/rpc/detail/Pathfinder.cpp index 5b7f1415a2..642b5c4253 100644 --- a/src/xrpld/rpc/detail/Pathfinder.cpp +++ b/src/xrpld/rpc/detail/Pathfinder.cpp @@ -962,14 +962,10 @@ Pathfinder::isNoRippleOut(STPath const& currentPath) void addUniquePath(STPathSet& pathSet, STPath const& path) { - // TODO(tom): building an STPathSet this way is quadratic in the size - // of the STPathSet! - for (auto const& p : pathSet) + if (!pathSet.contains(path)) { - if (p == path) - return; + pathSet.pushBack(path); } - pathSet.pushBack(path); } void From d60955e2fc444225161665ae3881d6215ad393ab Mon Sep 17 00:00:00 2001 From: Timothy Banks Date: Thu, 16 Jul 2026 17:41:34 +0100 Subject: [PATCH 04/52] fix: Acquire lock on getClosedLedgerHash --- src/test/app/LedgerReplay_test.cpp | 2 +- src/test/overlay/reduce_relay_test.cpp | 2 +- src/xrpld/overlay/Peer.h | 2 +- src/xrpld/overlay/detail/PeerImp.h | 3 ++- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/test/app/LedgerReplay_test.cpp b/src/test/app/LedgerReplay_test.cpp index 4cc83608d6..f08d6548e0 100644 --- a/src/test/app/LedgerReplay_test.cpp +++ b/src/test/app/LedgerReplay_test.cpp @@ -333,7 +333,7 @@ public: setPublisherListSequence(PublicKey const&, std::size_t const) override { } - [[nodiscard]] uint256 const& + [[nodiscard]] uint256 getClosedLedgerHash() const override { static uint256 const kHash{}; diff --git a/src/test/overlay/reduce_relay_test.cpp b/src/test/overlay/reduce_relay_test.cpp index 2f42313037..6b991836d1 100644 --- a/src/test/overlay/reduce_relay_test.cpp +++ b/src/test/overlay/reduce_relay_test.cpp @@ -140,7 +140,7 @@ public: setPublisherListSequence(PublicKey const&, std::size_t const) override { } - [[nodiscard]] uint256 const& + [[nodiscard]] uint256 getClosedLedgerHash() const override { static uint256 const kHash{}; diff --git a/src/xrpld/overlay/Peer.h b/src/xrpld/overlay/Peer.h index 23a45dc512..328b9075d6 100644 --- a/src/xrpld/overlay/Peer.h +++ b/src/xrpld/overlay/Peer.h @@ -117,7 +117,7 @@ public: // Ledger // - [[nodiscard]] virtual uint256 const& + [[nodiscard]] virtual uint256 getClosedLedgerHash() const = 0; [[nodiscard]] virtual bool hasLedger(uint256 const& hash, std::uint32_t seq) const = 0; diff --git a/src/xrpld/overlay/detail/PeerImp.h b/src/xrpld/overlay/detail/PeerImp.h index ea6eccd656..0085927550 100644 --- a/src/xrpld/overlay/detail/PeerImp.h +++ b/src/xrpld/overlay/detail/PeerImp.h @@ -426,9 +426,10 @@ public: // Ledger // - uint256 const& + uint256 getClosedLedgerHash() const override { + std::scoped_lock const sl{recentLock_}; return closedLedgerHash_; } From 6c793edf72c8d64934633b66591d384e93b6dd6f Mon Sep 17 00:00:00 2001 From: Timothy Banks Date: Thu, 16 Jul 2026 20:07:36 +0100 Subject: [PATCH 05/52] fix: Reject oversized TMPing messages --- src/xrpld/overlay/Message.h | 3 +++ src/xrpld/overlay/detail/ProtocolMessage.h | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/src/xrpld/overlay/Message.h b/src/xrpld/overlay/Message.h index 2e187a2a4d..e942ed244a 100644 --- a/src/xrpld/overlay/Message.h +++ b/src/xrpld/overlay/Message.h @@ -19,6 +19,9 @@ namespace xrpl { constexpr std::size_t kMaximumMessageSize = megabytes(64); +// Ping messages should be much smaller than the maximum message size, +// so we define a separate limit for them. +constexpr std::size_t kMaximumPingMessageSize = kilobytes(1); // VFALCO NOTE If we forward declare Message and write out shared_ptr // instead of using the in-class type alias, we can remove the diff --git a/src/xrpld/overlay/detail/ProtocolMessage.h b/src/xrpld/overlay/detail/ProtocolMessage.h index ef1bc8cb2b..7c22a8e84c 100644 --- a/src/xrpld/overlay/detail/ProtocolMessage.h +++ b/src/xrpld/overlay/detail/ProtocolMessage.h @@ -359,6 +359,13 @@ invokeProtocolMessage(Buffers const& buffers, Handler& handler, std::size_t& hin return result; } + if (header->messageType == protocol::mtPING && + header->uncompressedSize + header->headerSize > kMaximumPingMessageSize) + { + result.second = make_error_code(boost::system::errc::message_size); + return result; + } + // We don't have the whole message yet. This isn't an error but we have // nothing to do. if (header->totalWireSize > size) From e1dae5f775fcb75ecc6b77b3915276f04ab25b41 Mon Sep 17 00:00:00 2001 From: Bart Date: Thu, 16 Jul 2026 16:32:54 -0400 Subject: [PATCH 06/52] refactor: Use `tecPSEUDO_ACCOUNT` instead of `tecNO_PERMISSION` where relevant --- .../tx/transactors/credentials/CredentialCreate.cpp | 2 +- src/libxrpl/tx/transactors/delegate/DelegateSet.cpp | 2 +- src/libxrpl/tx/transactors/payment/DepositPreauth.cpp | 2 +- src/libxrpl/tx/transactors/sponsor/SponsorshipSet.cpp | 2 +- src/test/app/Credentials_test.cpp | 2 +- src/test/app/Delegate_test.cpp | 4 ++-- src/test/app/DepositAuth_test.cpp | 2 +- src/test/app/Sponsor_test.cpp | 8 ++++---- 8 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/libxrpl/tx/transactors/credentials/CredentialCreate.cpp b/src/libxrpl/tx/transactors/credentials/CredentialCreate.cpp index 29e262d9ec..5cce1a7de8 100644 --- a/src/libxrpl/tx/transactors/credentials/CredentialCreate.cpp +++ b/src/libxrpl/tx/transactors/credentials/CredentialCreate.cpp @@ -101,7 +101,7 @@ CredentialCreate::preclaim(PreclaimContext const& ctx) if (ctx.view.rules().enabled(fixCleanup3_3_0) && isPseudoAccount(subjectSle)) { JLOG(ctx.j.trace()) << "Subject is a pseudo-account."; - return tecNO_PERMISSION; + return tecPSEUDO_ACCOUNT; } return tesSUCCESS; diff --git a/src/libxrpl/tx/transactors/delegate/DelegateSet.cpp b/src/libxrpl/tx/transactors/delegate/DelegateSet.cpp index 96e6c9e443..12edb43bff 100644 --- a/src/libxrpl/tx/transactors/delegate/DelegateSet.cpp +++ b/src/libxrpl/tx/transactors/delegate/DelegateSet.cpp @@ -57,7 +57,7 @@ DelegateSet::preclaim(PreclaimContext const& ctx) return tecNO_TARGET; if (isPseudoAccount(sleAuthorize)) - return tecNO_PERMISSION; + return tecPSEUDO_ACCOUNT; // Deleting the delegate object is invalid if it doesn’t exist. if (ctx.tx.getFieldArray(sfPermissions).empty() && diff --git a/src/libxrpl/tx/transactors/payment/DepositPreauth.cpp b/src/libxrpl/tx/transactors/payment/DepositPreauth.cpp index aa2c6e42bd..c11c0ed916 100644 --- a/src/libxrpl/tx/transactors/payment/DepositPreauth.cpp +++ b/src/libxrpl/tx/transactors/payment/DepositPreauth.cpp @@ -110,7 +110,7 @@ DepositPreauth::preclaim(PreclaimContext const& ctx) if (ctx.view.rules().enabled(fixCleanup3_3_0) && isPseudoAccount(sleAuth)) { JLOG(ctx.j.debug()) << "Authorized account is a pseudo-account."; - return tecNO_PERMISSION; + return tecPSEUDO_ACCOUNT; } // Verify that the Preauth entry they asked to add is not already diff --git a/src/libxrpl/tx/transactors/sponsor/SponsorshipSet.cpp b/src/libxrpl/tx/transactors/sponsor/SponsorshipSet.cpp index 2b6ab8cf15..24bfaad2f8 100644 --- a/src/libxrpl/tx/transactors/sponsor/SponsorshipSet.cpp +++ b/src/libxrpl/tx/transactors/sponsor/SponsorshipSet.cpp @@ -146,7 +146,7 @@ SponsorshipSet::preclaim(PreclaimContext const& ctx) // Pseudo-accounts cannot participate in sponsorship. if (isPseudoAccount(sponsorAccSle) || isPseudoAccount(sponseeSle)) - return tecNO_PERMISSION; + return tecPSEUDO_ACCOUNT; auto const sponsorshipSle = ctx.view.read(keylet::sponsorship(sponsorID, sponseeID)); diff --git a/src/test/app/Credentials_test.cpp b/src/test/app/Credentials_test.cpp index a26ceb261c..ff3489884e 100644 --- a/src/test/app/Credentials_test.cpp +++ b/src/test/app/Credentials_test.cpp @@ -580,7 +580,7 @@ struct Credentials_test : public beast::unit_test::Suite return; Account const vaultPseudo{"vault", sleVault->at(sfAccount)}; auto const expectedResult = - features[fixCleanup3_3_0] ? Ter(tecNO_PERMISSION) : Ter(tesSUCCESS); + features[fixCleanup3_3_0] ? Ter(tecPSEUDO_ACCOUNT) : Ter(tesSUCCESS); env(credentials::create(vaultPseudo, issuer, credType), expectedResult); env.close(); diff --git a/src/test/app/Delegate_test.cpp b/src/test/app/Delegate_test.cpp index 257ed33619..a8ddc27e6f 100644 --- a/src/test/app/Delegate_test.cpp +++ b/src/test/app/Delegate_test.cpp @@ -236,7 +236,7 @@ class Delegate_test : public beast::unit_test::Suite env(delegate::set(gw, Account("unknown"), {"Payment"}), Ter(tecNO_TARGET)); } - // Delegating to a pseudo-account is not allowed, should return tecNO_PERMISSION + // Delegating to a pseudo-account is not allowed, should return tecPSEUDO_ACCOUNT { Vault const vault{env}; auto [tx, keylet] = vault.create({.owner = gw, .asset = xrpIssue()}); @@ -246,7 +246,7 @@ class Delegate_test : public beast::unit_test::Suite auto const sleVault = env.le(keylet); BEAST_EXPECT(sleVault); Account const vaultPseudo{"vault", sleVault->at(sfAccount)}; - env(delegate::set(gw, vaultPseudo, {"Payment"}), Ter(tecNO_PERMISSION)); + env(delegate::set(gw, vaultPseudo, {"Payment"}), Ter(tecPSEUDO_ACCOUNT)); } // non-delegable transaction diff --git a/src/test/app/DepositAuth_test.cpp b/src/test/app/DepositAuth_test.cpp index 5943f5ca95..881441e0f9 100644 --- a/src/test/app/DepositAuth_test.cpp +++ b/src/test/app/DepositAuth_test.cpp @@ -575,7 +575,7 @@ struct DepositPreauth_test : public beast::unit_test::Suite Account const vaultPseudo{"vault", sleVault->at(sfAccount)}; auto const expectedResult = - features[fixCleanup3_3_0] ? Ter(tecNO_PERMISSION) : Ter(tesSUCCESS); + features[fixCleanup3_3_0] ? Ter(tecPSEUDO_ACCOUNT) : Ter(tesSUCCESS); env(deposit::auth(alice, vaultPseudo), expectedResult); env.close(); env.require(Owners(alice, features[fixCleanup3_3_0] ? 0 : 1)); diff --git a/src/test/app/Sponsor_test.cpp b/src/test/app/Sponsor_test.cpp index f20aac68f9..1012a7d0b6 100644 --- a/src/test/app/Sponsor_test.cpp +++ b/src/test/app/Sponsor_test.cpp @@ -353,17 +353,17 @@ public: Account const pseudoAcc("vault", vaultSle->getAccountID(sfAccount)); env.memoize(pseudoAcc); - // Sponsee is a pseudo account -> tecNO_PERMISSION + // Sponsee is a pseudo account -> tecPSEUDO_ACCOUNT env(sponsor::set(sp, 0, 100, XRP(100)), sponsor::SponseeAcc(pseudoAcc), - Ter(tecNO_PERMISSION)); + Ter(tecPSEUDO_ACCOUNT)); env.close(); - // Sponsor is a pseudo account -> tecNO_PERMISSION + // Sponsor is a pseudo account -> tecPSEUDO_ACCOUNT // (submitted by bob with counterpartySponsor pointing to pseudo account) env(sponsor::set(bob, tfDeleteObject), sponsor::CounterpartySponsor(pseudoAcc), - Ter(tecNO_PERMISSION)); + Ter(tecPSEUDO_ACCOUNT)); env.close(); } From a5af6b4e4aa4035cc7d6b3c23602fd9fb287919e Mon Sep 17 00:00:00 2001 From: Bart Date: Thu, 16 Jul 2026 16:31:34 -0400 Subject: [PATCH 07/52] fix: Charge kFeeHeavyBurdenRpc in doChannelVerify --- src/test/app/PayChan_test.cpp | 155 +++++++++++++++++- src/xrpld/rpc/handlers/ChannelVerify.cpp | 3 + .../admin/signing/ChannelAuthorize.cpp | 3 + 3 files changed, 159 insertions(+), 2 deletions(-) diff --git a/src/test/app/PayChan_test.cpp b/src/test/app/PayChan_test.cpp index 5068472135..703bcf51a7 100644 --- a/src/test/app/PayChan_test.cpp +++ b/src/test/app/PayChan_test.cpp @@ -13,13 +13,18 @@ #include #include +#include +#include +#include + #include #include #include #include #include #include -#include // IWYU pragma: keep +#include +#include #include #include #include @@ -39,6 +44,9 @@ #include #include #include +#include +#include +#include #include #include @@ -48,6 +56,7 @@ #include #include #include +#include #include #include @@ -495,7 +504,7 @@ struct PayChan_test : public beast::unit_test::Suite // Owner closes, will close after settleDelay env(claim(alice, chan), Txflags(tfClose)); BEAST_EXPECT(channelExists(*env.current(), chan)); - env.close(settleTimepoint - settleDelay / 2); + env.close(settleTimepoint - (settleDelay / 2)); { // receiver can still claim auto const chanBal = channelBalance(*env.current(), chan); @@ -1587,6 +1596,146 @@ struct PayChan_test : public beast::unit_test::Suite } } + void + testChannelVerifyLoadType(FeatureBitset features) + { + testcase("channel_verify sets kFEE_HEAVY_BURDEN_RPC load type"); + + using namespace jtx; + using namespace std::literals::chrono_literals; + + Env env{*this, features}; + auto const alice = Account("alice"); + auto const bob = Account("bob"); + + env.fund(XRP(10000), alice, bob); + + auto const pk = alice.pk(); + auto const settleDelay = 3600s; + auto const channelFunds = XRP(1000); + auto const chanStr = to_string(channel(alice, bob, env.seq(alice))); + + env(create(alice, bob, channelFunds, settleDelay, pk)); + env.close(); + + // Step 1: get a valid signature from channel_authorize + auto const authResult = env.rpc("channel_authorize", "alice", chanStr, "1000"); + auto const sig = authResult[jss::result][jss::signature].asString(); + BEAST_EXPECT(!sig.empty()); + auto const pkHex = strHex(pk.slice()); + + // Step 2: build RPC::JsonContext directly so we can inspect loadType + auto& app = env.app(); + Resource::Charge loadType = Resource::kFeeReferenceRpc; + Resource::Consumer c; + RPC::JsonContext context{ + {.j = env.journal, + .app = app, + .loadType = loadType, + .netOps = app.getOPs(), + .ledgerMaster = app.getLedgerMaster(), + .consumer = c, + .role = Role::USER, + .coro = {}, + .infoSub = {}, + .apiVersion = RPC::kApiVersionIfUnspecified}, + {}, + {}}; + json::Value params; + params[jss::public_key] = pkHex; + params[jss::channel_id] = chanStr; + params[jss::amount] = "1000"; + params[jss::signature] = sig; + context.params = std::move(params); + + // Confirm default before calling handler + BEAST_EXPECT(context.loadType == Resource::kFeeReferenceRpc); + json::Value result; + Gate g; + app.getJobQueue().postCoro(JtClient, "RPC-Client", [&](auto const& coro) { + context.coro = coro; + result = doChannelVerify(context); + g.signal(); + }); + + using namespace std::chrono_literals; + BEAST_EXPECT(g.waitFor(5s)); + // Signature must verify correctly + BEAST_EXPECT(result[jss::signature_verified].asBool()); + // KEY ASSERTION: loadType must be kFEE_HEAVY_BURDEN_RPC after the fix + // Before fix: this will FAIL because loadType stays kFEE_REFERENCE_RPC (20) + // After fix: this will PASS because loadType is kFEE_HEAVY_BURDEN_RPC (3000) + BEAST_EXPECT(context.loadType == Resource::kFeeHeavyBurdenRpc); + // Confirm the charge is 150x heavier than the current (broken) default + BEAST_EXPECT(context.loadType.cost() == Resource::kFeeHeavyBurdenRpc.cost()); // 3000 + BEAST_EXPECT(context.loadType.cost() != Resource::kFeeReferenceRpc.cost()); // not 20 + } + + void + testChannelAuthorizeLoadType(FeatureBitset features) + { + testcase("channel_authorize sets kFEE_HEAVY_BURDEN_RPC load type"); + + using namespace jtx; + using namespace std::literals::chrono_literals; + + Env env{*this, features}; + auto const alice = Account("alice"); + auto const bob = Account("bob"); + + env.fund(XRP(10000), alice, bob); + + auto const pk = alice.pk(); + auto const settleDelay = 3600s; + auto const chanStr = to_string(channel(alice, bob, env.seq(alice))); + + env(create(alice, bob, XRP(1000), settleDelay, pk)); + env.close(); + + auto& app = env.app(); + Resource::Charge loadType = Resource::kFeeReferenceRpc; + Resource::Consumer c; + RPC::JsonContext context{ + {.j = env.journal, + .app = app, + .loadType = loadType, + .netOps = app.getOPs(), + .ledgerMaster = app.getLedgerMaster(), + .consumer = c, + .role = Role::ADMIN, // channel_authorize requires ADMIN or canSign() + .coro = {}, + .infoSub = {}, + .apiVersion = RPC::kApiVersionIfUnspecified}, + {}, + {}}; + json::Value params; + params[jss::channel_id] = chanStr; + params[jss::amount] = "1000"; + params[jss::secret] = alice.name(); // use account name as seed + context.params = std::move(params); + + // Confirm default before calling handler + BEAST_EXPECT(context.loadType == Resource::kFeeReferenceRpc); + json::Value result; + Gate g; + app.getJobQueue().postCoro(JtClient, "RPC-Client", [&](auto const& coro) { + context.coro = coro; + result = doChannelAuthorize(context); + g.signal(); + }); + + using namespace std::chrono_literals; + + BEAST_EXPECT(g.waitFor(5s)); + // Must return a valid signature + BEAST_EXPECT(result.isMember(jss::signature)); + BEAST_EXPECT(!result[jss::signature].asString().empty()); + // KEY ASSERTION: loadType must be kFEE_HEAVY_BURDEN_RPC after the fix + // Before fix: FAILS — stays at kFEE_REFERENCE_RPC (charge=20) + // After fix: PASSES — set to kFEE_HEAVY_BURDEN_RPC (charge=3000) + BEAST_EXPECT(context.loadType == Resource::kFeeHeavyBurdenRpc); + } + void testMalformedPK(FeatureBitset features) { @@ -1983,6 +2132,8 @@ struct PayChan_test : public beast::unit_test::Suite testMetaAndOwnership(features); testAccountDelete(features); testUsingTickets(features); + testChannelVerifyLoadType(features); + testChannelAuthorizeLoadType(features); } public: diff --git a/src/xrpld/rpc/handlers/ChannelVerify.cpp b/src/xrpld/rpc/handlers/ChannelVerify.cpp index 64f616e829..b018b70516 100644 --- a/src/xrpld/rpc/handlers/ChannelVerify.cpp +++ b/src/xrpld/rpc/handlers/ChannelVerify.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include @@ -36,6 +37,8 @@ doChannelVerify(RPC::JsonContext& context) return RPC::missingFieldError(p); } + context.loadType = Resource::kFeeHeavyBurdenRpc; + std::optional pk; { std::string const strPk = params[jss::public_key].asString(); diff --git a/src/xrpld/rpc/handlers/admin/signing/ChannelAuthorize.cpp b/src/xrpld/rpc/handlers/admin/signing/ChannelAuthorize.cpp index be3ce13d45..d304992261 100644 --- a/src/xrpld/rpc/handlers/admin/signing/ChannelAuthorize.cpp +++ b/src/xrpld/rpc/handlers/admin/signing/ChannelAuthorize.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include @@ -37,6 +38,8 @@ doChannelAuthorize(RPC::JsonContext& context) return RPC::makeError(RpcNotSupported, "Signing is not supported by this server."); } + context.loadType = Resource::kFeeHeavyBurdenRpc; + auto const& params(context.params); for (auto const& p : {jss::channel_id, jss::amount}) { From 981c2569333f25e47fdcecb5f788774351894631 Mon Sep 17 00:00:00 2001 From: Bart Date: Thu, 16 Jul 2026 16:30:47 -0400 Subject: [PATCH 08/52] fix: Use weighted median for close-time offset aggregation --- src/xrpld/app/consensus/RCLConsensus.cpp | 17 +----- src/xrpld/consensus/Consensus.h | 9 ++- src/xrpld/consensus/ConsensusTypes.h | 71 ++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 15 deletions(-) diff --git a/src/xrpld/app/consensus/RCLConsensus.cpp b/src/xrpld/app/consensus/RCLConsensus.cpp index 4abf77f578..bddd6f3ffb 100644 --- a/src/xrpld/app/consensus/RCLConsensus.cpp +++ b/src/xrpld/app/consensus/RCLConsensus.cpp @@ -686,28 +686,17 @@ RCLConsensus::Adaptor::doAccept( // close time reports, and update our clock. if ((mode == ConsensusMode::Proposing || mode == ConsensusMode::Observing) && !consensusFail) { - auto closeTime = rawCloseTimes.self; - - JLOG(j_.info()) << "We closed at " << closeTime.time_since_epoch().count(); - using usec64_t = std::chrono::duration; - auto closeTotal = std::chrono::duration_cast(closeTime.time_since_epoch()); + JLOG(j_.info()) << "We closed at " << rawCloseTimes.self.time_since_epoch().count(); int closeCount = 1; - for (auto const& [t, v] : rawCloseTimes.peers) { JLOG(j_.info()) << std::to_string(v) << " time votes for " << std::to_string(t.time_since_epoch().count()); closeCount += v; - closeTotal += std::chrono::duration_cast(t.time_since_epoch()) * v; } - closeTotal += usec64_t(closeCount / 2); // for round to nearest - closeTotal /= closeCount; - - // Use signed times since we are subtracting - using duration = std::chrono::duration; - using time_point = std::chrono::time_point; - auto offset = time_point{closeTotal} - std::chrono::time_point_cast(closeTime); + // Median handles outliers better than mean. + auto const offset = medianCloseOffset(rawCloseTimes); JLOG(j_.info()) << "Our close offset is estimated at " << offset.count() << " (" << closeCount << ")"; diff --git a/src/xrpld/consensus/Consensus.h b/src/xrpld/consensus/Consensus.h index 440191939b..19def76ec0 100644 --- a/src/xrpld/consensus/Consensus.h +++ b/src/xrpld/consensus/Consensus.h @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -1580,7 +1581,13 @@ Consensus::updateOurPositions(std::unique_ptr const& JLOG(j_.info()) << ss.str(); CLOG(clog) << ss.str(); - for (auto const& [t, v] : closeTimeVotes) + // Walk the votes highest-time first so that, among close times tied + // for the most votes, the earliest wins. The smaller value is the + // safer choice: without close-time consensus this round, the winner + // only updates our position for the next proposal, and a too-early + // time is bounded below by the prior ledger's close time. Only the + // tie-break changes; the bin with the most votes still wins. + for (auto const& [t, v] : std::views::reverse(closeTimeVotes)) { JLOG(j_.debug()) << "CCTime: seq " << static_cast(previousLedger_.seq()) + 1 << ": " diff --git a/src/xrpld/consensus/ConsensusTypes.h b/src/xrpld/consensus/ConsensusTypes.h index 4553e46f48..063d18cb63 100644 --- a/src/xrpld/consensus/ConsensusTypes.h +++ b/src/xrpld/consensus/ConsensusTypes.h @@ -9,7 +9,9 @@ #include #include +#include #include +#include #include namespace xrpl { @@ -190,6 +192,75 @@ struct ConsensusCloseTimes NetClock::time_point self; }; +/** + * Offset of the network's close time relative to ours, using a weighted median. + * + * Treats the sample set as `{self x 1}` merged with `{t x w}` for each + * `(t, w)` in `times.peers`, in time order, and returns `(median - self)` + * in whole seconds. Uses the lower weighted median: the median is the + * earliest time at which the running weight reaches half the total, so an + * even total whose halfway point falls between two bins resolves to the + * earlier bin. + * + * @param times Our own close time and the weighted close times of peers. + * @return Weighted median of all close times minus our own, in whole seconds. + */ +inline std::chrono::seconds +medianCloseOffset(ConsensusCloseTimes const& times) +{ + using namespace std::chrono; + using time_point = NetClock::time_point; + + std::int64_t totalWeight = 1; + for (auto const& [_, w] : times.peers) + totalWeight += w; + + std::int64_t const halfWeight = (totalWeight + 1) / 2; + + std::optional median{}; + std::int64_t tally = 0; + bool selfPlaced = false; + + // Accumulate weight in time order; the first bin to reach halfWeight is + // the (lower) weighted median. Returns true once that bin is found. + auto step = [&](time_point t, std::int64_t w) { + XRPL_ASSERT(tally < halfWeight, "xrpl::medianCloseOffset::step : median not yet found"); + tally += w; + if (tally >= halfWeight) + { + median = t; + return true; + } + return false; + }; + + for (auto const& [t, w] : times.peers) + { + if (!selfPlaced && times.self <= t) + { + selfPlaced = true; + if (step(times.self, 1)) + break; + } + if (step(t, w)) + break; + } + if (!selfPlaced && !median) + step(times.self, 1); + + if (!median) + { + // LCOV_EXCL_START + UNREACHABLE("xrpl::medianCloseOffset : median not found"); + median = times.self; + // LCOV_EXCL_STOP + } + + return duration_cast( + duration{median->time_since_epoch().count()} - + duration{times.self.time_since_epoch().count()}); +} + /** * Whether we have or don't have a consensus */ From 4a9ee54c88118ff0ab869ec81b8e1066f50f137b Mon Sep 17 00:00:00 2001 From: Bart Date: Thu, 16 Jul 2026 16:29:31 -0400 Subject: [PATCH 09/52] fix: Handle malformed ledger replay responses --- include/xrpl/resource/Fees.h | 1 + src/libxrpl/resource/Fees.cpp | 1 + src/test/app/LedgerReplay_test.cpp | 130 +++++++++++++++--- .../ledger/detail/LedgerReplayMsgHandler.cpp | 112 ++++++++++----- .../ledger/detail/LedgerReplayMsgHandler.h | 21 ++- src/xrpld/overlay/detail/PeerImp.cpp | 22 ++- 6 files changed, 219 insertions(+), 68 deletions(-) diff --git a/include/xrpl/resource/Fees.h b/include/xrpl/resource/Fees.h index 5001b504d6..a7c923dfef 100644 --- a/include/xrpl/resource/Fees.h +++ b/include/xrpl/resource/Fees.h @@ -13,6 +13,7 @@ extern Charge const kFeeRequestNoReply; // A request that we cannot satisfy. extern Charge const kFeeInvalidSignature; // An object whose signature we had to check that failed. extern Charge const kFeeUselessData; // Data we have no use for. extern Charge const kFeeInvalidData; // Data we have to verify before rejecting. +extern Charge const kFeeMalformedData; // Data that no honest peer would send. // RPC loads extern Charge const kFeeMalformedRpc; // An RPC request that we can immediately tell is invalid. diff --git a/src/libxrpl/resource/Fees.cpp b/src/libxrpl/resource/Fees.cpp index bb825fa3c7..6792dd7e36 100644 --- a/src/libxrpl/resource/Fees.cpp +++ b/src/libxrpl/resource/Fees.cpp @@ -9,6 +9,7 @@ Charge const kFeeRequestNoReply(10, "unsatisfiable request"); Charge const kFeeInvalidSignature(2000, "invalid signature"); Charge const kFeeUselessData(150, "useless data"); Charge const kFeeInvalidData(400, "invalid data"); +Charge const kFeeMalformedData(2000, "malformed data"); Charge const kFeeMalformedRpc(100, "malformed RPC"); Charge const kFeeReferenceRpc(20, "reference RPC"); diff --git a/src/test/app/LedgerReplay_test.cpp b/src/test/app/LedgerReplay_test.cpp index f08d6548e0..27e8a8030b 100644 --- a/src/test/app/LedgerReplay_test.cpp +++ b/src/test/app/LedgerReplay_test.cpp @@ -58,6 +58,8 @@ #include #include #include +#include +#include #include #include #include @@ -958,7 +960,8 @@ struct LedgerReplayer_test : public beast::unit_test::Suite auto reply = std::make_shared( server.msgHandler.processProofPathRequest(request)); BEAST_EXPECT(reply->has_error()); - BEAST_EXPECT(!server.msgHandler.processProofPathResponse(reply)); + BEAST_EXPECT( + server.msgHandler.processProofPathResponse(reply) == ReplayMsgStatus::BadData); } { // request, wrong hash @@ -982,7 +985,7 @@ struct LedgerReplayer_test : public beast::unit_test::Suite auto reply = std::make_shared( server.msgHandler.processProofPathRequest(request)); BEAST_EXPECT(!reply->has_error()); - BEAST_EXPECT(server.msgHandler.processProofPathResponse(reply)); + BEAST_EXPECT(server.msgHandler.processProofPathResponse(reply) == ReplayMsgStatus::Ok); { // bad reply: invalid hash/key sizes @@ -990,37 +993,49 @@ struct LedgerReplayer_test : public beast::unit_test::Suite // reply with undersized ledgerhash (31 bytes) auto bad = std::make_shared(*reply); bad->set_ledgerhash(std::string(31, '\x01')); - BEAST_EXPECT(!server.msgHandler.processProofPathResponse(bad)); + BEAST_EXPECT( + server.msgHandler.processProofPathResponse(bad) == + ReplayMsgStatus::Malformed); } { // reply with oversized ledgerhash (33 bytes) auto bad = std::make_shared(*reply); bad->set_ledgerhash(std::string(33, '\x01')); - BEAST_EXPECT(!server.msgHandler.processProofPathResponse(bad)); + BEAST_EXPECT( + server.msgHandler.processProofPathResponse(bad) == + ReplayMsgStatus::Malformed); } { // reply with empty ledgerhash auto bad = std::make_shared(*reply); bad->set_ledgerhash(std::string()); - BEAST_EXPECT(!server.msgHandler.processProofPathResponse(bad)); + BEAST_EXPECT( + server.msgHandler.processProofPathResponse(bad) == + ReplayMsgStatus::Malformed); } { // reply with undersized key (31 bytes) auto bad = std::make_shared(*reply); bad->set_key(std::string(31, '\x01')); - BEAST_EXPECT(!server.msgHandler.processProofPathResponse(bad)); + BEAST_EXPECT( + server.msgHandler.processProofPathResponse(bad) == + ReplayMsgStatus::Malformed); } { // reply with oversized key (33 bytes) auto bad = std::make_shared(*reply); bad->set_key(std::string(33, '\x01')); - BEAST_EXPECT(!server.msgHandler.processProofPathResponse(bad)); + BEAST_EXPECT( + server.msgHandler.processProofPathResponse(bad) == + ReplayMsgStatus::Malformed); } { // reply with empty key auto bad = std::make_shared(*reply); bad->set_key(std::string()); - BEAST_EXPECT(!server.msgHandler.processProofPathResponse(bad)); + BEAST_EXPECT( + server.msgHandler.processProofPathResponse(bad) == + ReplayMsgStatus::Malformed); } } @@ -1030,13 +1045,18 @@ struct LedgerReplayer_test : public beast::unit_test::Suite std::string r(reply->ledgerheader()); r.back()--; reply->set_ledgerheader(r); - BEAST_EXPECT(!server.msgHandler.processProofPathResponse(reply)); + BEAST_EXPECT( + server.msgHandler.processProofPathResponse(reply) == + ReplayMsgStatus::Malformed); r.back()++; reply->set_ledgerheader(r); - BEAST_EXPECT(server.msgHandler.processProofPathResponse(reply)); + BEAST_EXPECT( + server.msgHandler.processProofPathResponse(reply) == ReplayMsgStatus::Ok); // bad proof path reply->mutable_path()->RemoveLast(); - BEAST_EXPECT(!server.msgHandler.processProofPathResponse(reply)); + BEAST_EXPECT( + server.msgHandler.processProofPathResponse(reply) == + ReplayMsgStatus::Malformed); } } } @@ -1054,14 +1074,16 @@ struct LedgerReplayer_test : public beast::unit_test::Suite auto reply = std::make_shared( server.msgHandler.processReplayDeltaRequest(request)); BEAST_EXPECT(reply->has_error()); - BEAST_EXPECT(!server.msgHandler.processReplayDeltaResponse(reply)); + BEAST_EXPECT( + server.msgHandler.processReplayDeltaResponse(reply) == ReplayMsgStatus::BadData); // request, wrong hash uint256 hash(1234567); request->set_ledgerhash(hash.data(), hash.size()); reply = std::make_shared( server.msgHandler.processReplayDeltaRequest(request)); BEAST_EXPECT(reply->has_error()); - BEAST_EXPECT(!server.msgHandler.processReplayDeltaResponse(reply)); + BEAST_EXPECT( + server.msgHandler.processReplayDeltaResponse(reply) == ReplayMsgStatus::BadData); } { @@ -1071,7 +1093,8 @@ struct LedgerReplayer_test : public beast::unit_test::Suite auto reply = std::make_shared( server.msgHandler.processReplayDeltaRequest(request)); BEAST_EXPECT(!reply->has_error()); - BEAST_EXPECT(server.msgHandler.processReplayDeltaResponse(reply)); + BEAST_EXPECT( + server.msgHandler.processReplayDeltaResponse(reply) == ReplayMsgStatus::Ok); { // bad reply: invalid hash sizes @@ -1079,19 +1102,25 @@ struct LedgerReplayer_test : public beast::unit_test::Suite // reply with undersized ledgerhash (31 bytes) auto bad = std::make_shared(*reply); bad->set_ledgerhash(std::string(31, '\x01')); - BEAST_EXPECT(!server.msgHandler.processReplayDeltaResponse(bad)); + BEAST_EXPECT( + server.msgHandler.processReplayDeltaResponse(bad) == + ReplayMsgStatus::Malformed); } { // reply with oversized ledgerhash (33 bytes) auto bad = std::make_shared(*reply); bad->set_ledgerhash(std::string(33, '\x01')); - BEAST_EXPECT(!server.msgHandler.processReplayDeltaResponse(bad)); + BEAST_EXPECT( + server.msgHandler.processReplayDeltaResponse(bad) == + ReplayMsgStatus::Malformed); } { // reply with empty ledgerhash auto bad = std::make_shared(*reply); bad->set_ledgerhash(std::string()); - BEAST_EXPECT(!server.msgHandler.processReplayDeltaResponse(bad)); + BEAST_EXPECT( + server.msgHandler.processReplayDeltaResponse(bad) == + ReplayMsgStatus::Malformed); } } @@ -1101,17 +1130,77 @@ struct LedgerReplayer_test : public beast::unit_test::Suite std::string r(reply->ledgerheader()); r.back()--; reply->set_ledgerheader(r); - BEAST_EXPECT(!server.msgHandler.processReplayDeltaResponse(reply)); + BEAST_EXPECT( + server.msgHandler.processReplayDeltaResponse(reply) == + ReplayMsgStatus::Malformed); r.back()++; reply->set_ledgerheader(r); - BEAST_EXPECT(server.msgHandler.processReplayDeltaResponse(reply)); + BEAST_EXPECT( + server.msgHandler.processReplayDeltaResponse(reply) == ReplayMsgStatus::Ok); // bad txns reply->mutable_transaction()->RemoveLast(); - BEAST_EXPECT(!server.msgHandler.processReplayDeltaResponse(reply)); + BEAST_EXPECT( + server.msgHandler.processReplayDeltaResponse(reply) == + ReplayMsgStatus::Malformed); } } } + void + testTruncatedHeader() + { + testcase("TruncatedLedgerHeader"); + LedgerServer server(*this, {.initLedgers = 1}); + auto const l = server.ledgerMaster.getClosedLedger(); + + auto runNoThrow = [this](auto fn, char const* what) { + try + { + BEAST_EXPECT(fn() == ReplayMsgStatus::Malformed); + } + catch (std::exception const& e) + { + fail( + std::format("processor threw on truncated header ({}): {}", what, e.what()), + __FILE__, + __LINE__); + } + catch (...) + { + fail( + std::format("processor threw unknown exception ({}) on truncated header", what), + __FILE__, + __LINE__); + } + }; + + { + auto request = std::make_shared(); + request->set_ledgerhash(l->header().hash.data(), l->header().hash.size()); + auto reply = std::make_shared( + server.msgHandler.processReplayDeltaRequest(request)); + BEAST_EXPECT(!reply->has_error()); + + reply->set_ledgerheader(std::string(1, '\x00')); + runNoThrow( + [&] { return server.msgHandler.processReplayDeltaResponse(reply); }, "ReplayDelta"); + } + + { + auto request = std::make_shared(); + request->set_ledgerhash(l->header().hash.data(), l->header().hash.size()); + request->set_type(protocol::TMLedgerMapType::lmACCOUNT_STATE); + request->set_key(keylet::skip().key.data(), keylet::skip().key.size()); + auto reply = std::make_shared( + server.msgHandler.processProofPathRequest(request)); + BEAST_EXPECT(!reply->has_error()); + + reply->set_ledgerheader(std::string(1, '\x00')); + runNoThrow( + [&] { return server.msgHandler.processProofPathResponse(reply); }, "ProofPath"); + } + } + void testTaskParameter() { @@ -1514,6 +1603,7 @@ struct LedgerReplayer_test : public beast::unit_test::Suite { testProofPath(); testReplayDelta(); + testTruncatedHeader(); testTaskParameter(); testConfig(); testHandshake(); diff --git a/src/xrpld/app/ledger/detail/LedgerReplayMsgHandler.cpp b/src/xrpld/app/ledger/detail/LedgerReplayMsgHandler.cpp index 07738d99f4..6ed4a296ac 100644 --- a/src/xrpld/app/ledger/detail/LedgerReplayMsgHandler.cpp +++ b/src/xrpld/app/ledger/detail/LedgerReplayMsgHandler.cpp @@ -101,42 +101,54 @@ LedgerReplayMsgHandler::processProofPathRequest( return reply; } -bool +ReplayMsgStatus LedgerReplayMsgHandler::processProofPathResponse( std::shared_ptr const& msg) { protocol::TMProofPathResponse const& reply = *msg; - if (reply.has_error() || !reply.has_key() || !reply.has_ledgerhash() || !reply.has_type() || + if (reply.has_error()) + { + JLOG(journal_.debug()) << "ProofPathResponse: peer reported error"; + return ReplayMsgStatus::BadData; + } + if (!reply.has_key() || !reply.has_ledgerhash() || !reply.has_type() || !reply.has_ledgerheader() || reply.path_size() == 0 || reply.ledgerhash().size() != uint256::size() || reply.key().size() != uint256::size()) { - JLOG(journal_.debug()) << "Bad message: Error reply"; - return false; + JLOG(journal_.debug()) << "ProofPathResponse: malformed (missing or wrong-size fields)"; + return ReplayMsgStatus::Malformed; } if (reply.type() != protocol::lmACCOUNT_STATE) { - JLOG(journal_.debug()) << "Bad message: we only support the state ShaMap for now"; - return false; + JLOG(journal_.debug()) << "ProofPathResponse: malformed (unsupported map type)"; + return ReplayMsgStatus::Malformed; } // deserialize the header - auto info = deserializeHeader({reply.ledgerheader().data(), reply.ledgerheader().size()}); + LedgerHeader info; + try + { + info = deserializeHeader(makeSlice(reply.ledgerheader())); + } + catch (std::exception const& e) + { + JLOG(journal_.debug()) << "ProofPathResponse: malformed header (" << e.what() << ")"; + return ReplayMsgStatus::Malformed; + } uint256 const replyHash = uint256::fromRaw(reply.ledgerhash()); if (calculateLedgerHash(info) != replyHash) { - JLOG(journal_.debug()) << "Bad message: Hash mismatch"; - return false; + JLOG(journal_.debug()) << "ProofPathResponse: malformed (hash mismatch)"; + return ReplayMsgStatus::Malformed; } info.hash = replyHash; uint256 const key = uint256::fromRaw(reply.key()); if (key != keylet::skip().key) { - JLOG(journal_.debug()) << "Bad message: we only support the short skip list for now. " - "Key in reply " - << key; - return false; + JLOG(journal_.debug()) << "ProofPathResponse: malformed (unexpected key " << key << ")"; + return ReplayMsgStatus::Malformed; } // verify the skip list @@ -149,26 +161,35 @@ LedgerReplayMsgHandler::processProofPathResponse( if (!SHAMap::verifyProofPath(info.accountHash, key, path)) { - JLOG(journal_.debug()) << "Bad message: Proof path verify failed"; - return false; + JLOG(journal_.debug()) << "ProofPathResponse: malformed (proof path verify failed)"; + return ReplayMsgStatus::Malformed; } // deserialize the SHAMapItem - auto node = SHAMapTreeNode::makeFromWire(makeSlice(path.front())); + SHAMapTreeNodePtr node; + try + { + node = SHAMapTreeNode::makeFromWire(makeSlice(path.front())); + } + catch (std::exception const& e) + { + JLOG(journal_.debug()) << "ProofPathResponse: malformed SHAMap node (" << e.what() << ")"; + return ReplayMsgStatus::Malformed; + } if (!node || !node->isLeaf()) { - JLOG(journal_.debug()) << "Bad message: Cannot deserialize"; - return false; + JLOG(journal_.debug()) << "ProofPathResponse: malformed (not a leaf node)"; + return ReplayMsgStatus::Malformed; } if (auto item = safeDowncast(node.get())->peekItem()) { replayer_.gotSkipList(info, item); - return true; + return ReplayMsgStatus::Ok; } - JLOG(journal_.debug()) << "Bad message: Cannot get ShaMapItem"; - return false; + JLOG(journal_.debug()) << "ProofPathResponse: malformed (no SHAMapItem)"; + return ReplayMsgStatus::Malformed; } protocol::TMReplayDeltaResponse @@ -210,24 +231,38 @@ LedgerReplayMsgHandler::processReplayDeltaRequest( return reply; } -bool +ReplayMsgStatus LedgerReplayMsgHandler::processReplayDeltaResponse( std::shared_ptr const& msg) { protocol::TMReplayDeltaResponse const& reply = *msg; - if (reply.has_error() || !reply.has_ledgerheader() || !reply.has_ledgerhash() || + if (reply.has_error()) + { + JLOG(journal_.debug()) << "ReplayDeltaResponse: peer reported error"; + return ReplayMsgStatus::BadData; + } + if (!reply.has_ledgerheader() || !reply.has_ledgerhash() || reply.ledgerhash().size() != uint256::size()) { - JLOG(journal_.debug()) << "Bad message: Error reply"; - return false; + JLOG(journal_.debug()) << "ReplayDeltaResponse: malformed (missing or wrong-size fields)"; + return ReplayMsgStatus::Malformed; } - auto info = deserializeHeader({reply.ledgerheader().data(), reply.ledgerheader().size()}); + LedgerHeader info; + try + { + info = deserializeHeader(makeSlice(reply.ledgerheader())); + } + catch (std::exception const& e) + { + JLOG(journal_.debug()) << "ReplayDeltaResponse: malformed header (" << e.what() << ")"; + return ReplayMsgStatus::Malformed; + } uint256 const replyHash = uint256::fromRaw(reply.ledgerhash()); if (calculateLedgerHash(info) != replyHash) { - JLOG(journal_.debug()) << "Bad message: Hash mismatch"; - return false; + JLOG(journal_.debug()) << "ReplayDeltaResponse: malformed (hash mismatch)"; + return ReplayMsgStatus::Malformed; } info.hash = replyHash; @@ -252,8 +287,8 @@ LedgerReplayMsgHandler::processReplayDeltaResponse( auto tx = std::make_shared(txSit); if (!tx) { - JLOG(journal_.debug()) << "Bad message: Cannot deserialize"; - return false; + JLOG(journal_.debug()) << "ReplayDeltaResponse: malformed (tx deserialize)"; + return ReplayMsgStatus::Malformed; } auto tid = tx->getTransactionID(); STObject meta(metaSit, sfMetadata); @@ -262,25 +297,26 @@ LedgerReplayMsgHandler::processReplayDeltaResponse( if (!txMap.addGiveItem( SHAMapNodeType::TnTransactionMd, makeShamapitem(tid, shaMapItemData.slice()))) { - JLOG(journal_.debug()) << "Bad message: Cannot deserialize"; - return false; + JLOG(journal_.debug()) << "ReplayDeltaResponse: malformed (tx map add)"; + return ReplayMsgStatus::Malformed; } } } - catch (std::exception const&) + catch (std::exception const& e) { - JLOG(journal_.debug()) << "Bad message: Cannot deserialize"; - return false; + JLOG(journal_.debug()) << "ReplayDeltaResponse: malformed transactions (" << e.what() + << ")"; + return ReplayMsgStatus::Malformed; } if (txMap.getHash().asUInt256() != info.txHash) { - JLOG(journal_.debug()) << "Bad message: Transactions verify failed"; - return false; + JLOG(journal_.debug()) << "ReplayDeltaResponse: malformed (transactions verify failed)"; + return ReplayMsgStatus::Malformed; } replayer_.gotReplayDelta(info, std::move(orderedTxns)); - return true; + return ReplayMsgStatus::Ok; } } // namespace xrpl diff --git a/src/xrpld/app/ledger/detail/LedgerReplayMsgHandler.h b/src/xrpld/app/ledger/detail/LedgerReplayMsgHandler.h index 5a8951fb25..ba989e2586 100644 --- a/src/xrpld/app/ledger/detail/LedgerReplayMsgHandler.h +++ b/src/xrpld/app/ledger/detail/LedgerReplayMsgHandler.h @@ -10,6 +10,15 @@ namespace xrpl { class Application; class LedgerReplayer; +/** + * Outcome of processing an incoming ledger-replay response. + */ +enum class ReplayMsgStatus { + Ok, ///< Accepted. + BadData, ///< Peer reported has_error() (legitimate "cannot fulfill" signal). + Malformed, ///< Protocol-level violation; no honest peer would produce this. +}; + class LedgerReplayMsgHandler final { public: @@ -19,31 +28,31 @@ public: /** * Process TMProofPathRequest and return TMProofPathResponse * @note check has_error() and error() of the response for error + * @return TMProofPathResponse with the proof path, or with error() set if + * the request cannot be fulfilled */ protocol::TMProofPathResponse processProofPathRequest(std::shared_ptr const& msg); /** * Process TMProofPathResponse - * @return false if the response message has bad format or bad data; - * true otherwise */ - bool + ReplayMsgStatus processProofPathResponse(std::shared_ptr const& msg); /** * Process TMReplayDeltaRequest and return TMReplayDeltaResponse * @note check has_error() and error() of the response for error + * @return TMReplayDeltaResponse with the ledger delta, or with error() set + * if the request cannot be fulfilled */ protocol::TMReplayDeltaResponse processReplayDeltaRequest(std::shared_ptr const& msg); /** * Process TMReplayDeltaResponse - * @return false if the response message has bad format or bad data; - * true otherwise */ - bool + ReplayMsgStatus processReplayDeltaResponse(std::shared_ptr const& msg); private: diff --git a/src/xrpld/overlay/detail/PeerImp.cpp b/src/xrpld/overlay/detail/PeerImp.cpp index 8838970b5f..35eecc6439 100644 --- a/src/xrpld/overlay/detail/PeerImp.cpp +++ b/src/xrpld/overlay/detail/PeerImp.cpp @@ -1565,9 +1565,16 @@ PeerImp::onMessage(std::shared_ptr const& m) return; } - if (!ledgerReplayMsgHandler_.processProofPathResponse(m)) + switch (ledgerReplayMsgHandler_.processProofPathResponse(m)) { - fee_.update(Resource::kFeeInvalidData, "proof_path_response"); + case ReplayMsgStatus::Ok: + break; + case ReplayMsgStatus::BadData: + fee_.update(Resource::kFeeInvalidData, "proof_path_response"); + break; + case ReplayMsgStatus::Malformed: + fee_.update(Resource::kFeeMalformedData, "proof_path_response malformed"); + break; } } @@ -1615,9 +1622,16 @@ PeerImp::onMessage(std::shared_ptr const& m) return; } - if (!ledgerReplayMsgHandler_.processReplayDeltaResponse(m)) + switch (ledgerReplayMsgHandler_.processReplayDeltaResponse(m)) { - fee_.update(Resource::kFeeInvalidData, "replay_delta_response"); + case ReplayMsgStatus::Ok: + break; + case ReplayMsgStatus::BadData: + fee_.update(Resource::kFeeInvalidData, "replay_delta_response"); + break; + case ReplayMsgStatus::Malformed: + fee_.update(Resource::kFeeMalformedData, "replay_delta_response malformed"); + break; } } From 5ab95748d49a8085013a85dfc4a796366c8a68eb Mon Sep 17 00:00:00 2001 From: Bart Date: Thu, 16 Jul 2026 16:25:00 -0400 Subject: [PATCH 10/52] refactor: Clean up pong replies --- include/xrpl/proto/xrpl.proto | 7 ++++--- src/xrpld/overlay/detail/PeerImp.cpp | 9 ++++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/include/xrpl/proto/xrpl.proto b/include/xrpl/proto/xrpl.proto index d49920201e..302120d1be 100644 --- a/include/xrpl/proto/xrpl.proto +++ b/include/xrpl/proto/xrpl.proto @@ -293,14 +293,15 @@ message TMLedgerData { } message TMPing { + // Previously used - don't reuse. + reserved 3, 4; + enum pingType { ptPING = 0; // we want a reply ptPONG = 1; // this is a reply } required pingType type = 1; - optional uint32 seq = 2; // detect stale replies, ensure other side is reading - optional uint64 pingTime = 3; // know when we think we sent the ping - optional uint64 netTime = 4; + optional uint32 seq = 2; // detect stale replies, ensure other side is reading } message TMSquelch { diff --git a/src/xrpld/overlay/detail/PeerImp.cpp b/src/xrpld/overlay/detail/PeerImp.cpp index 35eecc6439..5662b6d33d 100644 --- a/src/xrpld/overlay/detail/PeerImp.cpp +++ b/src/xrpld/overlay/detail/PeerImp.cpp @@ -1113,10 +1113,13 @@ PeerImp::onMessage(std::shared_ptr const& m) { if (m->type() == protocol::TMPing::ptPING) { - // We have received a ping request, reply with a pong + // We have received a ping request, reply with a pong. fee_.update(Resource::kFeeModerateBurdenPeer, "ping request"); - m->set_type(protocol::TMPing::ptPONG); - send(std::make_shared(*m, protocol::mtPING)); + protocol::TMPing pong; + pong.set_type(protocol::TMPing::ptPONG); + if (m->has_seq()) + pong.set_seq(m->seq()); + send(std::make_shared(pong, protocol::mtPING)); return; } From 1dcaf4b54eaf461cb91cc9bc3cc9fc4ae40af11f Mon Sep 17 00:00:00 2001 From: Bart Date: Thu, 16 Jul 2026 16:24:06 -0400 Subject: [PATCH 11/52] fix: Bound and offload per-connection subscription cleanup --- cfg/xrpld-example.cfg | 11 + include/xrpl/config/Constants.h | 1 + include/xrpl/server/InfoSub.h | 142 +++- src/libxrpl/server/InfoSub.cpp | 116 ++- src/test/rpc/Subscribe_test.cpp | 416 +++++++++++ src/tests/libxrpl/CMakeLists.txt | 1 + src/tests/libxrpl/server/InfoSub.cpp | 60 ++ src/xrpld/app/ledger/AcceptedLedger.h | 9 + src/xrpld/app/misc/NetworkOPs.cpp | 667 +++++++++++++----- src/xrpld/core/Config.h | 6 + src/xrpld/core/detail/Config.cpp | 3 + .../rpc/handlers/subscribe/Subscribe.cpp | 83 ++- 12 files changed, 1301 insertions(+), 214 deletions(-) create mode 100644 src/tests/libxrpl/server/InfoSub.cpp diff --git a/cfg/xrpld-example.cfg b/cfg/xrpld-example.cfg index 9e334e6f4f..6a44561c68 100644 --- a/cfg/xrpld-example.cfg +++ b/cfg/xrpld-example.cfg @@ -488,6 +488,17 @@ # Must be a number between 100 and 1000, defaults to 250 # # +# [max_subscriptions_per_connection] +# +# Maximum number of account, real-time account, and account-history +# subscriptions a single client connection may hold at once. Bounds the +# per-connection state torn down when the connection disconnects. Book +# subscriptions are tracked separately and are not counted here. +# +# Defaults to 100000 if not set; large enough for legitimate power users +# such as block explorers. +# +# # [overlay] # # Controls settings related to the peer to peer overlay. diff --git a/include/xrpl/config/Constants.h b/include/xrpl/config/Constants.h index 5514e0e77b..0fe4efee63 100644 --- a/include/xrpl/config/Constants.h +++ b/include/xrpl/config/Constants.h @@ -25,6 +25,7 @@ struct Sections static constexpr auto kLedgerHistory = "ledger_history"; static constexpr auto kLedgerReplay = "ledger_replay"; static constexpr auto kLedgerTxTables = "ledger_tx_tables"; + static constexpr auto kMaxSubscriptionsPerConnection = "max_subscriptions_per_connection"; static constexpr auto kMaxTransactions = "max_transactions"; static constexpr auto kNetworkId = "network_id"; static constexpr auto kNetworkQuorum = "network_quorum"; diff --git a/include/xrpl/server/InfoSub.h b/include/xrpl/server/InfoSub.h index 2e9bd857c7..1c12d9e520 100644 --- a/include/xrpl/server/InfoSub.h +++ b/include/xrpl/server/InfoSub.h @@ -11,6 +11,7 @@ #include #include +#include #include #include #include @@ -22,6 +23,39 @@ namespace xrpl { // Operations that clients may wish to perform against the network // Master operational handler, server sequencer, network tracker +/** + * Maximum number of subscriptions a single client connection may hold at once. + * + * Applies to the account, real-time account, and account-history subscriptions + * tracked on one InfoSub (the sets counted by totalSubscriptionCount), bounding + * the disconnect-time cleanup of those sets. Book subscriptions are tracked + * separately (OrderBookDB) and are not counted here. Generous enough for + * legitimate power users such as block explorers. + */ +constexpr std::size_t kMaxSubscriptionsPerConnection = 100'000; + +/** + * Whether adding @p additional subscriptions to a connection already holding + * @p current would exceed the cap. + * + * Pure arithmetic split out so it can be unit-tested without a live + * connection. The first term avoids underflow in the subtraction. + * + * @param current Subscriptions already tracked on the connection. + * @param additional Subscriptions a request would add. + * @param cap The effective per-connection cap. Defaults to the + * built-in limit; callers may pass a configured override. + * @return true if the request must be rejected to stay within the cap. + */ +[[nodiscard]] constexpr bool +exceedsSubscriptionCap( + std::size_t current, + std::size_t additional, + std::size_t cap = kMaxSubscriptionsPerConnection) +{ + return additional > cap || current > cap - additional; +} + class InfoSubRequest : public CountedObject { public: @@ -44,12 +78,12 @@ public: * map. * * @note Lifetime contract: every `InfoSub` instance MUST be destroyed - * before the backing `Source`. NetworkOPsImp shutdown drops all - * subscriber strong refs before its own teardown to satisfy this. + * before the backing `Source`. NetworkOPsImp shutdown drops all + * subscriber strong refs before its own teardown to satisfy this. * @note Thread-safety: per-instance state is guarded by `lock_`. The - * destructor reads tracking sets without taking `lock_` because - * the strong-pointer ref-count is zero at destruction time, so - * no other thread can be calling the public mutators. + * destructor reads tracking sets without taking `lock_` because + * the strong-pointer ref-count is zero at destruction time, so + * no other thread can be calling the public mutators. */ class InfoSub : public CountedObject { @@ -117,6 +151,34 @@ public: AccountID const& account, bool historyOnly) = 0; + /** + * Schedule the server-side teardown of a disconnecting connection's + * account subscriptions off the destructor thread. + * + * The implementation posts a low-priority JobQueue task that erases the + * entries in bounded chunks, so `~InfoSub` returns immediately instead + * of running the erase loop inline. The sets are taken by value so the + * job owns its copies and never references the destroyed `InfoSub`. + * Cleanup is keyed on `seq` (unique per connection), so deferring it + * cannot disturb a reconnected client reusing the same accounts. + * + * @param seq The disconnecting connection's unique subscription id. + * @param rtAccounts Real-time account subscriptions to remove. + * @param normalAccounts Normal account subscriptions to remove. + * @param historyAccounts Account-history subscriptions to remove. + * + * @note The implementing `Source` must outlive any job it posts. If the + * JobQueue is already stopping (process shutdown), the job is not + * enqueued; the cleanup is skipped because the server-side maps + * are about to be destroyed and no publishing can run. + */ + virtual void + scheduleAccountCleanup( + std::uint64_t seq, + hash_set rtAccounts, + hash_set normalAccounts, + hash_set historyAccounts) = 0; + // VFALCO TODO Document the bool return value virtual bool subLedger(ref ispListener, json::Value& jvResult) = 0; @@ -153,12 +215,12 @@ public: * @param ispListener The subscriber requesting removal. * @param book The order book to unsubscribe from. * @return true if the entry was present and removed, false if the - * subscriber was not subscribed to @p book. + * subscriber was not subscribed to @p book. * - * @note Thread-safety: acquires subLock_ internally. + * @note Thread-safety: acquires bookLock_ internally. * @note Do NOT call from ~InfoSub(). Use unsubBookInternal instead - * to avoid a redundant write-back to bookSubscriptions_ on a - * partially-destroyed object. + * to avoid a redundant write-back to bookSubscriptions_ on a + * partially-destroyed object. */ virtual bool unsubBook(ref ispListener, Book const&) = 0; @@ -173,9 +235,9 @@ public: * @param uListener The sequence number of the subscriber being torn down. * @param book The order book entry to remove. * @return true if the entry was present and removed, false otherwise - * (e.g., already removed by a concurrent RPC unsubscribe). + * (e.g., already removed by a concurrent RPC unsubscribe). * - * @note Thread-safety: acquires subLock_ internally. + * @note Thread-safety: acquires bookLock_ internally. */ virtual bool unsubBookInternal(std::uint64_t uListener, Book const&) = 0; @@ -221,8 +283,8 @@ public: /** * Journal used by InfoSub for diagnostics that occur after the - * owning subsystem (e.g. application-level Logs) is the only - * surviving sink — primarily destructor-time cleanup failures. + * owning subsystem (e.g. application-level Logs) is the only + * surviving sink — primarily destructor-time cleanup failures. */ [[nodiscard]] virtual beast::Journal const& journal() const = 0; @@ -243,6 +305,56 @@ public: [[nodiscard]] std::uint64_t getSeq() const; + /** + * Return the number of subscriptions currently tracked on this + * connection. + * + * The combined size of the per-connection account, real-time account, and + * account-history subscription sets. `doSubscribe` reads this to enforce + * the per-connection subscription cap before admitting more. + * + * @return The total tracked subscription count for this connection. + * + * @note Thread-safe: takes `lock_` for the read; read-only. + */ + [[nodiscard]] std::size_t + totalSubscriptionCount() const; + + /** + * Enforce the cap and reserve a request's net-new accounts, atomically. + * + * Under one hold of `lock_`: count the net-new entries in the two sets, + * check the total against @p cap, and insert them only if it fits. + * All-or-nothing. Doing check and insert together stops two concurrent + * requests sharing an InfoSub (the admin subscribe-by-url path) from both + * passing the check before either records its accounts. The server-side + * maps are populated afterwards by subAccount, whose re-insert is a no-op. + * + * @param proposedAccounts Real-time (accounts_proposed) ids to reserve. + * @param normalAccounts Normal (accounts) ids to reserve. + * @param cap The effective per-connection cap. + * @return true if reserved; false if the request must be rejected. + * @note Thread-safe: takes `lock_`. + */ + [[nodiscard]] bool + tryReserveAccountSubscriptions( + hash_set const& proposedAccounts, + hash_set const& normalAccounts, + std::size_t cap); + + /** + * Whether this connection already tracks an account-history for @p account. + * + * `doSubscribe` reads this to charge the cap for an account_history_tx_stream + * only when it is net-new, matching the account branches. + * + * @param account The account an account_history_tx_stream would add. + * @return true if @p account is already in the account-history set. + * @note Thread-safe: takes `lock_`; read-only. + */ + [[nodiscard]] bool + hasAccountHistorySubscription(AccountID const& account) const; + void onSendEmpty(); @@ -302,7 +414,9 @@ public: getApiVersion() const noexcept; protected: - std::mutex lock_; + // Mutable so the read-only totalSubscriptionCount() accessor can lock it + // from a const method; locking semantics are otherwise unchanged. + mutable std::mutex lock_; private: Consumer consumer_; diff --git a/src/libxrpl/server/InfoSub.cpp b/src/libxrpl/server/InfoSub.cpp index 353c295856..ceb1027d95 100644 --- a/src/libxrpl/server/InfoSub.cpp +++ b/src/libxrpl/server/InfoSub.cpp @@ -7,10 +7,12 @@ #include #include +#include #include #include #include #include +#include namespace xrpl { @@ -64,6 +66,9 @@ InfoSub::InfoSub(Source& source, Consumer consumer) InfoSub::~InfoSub() { + // Stream unsubscribes are O(1): each erases this connection's single seq_ + // from one stream map, so they are cheap enough to run inline on the + // disconnect thread. // Each Source teardown call below acquires a server-side lock and // can throw. Wrap each independent call so partial failure does not // skip the remaining teardown steps. @@ -79,29 +84,48 @@ InfoSub::~InfoSub() safeUnsub(seq_, [&] { source_.unsubPeerStatus(seq_); }, j); safeUnsub(seq_, [&] { source_.unsubConsensus(seq_); }, j); - // Use the internal unsubscribe so that it won't call - // back to us and modify its own parameter - if (!realTimeSubscriptions_.empty()) - { - safeUnsub( - seq_, [&] { source_.unsubAccountInternal(seq_, realTimeSubscriptions_, true); }, j); - } - - if (!normalSubscriptions_.empty()) - { - safeUnsub( - seq_, [&] { source_.unsubAccountInternal(seq_, normalSubscriptions_, false); }, j); - } - - for (auto const& account : accountHistorySubscriptions_) - { - safeUnsub(seq_, [&] { source_.unsubAccountHistoryInternal(seq_, account, false); }, j); - } - + // Book subscriptions are torn down inline here, keyed on seq_, rather than + // through the chunked account cleanup below. The book set is not capped, so + // it can be large; but each unsubBookInternal takes bookLock_ for a single + // O(1) erase and releases it, so even a large set never holds a lock across + // the whole loop - a competing book publish can interleave between erases. + // The disconnect thread still does O(N) brief acquisitions. Use the internal + // variant so it does not write back to bookSubscriptions_ on this + // partially-destroyed object. for (auto const& book : bookSubscriptions_) { safeUnsub(seq_, [&] { source_.unsubBookInternal(seq_, book); }, j); } + + // Hand the account sets off (by move) to the Source for a chunked, + // off-thread teardown keyed on seq_, instead of erasing them inline here. + // This keeps the destructor from holding the account lock across a large + // erase loop. The job never references this object, which is being + // destroyed. + // + // Moving the sets without holding lock_ is safe: the destructor runs only + // when the last shared_ptr to this InfoSub is released, so by the + // shared_ptr contract no other thread holds a reference. Subscription maps + // store weak_ptrs, so a concurrent publisher must weak_ptr::lock() first; + // that succeeds only while a strong reference exists, which cannot overlap + // with destruction. No other thread can observe the moved-from sets. + // + // Wrapped like the steps above: scheduleAccountCleanup enqueues a JobQueue + // task, which allocates and locks and so can throw. A throw out of this + // noexcept destructor would terminate the process. Skipping the cleanup on + // throw is harmless: the account/rt maps hold weak_ptrs that the next + // publish prunes once this InfoSub is gone, and any history paging job + // self-terminates when its weak sink can no longer be locked. + safeUnsub( + seq_, + [&] { + source_.scheduleAccountCleanup( + seq_, + std::move(realTimeSubscriptions_), + std::move(normalSubscriptions_), + std::move(accountHistorySubscriptions_)); + }, + j); } Resource::Consumer& @@ -121,6 +145,53 @@ InfoSub::onSendEmpty() { } +std::size_t +InfoSub::totalSubscriptionCount() const +{ + // Hold lock_ for the whole read so the three sets cannot be mutated + // mid-count by a concurrent (un)subscribe on this connection. + std::scoped_lock const sl(lock_); + + // Combined tally the per-connection cap is enforced against. + return normalSubscriptions_.size() + realTimeSubscriptions_.size() + + accountHistorySubscriptions_.size(); +} + +bool +InfoSub::tryReserveAccountSubscriptions( + hash_set const& proposedAccounts, + hash_set const& normalAccounts, + std::size_t cap) +{ + // One lock hold covers the count, the check and the insert. + std::scoped_lock const sl(lock_); + + // Entries not already tracked; re-subscribing held accounts is not charged. + auto const countNew = [](hash_set const& requested, + hash_set const& existing) { + std::size_t fresh = 0; + for (auto const& account : requested) + { + if (!existing.contains(account)) + ++fresh; + } + return fresh; + }; + + std::size_t const additional = countNew(proposedAccounts, realTimeSubscriptions_) + + countNew(normalAccounts, normalSubscriptions_); + + std::size_t const current = normalSubscriptions_.size() + realTimeSubscriptions_.size() + + accountHistorySubscriptions_.size(); + + if (exceedsSubscriptionCap(current, additional, cap)) + return false; + + realTimeSubscriptions_.insert(proposedAccounts.begin(), proposedAccounts.end()); + normalSubscriptions_.insert(normalAccounts.begin(), normalAccounts.end()); + return true; +} + void InfoSub::insertSubAccountInfo(AccountID const& account, bool rt) { @@ -165,6 +236,13 @@ InfoSub::deleteSubAccountHistory(AccountID const& account) accountHistorySubscriptions_.erase(account); } +bool +InfoSub::hasAccountHistorySubscription(AccountID const& account) const +{ + std::scoped_lock const sl(lock_); + return accountHistorySubscriptions_.contains(account); +} + void InfoSub::insertBookSubscription(Book const& book) { diff --git a/src/test/rpc/Subscribe_test.cpp b/src/test/rpc/Subscribe_test.cpp index 97c5290947..567f31437a 100644 --- a/src/test/rpc/Subscribe_test.cpp +++ b/src/test/rpc/Subscribe_test.cpp @@ -27,6 +27,7 @@ #include #include #include +#include #include #include #include @@ -1548,6 +1549,413 @@ public: } } + // ----- Subscription limit / teardown verification ---------------------- + // + // The helpers and tests below exercise: + // * the per-connection subscription cap + proportional charge enforced + // in doSubscribe (Subscribe.cpp), and + // * the asynchronous, chunked teardown of a disconnecting connection's + // account subscriptions (~InfoSub -> scheduleAccountCleanup -> JobQueue). + // + // The cap-exceeded error is rpcINVALID_PARAMS with the message "Too many + // subscriptions for this connection."; the tests assert that exactly. + // + // There is no public accessor for the server-side per-connection count, so + // the async cleanup is verified behaviorally: publishing still flows to a + // live subscriber, rather than by reading a count to zero. + + // Build `count` distinct, valid, base58-encoded account strings cheaply by + // incrementing an AccountID. parseAccountIds dedups into a hash_set, so the + // strings MUST be distinct for the cap arithmetic to be exact; incrementing + // guarantees distinctness without deriving `count` keypairs. + static std::vector + makeAccountStrings(std::size_t count, std::uint32_t seed = 1) + { + std::vector out; + out.reserve(count); + // Start at `seed` so separate calls produce non-overlapping ranges, + // letting a test subscribe disjoint batches across requests. + AccountID id{static_cast(seed)}; + for (std::size_t i = 0; i < count; ++i) + { + out.push_back(toBase58(id)); + ++id; + } + return out; + } + + // Append the given account strings as a jss::accounts array onto a fresh + // subscribe request object. + static json::Value + accountsRequest(std::vector const& accts) + { + json::Value jv{json::ValueType::Object}; + jv[jss::accounts] = json::ValueType::Array; + for (auto const& a : accts) + jv[jss::accounts].append(a); + return jv; + } + + // Append the given account strings as a jss::accounts_proposed array onto a + // fresh subscribe request object. + static json::Value + accountsProposedRequest(std::vector const& accts) + { + json::Value jv{json::ValueType::Object}; + jv[jss::accounts_proposed] = json::ValueType::Array; + for (auto const& a : accts) + jv[jss::accounts_proposed].append(a); + return jv; + } + + // A single, valid XRP/USD order book request, as one entry of a + // jss::books array. + static json::Value + oneBookRequest() + { + using namespace jtx; + json::Value jv{json::ValueType::Object}; + jv[jss::books] = json::ValueType::Array; + json::Value& book = jv[jss::books][0u]; + book[jss::taker_gets] = json::ValueType::Object; + book[jss::taker_gets][jss::currency] = "XRP"; + book[jss::taker_pays] = json::ValueType::Object; + book[jss::taker_pays][jss::currency] = "USD"; + book[jss::taker_pays][jss::issuer] = Account("alice").human(); + return jv; + } + + // A single account_history_tx_stream subscribe request for `acct`. + static json::Value + accountHistoryRequest(std::string const& acct) + { + json::Value jv{json::ValueType::Object}; + jv[jss::account_history_tx_stream] = json::ValueType::Object; + jv[jss::account_history_tx_stream][jss::account] = acct; + return jv; + } + + // An envconfig modifier that lowers the per-connection subscription cap to + // `cap`, so the cap logic in doSubscribe can be driven without subscribing + // the production default (100'000) entries. (Env is non-movable, so this + // returns the config modifier rather than a ready-made Env.) + static auto + cappedConfig(std::size_t cap) + { + return [cap](std::unique_ptr cfg) { + cfg->maxSubscriptionsPerConnection = cap; + return jtx::singleThreadIo(std::move(cfg)); + }; + } + + void + testSubscriptionCapRejects() + { + // A request that alone exceeds the cap is rejected with the exact + // cap error, before any state is recorded. Baseline negative path. + testcase("subscription cap rejects an over-cap request"); + + using namespace jtx; + Env env{*this, envconfig(cappedConfig(5))}; + auto wsc = makeWSClient(env.app().config()); + + // Six accounts against a cap of five: rejected. + auto const jr = + wsc->invoke("subscribe", accountsRequest(makeAccountStrings(6)))[jss::result]; + BEAST_EXPECT(jr[jss::error] == "invalidParams"); + BEAST_EXPECT(jr[jss::error_message] == "Too many subscriptions for this connection."); + } + + void + testReSubscribeNotOvercounted() + { + // Re-subscribing accounts already held by this connection adds no new + // tracked state, so it must be admitted even at the cap. The cap check + // must count only NET-NEW accounts, not the raw request size. + testcase("re-subscribe at the cap is not over-counted"); + + using namespace jtx; + Env env{*this, envconfig(cappedConfig(5))}; + auto wsc = makeWSClient(env.app().config()); + + // Fill the cap exactly with five distinct accounts. + auto const five = makeAccountStrings(5); + { + auto const r = wsc->invoke("subscribe", accountsRequest(five)); + BEAST_EXPECTS(r[jss::status] == "success", to_string(r)); + } + + // Re-subscribe the same five: net-new is zero, so it stays within the + // cap and must succeed. (Pre-fix this was wrongly rejected.) + { + auto const r = wsc->invoke("subscribe", accountsRequest(five)); + BEAST_EXPECTS(r[jss::status] == "success", to_string(r)); + } + } + + void + testBooksCapIndependentOfAccounts() + { + // Book subscriptions are tracked separately (OrderBookDB) and are not + // part of totalSubscriptionCount(). An account set at the cap must not + // block an unrelated book subscription. + testcase("books cap is independent of account count"); + + using namespace jtx; + Env env{*this, envconfig(cappedConfig(5))}; + Account const alice{"alice"}; + env.fund(XRP(10000), alice); + BEAST_EXPECT(env.syncClose()); + + auto wsc = makeWSClient(env.app().config()); + + // Fill the account cap exactly. + { + auto const r = wsc->invoke("subscribe", accountsRequest(makeAccountStrings(5))); + BEAST_EXPECTS(r[jss::status] == "success", to_string(r)); + } + + // A single book subscription must still be admitted: it does not count + // against the account cap. (Pre-fix this was wrongly rejected.) + { + auto const r = wsc->invoke("subscribe", oneBookRequest()); + BEAST_EXPECTS(r[jss::status] == "success", to_string(r)); + } + } + + void + testMultiFieldNoPartialSubscribe() + { + // A single request mixing fields must be all-or-nothing: if a later + // field trips the cap, an earlier field must NOT have subscribed. The + // leak is detected through the cap arithmetic itself - a follow-up + // request succeeds only if no state leaked from the rejected one. + testcase("multi-field subscribe does not partially subscribe"); + + using namespace jtx; + Env env{*this, envconfig(cappedConfig(5))}; + auto wsc = makeWSClient(env.app().config()); + + // accounts_proposed (3, evaluated first, would subscribe) + + // accounts (3): combined 6 exceeds the cap of 5, so the request is + // rejected. The proposed branch must not have leaked its 3 entries. + json::Value req = accountsProposedRequest(makeAccountStrings(3, 1)); + for (auto const& a : makeAccountStrings(3, 100)) + req[jss::accounts].append(a); + { + auto const jr = wsc->invoke("subscribe", req)[jss::result]; + BEAST_EXPECT(jr[jss::error] == "invalidParams"); + BEAST_EXPECT(jr[jss::error_message] == "Too many subscriptions for this connection."); + } + + // If the rejected request leaked its 3 proposed subscriptions, the + // connection's count is already 3 and this 3-account request would be + // rejected (3 + 3 > 5). With no leak the count is 0 and it succeeds. + { + auto const r = wsc->invoke("subscribe", accountsRequest(makeAccountStrings(3, 200))); + BEAST_EXPECTS(r[jss::status] == "success", to_string(r)); + } + } + + void + testHistoryReSubscribeNotOvercounted() + { + // An account_history_tx_stream subscribe is charged against the cap only + // when it is net-new, matching the account branches. Re-subscribing an + // account-history already held on this connection adds no tracked entry, + // so it must NOT be rejected at the cap. The two rejection causes are + // told apart by their exact error_message: the cap check yields "Too + // many subscriptions for this connection."; a duplicate that gets past + // the cap and is rejected downstream by subAccountHistory yields the + // generic "Invalid parameters.". + testcase("account_history re-subscribe at the cap is not over-counted"); + + using namespace jtx; + Env env{*this, envconfig(cappedConfig(1))}; + Account const alice{"alice"}; + env.fund(XRP(10000), alice); + BEAST_EXPECT(env.syncClose()); + + auto wsc = makeWSClient(env.app().config()); + + // First account-history subscribe is net-new: charge 1 fills the cap of + // 1 exactly, so it is admitted. Positive path. + { + auto const r = wsc->invoke("subscribe", accountHistoryRequest(alice.human())); + BEAST_EXPECTS(r[jss::status] == "success", to_string(r)); + } + + // Re-subscribe the same account-history while sitting exactly at the + // cap. Net-new is zero, so the cap check must pass; the request is then + // rejected by subAccountHistory as a duplicate, NOT by the cap. Proven + // by the exact message: it is the duplicate error, not the cap error. + // (Pre-fix, the flat charge of 1 made the cap check reject this with the + // cap message instead.) + { + auto const jr = + wsc->invoke("subscribe", accountHistoryRequest(alice.human()))[jss::result]; + BEAST_EXPECT(jr[jss::error] == "invalidParams"); + BEAST_EXPECT(jr[jss::error_message] == "Invalid parameters."); + BEAST_EXPECT(jr[jss::error_message] != "Too many subscriptions for this connection."); + } + } + + void + testHistoryCapRejectsNetNew() + { + // A genuinely net-new account-history subscribe on a connection already + // at the cap IS rejected, with the cap error. Negative path, and the + // counterpart to testHistoryReSubscribeNotOvercounted: it confirms the + // net-new charge still rejects when the entry really is new. + testcase("account_history net-new subscribe is rejected at the cap"); + + using namespace jtx; + Env env{*this, envconfig(cappedConfig(1))}; + Account const alice{"alice"}; + Account const bob{"bob"}; + env.fund(XRP(10000), alice, bob); + BEAST_EXPECT(env.syncClose()); + + auto wsc = makeWSClient(env.app().config()); + + // Fill the cap of 1 with alice's account-history. + { + auto const r = wsc->invoke("subscribe", accountHistoryRequest(alice.human())); + BEAST_EXPECTS(r[jss::status] == "success", to_string(r)); + } + + // A different account-history (bob) is net-new: charge 1 over a cap of 1 + // already full, so it is rejected with the cap error. + { + auto const jr = + wsc->invoke("subscribe", accountHistoryRequest(bob.human()))[jss::result]; + BEAST_EXPECT(jr[jss::error] == "invalidParams"); + BEAST_EXPECT(jr[jss::error_message] == "Too many subscriptions for this connection."); + } + } + + void + testAsyncTeardownDoesNotStall() + { + // Test C (core regression): disconnecting a connection with many + // account subscriptions must NOT block subsequent operations or + // publishing. The teardown is now posted to a JobQueue job + // (scheduleAccountCleanup), so it runs off the disconnect thread. + testcase("async teardown does not stall publishing"); + + using namespace std::chrono_literals; + using namespace jtx; + Env env{*this, singleThreadIo(envconfig())}; + + Account const alice{"alice"}; + env.fund(XRP(10000), alice); + BEAST_EXPECT(env.syncClose()); + + // A second, long-lived subscriber to alice that must keep receiving + // publishes after the first connection disconnects. + auto wscLive = makeWSClient(env.app().config()); + { + json::Value jv{json::ValueType::Object}; + jv[jss::accounts] = json::ValueType::Array; + jv[jss::accounts].append(alice.human()); + auto const r = wscLive->invoke("subscribe", jv); + BEAST_EXPECTS(r[jss::status] == "success", to_string(r)); + } + + // A connection that subscribes to many accounts, then disconnects. A + // few thousand entries is enough to be a real teardown while still + // running fast in CI. + constexpr std::size_t kBulk = 3000; + { + auto wscBulk = makeWSClient(env.app().config()); + auto const r = + wscBulk->invoke("subscribe", accountsRequest(makeAccountStrings(kBulk, 10))); + BEAST_EXPECTS(r[jss::status] == "success", to_string(r)); + // Destroying the client closes the WS connection, which destroys + // the server-side InfoSub and posts the chunked async cleanup job. + // WSClient exposes no explicit close(); resetting the owning + // unique_ptr is the disconnect path. + wscBulk.reset(); + } + + // Immediately after the disconnect, an unrelated operation completes + // promptly (it would block for seconds with inline teardown). This is a + // cheap liveness check; the publish assertion below is the real proof. + { + auto const info = env.app().getOPs().getServerInfo(false, true, false); + BEAST_EXPECT(info.isMember(jss::server_state)); + } + + // The live subscriber still receives a published transaction for alice + // within a short timeout, proving account-publishing was not stalled by + // the concurrent teardown. + { + env(pay(env.master, alice, XRP(100))); + BEAST_EXPECT(env.syncClose()); + BEAST_EXPECT(wscLive->findMsg(5s, [&](auto const& jv) { + return jv.isMember(jss::transaction) && + jv[jss::transaction][jss::TransactionType] == jss::Payment && + jv[jss::transaction][jss::Destination] == alice.human(); + })); + } + + wscLive->invoke("unsubscribe", accountsRequest({alice.human()})); + } + + void + testResubscribeAfterDisconnect() + { + // Test D (Phase 3 correctness): connection A subscribes to account X + // and disconnects (async cleanup pending, keyed on A's seq). A new + // connection B subscribes to X and MUST still receive publishes for X - + // A's deferred, seq-keyed cleanup must not remove B's subscription. + testcase("re-subscribe after disconnect still delivers"); + + using namespace std::chrono_literals; + using namespace jtx; + Env env{*this, singleThreadIo(envconfig())}; + + Account const alice{"alice"}; + env.fund(XRP(10000), alice); + BEAST_EXPECT(env.syncClose()); + + // Connection A subscribes to alice, then disconnects. A also subscribes + // to a bulk set so its deferred cleanup is non-trivial and races with B. + { + auto wscA = makeWSClient(env.app().config()); + auto bulk = makeAccountStrings(2000, 10); + bulk.push_back(alice.human()); + auto const r = wscA->invoke("subscribe", accountsRequest(bulk)); + BEAST_EXPECTS(r[jss::status] == "success", to_string(r)); + // Disconnect A by destroying its client (no explicit close()). + wscA.reset(); + } + + // Connection B (a new InfoSub with a distinct seq) subscribes to alice. + auto wscB = makeWSClient(env.app().config()); + { + json::Value jv{json::ValueType::Object}; + jv[jss::accounts] = json::ValueType::Array; + jv[jss::accounts].append(alice.human()); + auto const r = wscB->invoke("subscribe", jv); + BEAST_EXPECTS(r[jss::status] == "success", to_string(r)); + } + + // A publish for alice must reach B. If A's seq-keyed cleanup had wrongly + // removed the shared alice entry, B would receive nothing. + { + env(pay(env.master, alice, XRP(100))); + BEAST_EXPECT(env.syncClose()); + BEAST_EXPECT(wscB->findMsg(5s, [&](auto const& jv) { + return jv.isMember(jss::transaction) && + jv[jss::transaction][jss::TransactionType] == jss::Payment && + jv[jss::transaction][jss::Destination] == alice.human(); + })); + } + + wscB->invoke("unsubscribe", accountsRequest({alice.human()})); + } + void run() override { @@ -1569,6 +1977,14 @@ public: testSubBookChanges(); testNFToken(all); testNFToken(all - featureNFTokenMintOffer); + testAsyncTeardownDoesNotStall(); + testResubscribeAfterDisconnect(); + testSubscriptionCapRejects(); + testReSubscribeNotOvercounted(); + testBooksCapIndependentOfAccounts(); + testMultiFieldNoPartialSubscribe(); + testHistoryReSubscribeNotOvercounted(); + testHistoryCapRejectsNetNew(); } }; diff --git a/src/tests/libxrpl/CMakeLists.txt b/src/tests/libxrpl/CMakeLists.txt index cafe72eff9..2fe046f3d4 100644 --- a/src/tests/libxrpl/CMakeLists.txt +++ b/src/tests/libxrpl/CMakeLists.txt @@ -33,6 +33,7 @@ set(test_modules shamap tx protocol_autogen + server ) if(NOT WIN32) list(APPEND test_modules net) diff --git a/src/tests/libxrpl/server/InfoSub.cpp b/src/tests/libxrpl/server/InfoSub.cpp new file mode 100644 index 0000000000..6913812a92 --- /dev/null +++ b/src/tests/libxrpl/server/InfoSub.cpp @@ -0,0 +1,60 @@ +#include + +#include + +#include +#include + +using namespace xrpl; + +// The per-connection subscription cap is enforced by the pure predicate +// exceedsSubscriptionCap(current, additional). Testing it directly (rather than +// by subscribing the real cap through a WebSocket, which would exceed the frame +// limit and drop the connection before the check runs) lets the boundary be +// asserted exactly. +TEST(InfoSubSubscriptionCap, Boundary) +{ + constexpr std::size_t cap = kMaxSubscriptionsPerConnection; + + // Empty connection: anything up to the cap is admitted, cap+1 is not. + EXPECT_FALSE(exceedsSubscriptionCap(0, 0)); + EXPECT_FALSE(exceedsSubscriptionCap(0, cap)); + EXPECT_TRUE(exceedsSubscriptionCap(0, cap + 1)); + + // Exactly at the cap: zero more is fine, one more is rejected. + EXPECT_FALSE(exceedsSubscriptionCap(cap, 0)); + EXPECT_TRUE(exceedsSubscriptionCap(cap, 1)); + + // One below the cap: exactly one more reaches the cap; two exceed it. + EXPECT_FALSE(exceedsSubscriptionCap(cap - 1, 1)); + EXPECT_TRUE(exceedsSubscriptionCap(cap - 1, 2)); +} + +TEST(InfoSubSubscriptionCap, NoOverflow) +{ + constexpr std::size_t cap = kMaxSubscriptionsPerConnection; + constexpr std::size_t max = std::numeric_limits::max(); + + // current + additional must not wrap: a huge additional is rejected even + // when current is 0 (the additional > cap term guards the subtraction). + EXPECT_TRUE(exceedsSubscriptionCap(0, max)); + EXPECT_TRUE(exceedsSubscriptionCap(cap, max)); +} + +TEST(InfoSubSubscriptionCap, ExplicitCap) +{ + // A configured override is honored: the boundary tracks the passed cap, not + // the built-in default. This is the seam doSubscribe uses to enforce a + // per-connection cap set via [max_subscriptions_per_connection]. + constexpr std::size_t cap = 5; + + EXPECT_FALSE(exceedsSubscriptionCap(0, cap, cap)); + EXPECT_TRUE(exceedsSubscriptionCap(0, cap + 1, cap)); + EXPECT_FALSE(exceedsSubscriptionCap(cap, 0, cap)); + EXPECT_TRUE(exceedsSubscriptionCap(cap, 1, cap)); + EXPECT_FALSE(exceedsSubscriptionCap(cap - 1, 1, cap)); + EXPECT_TRUE(exceedsSubscriptionCap(cap - 1, 2, cap)); + + // The overflow guard still holds with a small explicit cap. + EXPECT_TRUE(exceedsSubscriptionCap(0, std::numeric_limits::max(), cap)); +} diff --git a/src/xrpld/app/ledger/AcceptedLedger.h b/src/xrpld/app/ledger/AcceptedLedger.h index 6e42d611d4..a8b78d08b0 100644 --- a/src/xrpld/app/ledger/AcceptedLedger.h +++ b/src/xrpld/app/ledger/AcceptedLedger.h @@ -57,6 +57,15 @@ public: return transactions_.end(); } + /** + * The last accepted transaction. Precondition: size() > 0. + */ + [[nodiscard]] AcceptedLedgerTx const& + back() const + { + return *transactions_.back(); + } + private: std::shared_ptr ledger_; std::vector> transactions_; diff --git a/src/xrpld/app/misc/NetworkOPs.cpp b/src/xrpld/app/misc/NetworkOPs.cpp index 4b0091dff6..b43149b5af 100644 --- a/src/xrpld/app/misc/NetworkOPs.cpp +++ b/src/xrpld/app/misc/NetworkOPs.cpp @@ -154,6 +154,12 @@ namespace xrpl { +/** + * Concrete NetworkOPs: server sequencer, network tracker, and owner of all + * client subscription state (accounts, books, streams). Subscriptions use three + * independent non-recursive locks (accountLock_, bookLock_, streamLock_); see + * their declarations for the locking and deferred-destruction rules. + */ class NetworkOPsImp final : public NetworkOPs { /** @@ -194,7 +200,7 @@ class NetworkOPsImp final : public NetworkOPs /** * State accounting records two attributes for each possible server state: * 1) Amount of time spent in each state (in microseconds). This value is - * updated upon each state transition. + * updated upon each state transition. * 2) Number of transitions to each state. * * This data can be polled through server_info and represented by @@ -573,6 +579,13 @@ public: unsubAccountHistoryInternal(std::uint64_t seq, AccountID const& account, bool historyOnly) override; + void + scheduleAccountCleanup( + std::uint64_t seq, + hash_set rtAccounts, + hash_set normalAccounts, + hash_set historyAccounts) override; + bool subLedger(InfoSub::ref ispListener, json::Value& jvResult) override; bool @@ -636,6 +649,20 @@ public: bool tryRemoveRpcSub(std::string const& strUrl) override; + /** + * Look up an RPC subscription without taking streamLock_. + * + * Callers MUST already hold streamLock_. This exists so tryRemoveRpcSub + * can reuse the lookup while holding the lock; the plain std::mutex is not + * recursive, so calling the public findRpcSub (which locks) from under the + * lock would self-deadlock. + * + * @param strUrl The subscription URL key into rpcSubMap_. + * @return The matching InfoSub, or an empty pointer if not found. + */ + InfoSub::pointer + findRpcSubLocked(std::string const& strUrl); + beast::Journal const& journal() const override { @@ -724,22 +751,22 @@ private: * Extracts the set of order books affected by @p transaction, then * delivers @p jvObj to every live subscriber of those books. * - * Uses a two-pass design to keep subLock_ hold time short: - * 1. Under subLock_, collect strong InfoSub pointers for all live - * subscribers and prune any expired weak_ptrs encountered. - * 2. Release subLock_, then call send() on each collected pointer. + * Uses a two-pass design to keep bookLock_ hold time short: + * 1. Under bookLock_, collect strong InfoSub pointers for all live + * subscribers and prune any expired weak_ptrs encountered. + * 2. Release bookLock_, then call send() on each collected pointer. * * @param transaction The accepted ledger transaction to inspect. * @param jvObj JSON representation of the transaction to deliver. * - * @note Thread-safety: acquires subLock_ for the collection pass only. - * send() is intentionally called outside the lock to avoid blocking - * all other sub/unsub/publish paths while I/O is in progress. - * @note Contention: subLock_ is shared with all other subscription types. - * On high-throughput nodes processing multi-hop payments that touch - * many offer nodes, this pass holds subLock_ longer than the old - * per-book BookListeners locks did. This is an accepted trade-off - * for lock-domain simplicity. + * @note Thread-safety: acquires bookLock_ for the collection pass only. + * send() is intentionally called outside the lock to avoid blocking + * other book sub/unsub/publish paths while I/O is in progress. + * @note Contention: bookLock_ guards only book subscriptions, so this pass + * no longer competes with account or stream traffic. On high-throughput + * nodes processing multi-hop payments that touch many offer nodes, it + * still holds bookLock_ longer than the old per-book BookListeners + * locks did. This is an accepted trade-off for lock-domain simplicity. */ void pubBookTransaction(AcceptedLedgerTx const& transaction, MultiApiJson const& jvObj); @@ -750,6 +777,23 @@ private: std::shared_ptr const& transaction, TER result); + /** + * Send the ledgerClosed and book-changes stream updates for a ledger. + * Takes streamLock_ only. + */ + void + publishLedgerStreams( + std::shared_ptr const& lpAccepted, + std::shared_ptr const& alpAccepted); + + /** + * On the first published ledger only, start the delayed account-history + * streaming for any subscriptions that were registered before a validated + * ledger existed. Takes accountLock_ only. + */ + void + kickoffAccountHistory(std::shared_ptr const& alpAccepted); + void pubServer(); void @@ -802,7 +846,9 @@ private: hash_map>; /** - * @note called while holding subLock_ + * @note called while holding accountLock_ (it only touches + * subAccountHistory_ and posts a JobQueue task; it never reacquires + * a subscription lock nor touches the stream maps). */ void subAccountHistoryStart( @@ -813,12 +859,85 @@ private: void setAccountHistoryJobTimer(SubAccountHistoryInfoWeak subInfo); + /** + * Maximum number of account entries erased per accountLock_ acquisition + * during disconnect-time cleanup. + * + * The cleanup erase loops drop and reacquire accountLock_ after every + * chunk of this many accounts, bounding how long a large teardown holds + * the lock. A concurrent publish may interleave between chunks; that is + * safe because publishing tolerates a partially-cleaned map (a dead + * subscriber is simply not notified). + */ + static constexpr std::size_t kAccountCleanupChunk = 4096; + + /** + * Erase one connection's entries from a subscription map in + * accountLock_-bounded chunks. + * + * Shared engine behind cleanupAccountSubscriptions and + * cleanupAccountHistorySubscriptions: both walk @p accounts, and for each + * remove this connection's @p seq from the inner per-account map, dropping + * the outer entry once its last subscriber leaves. The lock is released + * between chunks so a competing publish can interleave; no iterator is held + * across the unlock, so a concurrent mutation cannot dangle. + * + * @tparam OuterMap hash_map>. + * @tparam BeforeErase Invoked with the inner value about to be erased, for + * per-entry teardown the plain account maps do not need + * (the history map uses it to stop its paging job). + * @param seq The disconnecting connection's subscription id. + * @param accounts The accounts this connection was subscribed to. + * @param outerMap The subscription map to erase from. + * @param beforeErase Called on each inner value just before it is erased. + * See kAccountCleanupChunk. + */ + template + void + cleanupSubscriptionMap( + std::uint64_t seq, + hash_set const& accounts, + OuterMap& outerMap, + BeforeErase&& beforeErase); + + /** + * Erase one connection's entries from the given account map (subAccount_ + * or subRTAccount_) in accountLock_-bounded chunks. The caller selects the + * map, so this need not know about the real-time/normal distinction. Keyed + * on seq, so it only removes the disconnecting connection's entries. + * See kAccountCleanupChunk. + */ + void + cleanupAccountSubscriptions( + std::uint64_t seq, + hash_set const& accounts, + SubInfoMapType& subMap); + + /** + * Erase one connection's entries from subAccountHistory_ in + * accountLock_-bounded chunks. Keyed on seq. See kAccountCleanupChunk. + */ + void + cleanupAccountHistorySubscriptions(std::uint64_t seq, hash_set const& accounts); + std::reference_wrapper registry_; beast::Journal journal_; std::unique_ptr localTX_; - std::recursive_mutex subLock_; + // Independent lock domains so a long cleanup/publish on one does not stall + // the others. Hold at most one at a time; if ever more, order: accountLock_, + // bookLock_, streamLock_. + // + // Deferred-destruction rule (non-recursive mutexes): under bookLock_ or + // streamLock_, never let the last InfoSub pointer die inside the lock - + // ~InfoSub re-acquires it via unsub* -> self-deadlock. Publishers collect the + // locked pointers in a vector declared before the lock and destruct after + // release (see pubServer / pubBookTransaction). accountLock_ is exempt: + // ~InfoSub offloads account teardown to scheduleAccountCleanup. + std::mutex accountLock_; ///< Guards subAccount_, subRTAccount_, subAccountHistory_. + std::mutex bookLock_; ///< Guards subBook_. + std::mutex streamLock_; ///< Guards streamMaps_[] and rpcSubMap_. std::atomic mode_; @@ -843,18 +962,18 @@ private: /** * Maps each order book to its current set of subscribers. - * Outer key: the Book (currency pair + optional domain). - * Inner key: InfoSub::seq (unique per connection). - * Inner value: weak_ptr so that a dropped connection does not prevent - * the InfoSub from being destroyed; expired entries are pruned lazily - * by pubBookTransaction and eagerly by unsubBookInternal (~InfoSub path). - * Guarded by subLock_. + * Outer key: the Book (currency pair + optional domain). + * Inner key: InfoSub::seq (unique per connection). + * Inner value: weak_ptr so that a dropped connection does not prevent + * the InfoSub from being destroyed; expired entries are pruned lazily + * by pubBookTransaction and eagerly by unsubBookInternal (~InfoSub path). + * Guarded by bookLock_. */ using SubBookMapType = hash_map; SubInfoMapType subAccount_; SubInfoMapType subRTAccount_; - SubBookMapType subBook_; ///< Guarded by subLock_. + SubBookMapType subBook_; ///< Guarded by bookLock_. subRpcMapType rpcSubMap_; @@ -875,6 +994,10 @@ private: SLastEntry // Any new entry must be ADDED ABOVE this one }; + /** + * One weak_ptr subscriber map per stream type. Guarded by streamLock_; + * subject to its deferred-destruction rule (see pubServer). + */ std::array streamMaps_; ServerFeeSummary lastFeeSummary_; @@ -2245,8 +2368,14 @@ NetworkOPsImp::consensusViewChange() void NetworkOPsImp::pubManifest(Manifest const& mo) { + // Hold each locked subscriber alive until after streamLock_ is released: + // if this is the last reference, ~InfoSub re-acquires streamLock_ (via its + // unsub* calls), which would self-deadlock on this non-recursive mutex. + // Declared before the lock so it is destroyed after the lock is dropped. + std::vector toRelease; + // VFALCO consider std::shared_mutex - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(streamLock_); if (!streamMaps_[SManifests].empty()) { @@ -2269,6 +2398,7 @@ NetworkOPsImp::pubManifest(Manifest const& mo) if (auto p = i->second.lock()) { p->send(jvObj, true); + toRelease.push_back(std::move(p)); ++i; } else @@ -2320,11 +2450,16 @@ trunc32(std::uint64_t v) void NetworkOPsImp::pubServer() { + // Hold each locked subscriber alive until after streamLock_ is released; a + // last-reference ~InfoSub would otherwise re-acquire this non-recursive + // mutex and self-deadlock. Declared before the lock, destroyed after it. + std::vector toRelease; + // VFALCO TODO Don't hold the lock across calls to send...make a copy of the // list into a local array while holding the lock then release // the lock and call send on everyone. // - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(streamLock_); if (!streamMaps_[SServer].empty()) { @@ -2362,7 +2497,7 @@ NetworkOPsImp::pubServer() for (auto i = streamMaps_[SServer].begin(); i != streamMaps_[SServer].end();) { - InfoSub::pointer const p = i->second.lock(); + InfoSub::pointer p = i->second.lock(); // VFALCO TODO research the possibility of using thread queues and // linearizing the deletion of subscribers with the @@ -2370,6 +2505,7 @@ NetworkOPsImp::pubServer() if (p) { p->send(jvObj, true); + toRelease.push_back(std::move(p)); ++i; } else @@ -2383,7 +2519,12 @@ NetworkOPsImp::pubServer() void NetworkOPsImp::pubConsensus(ConsensusPhase phase) { - std::scoped_lock const sl(subLock_); + // Hold each locked subscriber alive until after streamLock_ is released; a + // last-reference ~InfoSub would otherwise re-acquire this non-recursive + // mutex and self-deadlock. Declared before the lock, destroyed after it. + std::vector toRelease; + + std::scoped_lock const sl(streamLock_); auto& streamMap = streamMaps_[SConsensusPhase]; if (!streamMap.empty()) @@ -2397,6 +2538,7 @@ NetworkOPsImp::pubConsensus(ConsensusPhase phase) if (auto p = i->second.lock()) { p->send(jvObj, true); + toRelease.push_back(std::move(p)); ++i; } else @@ -2410,8 +2552,13 @@ NetworkOPsImp::pubConsensus(ConsensusPhase phase) void NetworkOPsImp::pubValidation(std::shared_ptr const& val) { + // Hold each locked subscriber alive until after streamLock_ is released; a + // last-reference ~InfoSub would otherwise re-acquire this non-recursive + // mutex and self-deadlock. Declared before the lock, destroyed after it. + std::vector toRelease; + // VFALCO consider std::shared_mutex - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(streamLock_); if (!streamMaps_[SValidations].empty()) { @@ -2503,6 +2650,7 @@ NetworkOPsImp::pubValidation(std::shared_ptr const& val) multiObj.visit( p->getApiVersion(), // [&](json::Value const& jv) { p->send(jv, true); }); + toRelease.push_back(std::move(p)); ++i; } else @@ -2516,7 +2664,12 @@ NetworkOPsImp::pubValidation(std::shared_ptr const& val) void NetworkOPsImp::pubPeerStatus(std::function const& func) { - std::scoped_lock const sl(subLock_); + // Hold each locked subscriber alive until after streamLock_ is released; a + // last-reference ~InfoSub would otherwise re-acquire this non-recursive + // mutex and self-deadlock. Declared before the lock, destroyed after it. + std::vector toRelease; + + std::scoped_lock const sl(streamLock_); if (!streamMaps_[SPeerStatus].empty()) { @@ -2526,11 +2679,12 @@ NetworkOPsImp::pubPeerStatus(std::function const& func) for (auto i = streamMaps_[SPeerStatus].begin(); i != streamMaps_[SPeerStatus].end();) { - InfoSub::pointer const p = i->second.lock(); + InfoSub::pointer p = i->second.lock(); if (p) { p->send(jvObj, true); + toRelease.push_back(std::move(p)); ++i; } else @@ -3074,7 +3228,13 @@ NetworkOPsImp::pubProposedTransaction( MultiApiJson const jvObj = transJson(transaction, result, false, ledger, std::nullopt); { - std::scoped_lock const sl(subLock_); + // Hold each locked subscriber alive until after streamLock_ is + // released; a last-reference ~InfoSub would otherwise re-acquire this + // non-recursive mutex and self-deadlock. Declared before the lock, + // destroyed after the block ends. + std::vector toRelease; + + std::scoped_lock const sl(streamLock_); auto it = streamMaps_[SRtTransactions].begin(); while (it != streamMaps_[SRtTransactions].end()) @@ -3086,6 +3246,7 @@ NetworkOPsImp::pubProposedTransaction( jvObj.visit( p->getApiVersion(), // [&](json::Value const& jv) { p->send(jv, true); }); + toRelease.push_back(std::move(p)); ++it; } else @@ -3117,100 +3278,121 @@ NetworkOPsImp::pubLedger(std::shared_ptr const& lpAccepted) alpAccepted->getLedger().get() == lpAccepted.get(), "xrpl::NetworkOPsImp::pubLedger : accepted input"); - { - JLOG(journal_.debug()) << "Publishing ledger " << lpAccepted->header().seq << " " - << lpAccepted->header().hash; + JLOG(journal_.debug()) << "Publishing ledger " << lpAccepted->header().seq << " " + << lpAccepted->header().hash; - std::scoped_lock const sl(subLock_); - - if (!streamMaps_[SLedger].empty()) - { - json::Value jvObj(json::ValueType::Object); - - jvObj[jss::type] = "ledgerClosed"; - jvObj[jss::ledger_index] = lpAccepted->header().seq; - jvObj[jss::ledger_hash] = to_string(lpAccepted->header().hash); - jvObj[jss::ledger_time] = - json::Value::UInt(lpAccepted->header().closeTime.time_since_epoch().count()); - - jvObj[jss::network_id] = registry_.get().getNetworkIDService().getNetworkID(); - - if (!lpAccepted->rules().enabled(featureXRPFees)) - jvObj[jss::fee_ref] = kFeeUnitsDeprecated; - jvObj[jss::fee_base] = lpAccepted->fees().base.jsonClipped(); - jvObj[jss::reserve_base] = lpAccepted->fees().reserve.jsonClipped(); - jvObj[jss::reserve_inc] = lpAccepted->fees().increment.jsonClipped(); - - jvObj[jss::txn_count] = json::UInt(alpAccepted->size()); - - if (mode_ >= OperatingMode::SYNCING) - { - jvObj[jss::validated_ledgers] = - registry_.get().getLedgerMaster().getCompleteLedgers(); - } - - auto it = streamMaps_[SLedger].begin(); - while (it != streamMaps_[SLedger].end()) - { - InfoSub::pointer const p = it->second.lock(); - if (p) - { - p->send(jvObj, true); - ++it; - } - else - { - it = streamMaps_[SLedger].erase(it); - } - } - } - - if (!streamMaps_[SBookChanges].empty()) - { - json::Value const jvObj = xrpl::RPC::computeBookChanges(lpAccepted); - - auto it = streamMaps_[SBookChanges].begin(); - while (it != streamMaps_[SBookChanges].end()) - { - InfoSub::pointer const p = it->second.lock(); - if (p) - { - p->send(jvObj, true); - ++it; - } - else - { - it = streamMaps_[SBookChanges].erase(it); - } - } - } - - { - static bool kFirstTime = true; - if (kFirstTime) - { - // First validated ledger, start delayed SubAccountHistory - kFirstTime = false; - for (auto& outer : subAccountHistory_) - { - for (auto& inner : outer.second) - { - auto& subInfo = inner.second; - if (subInfo.index->separationLedgerSeq == 0) - { - subAccountHistoryStart(alpAccepted->getLedger(), subInfo); - } - } - } - } - } - } + // Stream updates and the account-history kick-off touch different lock + // domains; each helper takes only its own lock, so the two are never held + // together. + publishLedgerStreams(lpAccepted, alpAccepted); + kickoffAccountHistory(alpAccepted); // Don't lock since pubAcceptedTransaction is locking. for (auto const& accTx : *alpAccepted) { JLOG(journal_.trace()) << "pubAccepted: " << accTx->getJson(); - pubValidatedTransaction(lpAccepted, *accTx, accTx == *(--alpAccepted->end())); + bool const last = &*accTx == &alpAccepted->back(); + pubValidatedTransaction(lpAccepted, *accTx, last); + } +} + +void +NetworkOPsImp::publishLedgerStreams( + std::shared_ptr const& lpAccepted, + std::shared_ptr const& alpAccepted) +{ + // Hold each locked subscriber alive until after streamLock_ is released; a + // last-reference ~InfoSub would otherwise re-acquire this non-recursive + // mutex and self-deadlock. Declared before the lock, destroyed after it; + // covers both the ledger and book-changes loops below. + std::vector toRelease; + + std::scoped_lock const sl(streamLock_); + + if (!streamMaps_[SLedger].empty()) + { + json::Value jvObj(json::ValueType::Object); + + jvObj[jss::type] = "ledgerClosed"; + jvObj[jss::ledger_index] = lpAccepted->header().seq; + jvObj[jss::ledger_hash] = to_string(lpAccepted->header().hash); + jvObj[jss::ledger_time] = + json::Value::UInt(lpAccepted->header().closeTime.time_since_epoch().count()); + + jvObj[jss::network_id] = registry_.get().getNetworkIDService().getNetworkID(); + + if (!lpAccepted->rules().enabled(featureXRPFees)) + jvObj[jss::fee_ref] = kFeeUnitsDeprecated; + jvObj[jss::fee_base] = lpAccepted->fees().base.jsonClipped(); + jvObj[jss::reserve_base] = lpAccepted->fees().reserve.jsonClipped(); + jvObj[jss::reserve_inc] = lpAccepted->fees().increment.jsonClipped(); + + jvObj[jss::txn_count] = json::UInt(alpAccepted->size()); + + if (mode_ >= OperatingMode::SYNCING) + { + jvObj[jss::validated_ledgers] = registry_.get().getLedgerMaster().getCompleteLedgers(); + } + auto it = streamMaps_[SLedger].begin(); + while (it != streamMaps_[SLedger].end()) + { + InfoSub::pointer p = it->second.lock(); + if (p) + { + p->send(jvObj, true); + toRelease.push_back(std::move(p)); + ++it; + } + else + { + it = streamMaps_[SLedger].erase(it); + } + } + } + + if (!streamMaps_[SBookChanges].empty()) + { + json::Value const jvObj = xrpl::RPC::computeBookChanges(lpAccepted); + + auto it = streamMaps_[SBookChanges].begin(); + while (it != streamMaps_[SBookChanges].end()) + { + InfoSub::pointer p = it->second.lock(); + if (p) + { + p->send(jvObj, true); + toRelease.push_back(std::move(p)); + ++it; + } + else + { + it = streamMaps_[SBookChanges].erase(it); + } + } + } +} + +void +NetworkOPsImp::kickoffAccountHistory(std::shared_ptr const& alpAccepted) +{ + // Runs exactly once, the first time a ledger is published. The atomic + // exchange lets the common post-first-ledger path return without taking + // accountLock_, while still admitting exactly one caller even if ledger + // publishing is ever made concurrent. + static std::atomic done{false}; + if (done.exchange(true)) + return; + + // It only reads/writes subAccountHistory_, so it takes accountLock_ alone. + std::scoped_lock const sl(accountLock_); + for (auto& outer : subAccountHistory_) + { + for (auto& inner : outer.second) + { + auto& subInfo = inner.second; + if (subInfo.index->separationLedgerSeq == 0) + subAccountHistoryStart(alpAccepted->getLedger(), subInfo); + } } } @@ -3249,7 +3431,7 @@ NetworkOPsImp::getLocalTxCount() std::size_t NetworkOPsImp::getBookSubscribersCount() { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(bookLock_); std::size_t total = 0; for (auto const& [_, subs] : subBook_) total += subs.size(); @@ -3376,7 +3558,13 @@ NetworkOPsImp::pubValidatedTransaction( MultiApiJson const jvObj = transJson(stTxn, trResult, true, ledger, metaRef); { - std::scoped_lock const sl(subLock_); + // Hold each locked subscriber alive until after streamLock_ is + // released; a last-reference ~InfoSub would otherwise re-acquire this + // non-recursive mutex and self-deadlock. Declared before the lock, + // destroyed after the block ends; covers both loops below. + std::vector toRelease; + + std::scoped_lock const sl(streamLock_); auto it = streamMaps_[STransactions].begin(); while (it != streamMaps_[STransactions].end()) @@ -3388,6 +3576,7 @@ NetworkOPsImp::pubValidatedTransaction( jvObj.visit( p->getApiVersion(), // [&](json::Value const& jv) { p->send(jv, true); }); + toRelease.push_back(std::move(p)); ++it; } else @@ -3407,6 +3596,7 @@ NetworkOPsImp::pubValidatedTransaction( jvObj.visit( p->getApiVersion(), // [&](json::Value const& jv) { p->send(jv, true); }); + toRelease.push_back(std::move(p)); ++it; } else @@ -3431,20 +3621,20 @@ NetworkOPsImp::pubBookTransaction(AcceptedLedgerTx const& alTx, MultiApiJson con // Two-pass design: // - // 1. Under subLock_, walk subBook_, collect a strong pointer for each + // 1. Under bookLock_, walk subBook_, collect a strong pointer for each // unique listener (and prune any expired weak_ptrs we encounter). - // 2. Release subLock_, then send to each collected listener. + // 2. Release bookLock_, then send to each collected listener. // // Reasoning: - // * send() can be slow / blocking, so holding subLock_ across it would - // stall every other sub/unsub/pub path on this server (see the matching - // TODO above pubServer at line ~2275). - // * A strong pointer destructed while subLock_ is held risks running + // * send() can be slow / blocking, so holding bookLock_ across it would + // stall every other book sub/unsub/pub path on this server (see the + // matching TODO above pubServer at line ~2275). + // * A strong pointer destructed while bookLock_ is held risks running // ~InfoSub() in-line, which re-enters unsubBook() and mutates the very // subBook_/SubMapType being iterated -> dangling iterator UB. // - // Releasing subLock_ before any InfoSub::pointer can decay solves both. - // ~InfoSub() reacquires subLock_ via unsubBook() on its own and serializes + // Releasing bookLock_ before any InfoSub::pointer can decay solves both. + // ~InfoSub() reacquires bookLock_ via unsubBook() on its own and serializes // safely with concurrent traffic. std::vector listeners; @@ -3458,7 +3648,7 @@ NetworkOPsImp::pubBookTransaction(AcceptedLedgerTx const& alTx, MultiApiJson con seen.reserve(books.size()); { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(bookLock_); for (auto const& book : books) { @@ -3496,8 +3686,8 @@ NetworkOPsImp::pubBookTransaction(AcceptedLedgerTx const& alTx, MultiApiJson con { jvObj.visit(p->getApiVersion(), [&](json::Value const& jv) { p->send(jv, true); }); } - // listeners destructs here, outside subLock_; ~InfoSub (if any fires) - // will reacquire subLock_ via unsubBook with no iterator hazard. + // listeners destructs here, outside bookLock_; ~InfoSub (if any fires) + // will reacquire bookLock_ via unsubBook with no iterator hazard. } void @@ -3513,7 +3703,7 @@ NetworkOPsImp::pubAccountTransaction( std::vector accountHistoryNotify; auto const currLedgerSeq = ledger->seq(); { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(accountLock_); if (!subAccount_.empty() || !subRTAccount_.empty() || !subAccountHistory_.empty()) { @@ -3646,7 +3836,7 @@ NetworkOPsImp::pubProposedAccountTransaction( std::vector accountHistoryNotify; { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(accountLock_); if (subRTAccount_.empty()) return; @@ -3730,7 +3920,7 @@ NetworkOPsImp::subAccount( isrListener->insertSubAccountInfo(naAccountID, rt); } - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(accountLock_); for (auto const& naAccountID : vnaAccountIDs) { @@ -3773,7 +3963,7 @@ NetworkOPsImp::unsubAccountInternal( hash_set const& vnaAccountIDs, bool rt) { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(accountLock_); SubInfoMapType& subMap = rt ? subRTAccount_ : subAccount_; @@ -3795,6 +3985,122 @@ NetworkOPsImp::unsubAccountInternal( } } +template +void +NetworkOPsImp::cleanupSubscriptionMap( + std::uint64_t seq, + hash_set const& accounts, + OuterMap& outerMap, + BeforeErase&& beforeErase) +{ + // Walk the disconnecting connection's accounts in chunks. Each chunk takes + // accountLock_, erases up to kAccountCleanupChunk entries, then releases + // the lock so a competing account-publish can run before the next chunk. + // No iterator into outerMap is held across the unlock: every chunk re-finds + // each account, so a concurrent mutation between chunks cannot dangle. + auto it = accounts.begin(); + auto const end = accounts.end(); + while (it != end) + { + std::scoped_lock const sl(accountLock_); + + for (std::size_t n = 0; n < kAccountCleanupChunk && it != end; ++n, ++it) + { + auto outerIter = outerMap.find(*it); + if (outerIter != outerMap.end()) + { + // Give the caller a chance to tear down this connection's inner + // entry before it is erased (the history map stops its paging + // job here); the plain account maps pass a no-op. + auto innerIter = outerIter->second.find(seq); + if (innerIter != outerIter->second.end()) + beforeErase(innerIter->second); + + // Erase only this connection's seq; other connections sharing + // the account keep their entry, so a reconnect is unaffected. + outerIter->second.erase(seq); + if (outerIter->second.empty()) + outerMap.erase(outerIter); + } + } + } +} + +void +NetworkOPsImp::cleanupAccountSubscriptions( + std::uint64_t seq, + hash_set const& accounts, + SubInfoMapType& subMap) +{ + // Plain account maps need no per-entry teardown before erase. + cleanupSubscriptionMap(seq, accounts, subMap, [](InfoSub::wptr const&) {}); +} + +void +NetworkOPsImp::cleanupAccountHistorySubscriptions( + std::uint64_t seq, + hash_set const& accounts) +{ + // Cancel any in-flight historical paging job for this connection before + // dropping its record. The job holds its own shared_ptr to the index, so + // erasing the map entry alone would not stop it; it reads this atomic + // between pages and exits promptly once set. + cleanupSubscriptionMap( + seq, accounts, subAccountHistory_, [](SubAccountHistoryInfoWeak const& info) { + info.index->stopHistorical = true; + }); +} + +void +NetworkOPsImp::scheduleAccountCleanup( + std::uint64_t seq, + hash_set rtAccounts, + hash_set normalAccounts, + hash_set historyAccounts) +{ + // Nothing to do for a connection that never subscribed to any account. + if (rtAccounts.empty() && normalAccounts.empty() && historyAccounts.empty()) + return; + + // Post the erase work to a low-priority job so the disconnect thread (and + // ~InfoSub) returns immediately. The job captures the sets BY MOVE and + // operates purely on seq + the captured accounts; it never touches the + // destroyed InfoSub. `this` outlives the job per the Source lifetime + // contract. Running on a JobQueue thread, it cannot re-enter accountLock_ + // held by the disconnecting thread, so the plain std::mutex is safe. + // + // The body is exception-guarded: the JobQueue invokes it bare, so an + // escaping exception on the worker thread would terminate the process. + // + // addJob returns false only once the JobQueue has been stopped, i.e. during + // process shutdown. At that point NetworkOPsImp's maps are about to be + // destroyed wholesale and no publish path can run, so dropping the cleanup + // is harmless; no inline fallback is needed. + jobQueue_.addJob( + JtClientAcctHist, + "SubCleanup", + [this, + seq, + rt = std::move(rtAccounts), + normal = std::move(normalAccounts), + history = std::move(historyAccounts)]() noexcept { + try + { + cleanupAccountSubscriptions(seq, rt, subRTAccount_); + cleanupAccountSubscriptions(seq, normal, subAccount_); + cleanupAccountHistorySubscriptions(seq, history); + } + catch (std::exception const& e) + { + JLOG(journal_.error()) << "SubCleanup[seq=" << seq << "]: " << e.what(); + } + catch (...) + { + JLOG(journal_.error()) << "SubCleanup[seq=" << seq << "]: unknown exception"; + } + }); +} + void NetworkOPsImp::addAccountHistoryJob(SubAccountHistoryInfoWeak subInfo) { @@ -4077,7 +4383,7 @@ NetworkOPsImp::subAccountHistory(InfoSub::ref isrListener, AccountID const& acco return RpcInvalidParams; } - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(accountLock_); SubAccountHistoryInfoWeak ahi{ .sinkWptr = isrListener, .index = std::make_shared(accountId)}; auto simIterator = subAccountHistory_.find(accountId); @@ -4125,7 +4431,7 @@ NetworkOPsImp::unsubAccountHistoryInternal( AccountID const& account, bool historyOnly) { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(accountLock_); auto simIterator = subAccountHistory_.find(account); if (simIterator != subAccountHistory_.end()) { @@ -4157,7 +4463,7 @@ NetworkOPsImp::subBook(InfoSub::ref isrListener, Book const& book) // prune in pubBookTransaction. With the reverse ordering, ~InfoSub would // call unsubBookInternal for a key that was never inserted server-side. { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(bookLock_); subBook_[book].try_emplace(isrListener->getSeq(), isrListener); } isrListener->insertBookSubscription(book); @@ -4177,7 +4483,7 @@ NetworkOPsImp::unsubBook(InfoSub::ref isrListener, Book const& book) bool NetworkOPsImp::unsubBookInternal(std::uint64_t uSeq, Book const& book) { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(bookLock_); auto it = subBook_.find(book); if (it == subBook_.end()) return false; @@ -4227,7 +4533,7 @@ NetworkOPsImp::subLedger(InfoSub::ref isrListener, json::Value& jvResult) jvResult[jss::validated_ledgers] = registry_.get().getLedgerMaster().getCompleteLedgers(); } - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(streamLock_); return streamMaps_[SLedger].emplace(isrListener->getSeq(), isrListener).second; } @@ -4235,7 +4541,7 @@ NetworkOPsImp::subLedger(InfoSub::ref isrListener, json::Value& jvResult) bool NetworkOPsImp::subBookChanges(InfoSub::ref isrListener) { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(streamLock_); return streamMaps_[SBookChanges].emplace(isrListener->getSeq(), isrListener).second; } @@ -4243,7 +4549,7 @@ NetworkOPsImp::subBookChanges(InfoSub::ref isrListener) bool NetworkOPsImp::unsubLedger(std::uint64_t uSeq) { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(streamLock_); return streamMaps_[SLedger].erase(uSeq) != 0u; } @@ -4251,7 +4557,7 @@ NetworkOPsImp::unsubLedger(std::uint64_t uSeq) bool NetworkOPsImp::unsubBookChanges(std::uint64_t uSeq) { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(streamLock_); return streamMaps_[SBookChanges].erase(uSeq) != 0u; } @@ -4259,7 +4565,7 @@ NetworkOPsImp::unsubBookChanges(std::uint64_t uSeq) bool NetworkOPsImp::subManifests(InfoSub::ref isrListener) { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(streamLock_); return streamMaps_[SManifests].emplace(isrListener->getSeq(), isrListener).second; } @@ -4267,7 +4573,7 @@ NetworkOPsImp::subManifests(InfoSub::ref isrListener) bool NetworkOPsImp::unsubManifests(std::uint64_t uSeq) { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(streamLock_); return streamMaps_[SManifests].erase(uSeq) != 0u; } @@ -4292,7 +4598,7 @@ NetworkOPsImp::subServer(InfoSub::ref isrListener, json::Value& jvResult, bool a jvResult[jss::pubkey_node] = toBase58(TokenType::NodePublic, registry_.get().getApp().nodeIdentity().first); - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(streamLock_); return streamMaps_[SServer].emplace(isrListener->getSeq(), isrListener).second; } @@ -4300,7 +4606,7 @@ NetworkOPsImp::subServer(InfoSub::ref isrListener, json::Value& jvResult, bool a bool NetworkOPsImp::unsubServer(std::uint64_t uSeq) { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(streamLock_); return streamMaps_[SServer].erase(uSeq) != 0u; } @@ -4308,7 +4614,7 @@ NetworkOPsImp::unsubServer(std::uint64_t uSeq) bool NetworkOPsImp::subTransactions(InfoSub::ref isrListener) { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(streamLock_); return streamMaps_[STransactions].emplace(isrListener->getSeq(), isrListener).second; } @@ -4316,7 +4622,7 @@ NetworkOPsImp::subTransactions(InfoSub::ref isrListener) bool NetworkOPsImp::unsubTransactions(std::uint64_t uSeq) { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(streamLock_); return streamMaps_[STransactions].erase(uSeq) != 0u; } @@ -4324,7 +4630,7 @@ NetworkOPsImp::unsubTransactions(std::uint64_t uSeq) bool NetworkOPsImp::subRTTransactions(InfoSub::ref isrListener) { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(streamLock_); return streamMaps_[SRtTransactions].emplace(isrListener->getSeq(), isrListener).second; } @@ -4332,7 +4638,7 @@ NetworkOPsImp::subRTTransactions(InfoSub::ref isrListener) bool NetworkOPsImp::unsubRTTransactions(std::uint64_t uSeq) { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(streamLock_); return streamMaps_[SRtTransactions].erase(uSeq) != 0u; } @@ -4340,7 +4646,7 @@ NetworkOPsImp::unsubRTTransactions(std::uint64_t uSeq) bool NetworkOPsImp::subValidations(InfoSub::ref isrListener) { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(streamLock_); return streamMaps_[SValidations].emplace(isrListener->getSeq(), isrListener).second; } @@ -4354,7 +4660,7 @@ NetworkOPsImp::stateAccounting(json::Value& obj) bool NetworkOPsImp::unsubValidations(std::uint64_t uSeq) { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(streamLock_); return streamMaps_[SValidations].erase(uSeq) != 0u; } @@ -4362,7 +4668,7 @@ NetworkOPsImp::unsubValidations(std::uint64_t uSeq) bool NetworkOPsImp::subPeerStatus(InfoSub::ref isrListener) { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(streamLock_); return streamMaps_[SPeerStatus].emplace(isrListener->getSeq(), isrListener).second; } @@ -4370,7 +4676,7 @@ NetworkOPsImp::subPeerStatus(InfoSub::ref isrListener) bool NetworkOPsImp::unsubPeerStatus(std::uint64_t uSeq) { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(streamLock_); return streamMaps_[SPeerStatus].erase(uSeq) != 0u; } @@ -4378,7 +4684,7 @@ NetworkOPsImp::unsubPeerStatus(std::uint64_t uSeq) bool NetworkOPsImp::subConsensus(InfoSub::ref isrListener) { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(streamLock_); return streamMaps_[SConsensusPhase].emplace(isrListener->getSeq(), isrListener).second; } @@ -4386,15 +4692,14 @@ NetworkOPsImp::subConsensus(InfoSub::ref isrListener) bool NetworkOPsImp::unsubConsensus(std::uint64_t uSeq) { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(streamLock_); return streamMaps_[SConsensusPhase].erase(uSeq) != 0u; } InfoSub::pointer -NetworkOPsImp::findRpcSub(std::string const& strUrl) +NetworkOPsImp::findRpcSubLocked(std::string const& strUrl) { - std::scoped_lock const sl(subLock_); - + // Caller already holds streamLock_; this performs the lookup only. auto const it = rpcSubMap_.find(strUrl); if (it != rpcSubMap_.end()) @@ -4403,10 +4708,17 @@ NetworkOPsImp::findRpcSub(std::string const& strUrl) return InfoSub::pointer(); } +InfoSub::pointer +NetworkOPsImp::findRpcSub(std::string const& strUrl) +{ + std::scoped_lock const sl(streamLock_); + return findRpcSubLocked(strUrl); +} + InfoSub::pointer NetworkOPsImp::addRpcSub(std::string const& strUrl, InfoSub::ref rspEntry) { - std::scoped_lock const sl(subLock_); + std::scoped_lock const sl(streamLock_); rpcSubMap_.emplace(strUrl, rspEntry); @@ -4416,20 +4728,31 @@ NetworkOPsImp::addRpcSub(std::string const& strUrl, InfoSub::ref rspEntry) bool NetworkOPsImp::tryRemoveRpcSub(std::string const& strUrl) { - std::scoped_lock const sl(subLock_); - auto pInfo = findRpcSub(strUrl); - - if (!pInfo) - return false; - - // check to see if any of the stream maps still hold a weak reference to - // this entry before removing - for (SubMapType const& map : streamMaps_) + // Declared before the lock so it outlives the scoped_lock and is destroyed + // only after streamLock_ is released. The erase below may drop the last + // strong reference; if so, ~InfoSub runs and its unsub* calls re-acquire + // the non-recursive streamLock_. Destroying pInfo inside the lock would + // self-deadlock. + InfoSub::pointer pInfo; { - if (map.contains(pInfo->getSeq())) + std::scoped_lock const sl(streamLock_); + // Use the no-lock helper: we already hold streamLock_ and the mutex is + // not recursive, so calling the public findRpcSub here would deadlock. + pInfo = findRpcSubLocked(strUrl); + + if (!pInfo) return false; + + // check to see if any of the stream maps still hold a weak reference to + // this entry before removing + for (SubMapType const& map : streamMaps_) + { + if (map.contains(pInfo->getSeq())) + return false; + } + rpcSubMap_.erase(strUrl); } - rpcSubMap_.erase(strUrl); + // pInfo destroyed here, after streamLock_ is released. return true; } diff --git a/src/xrpld/core/Config.h b/src/xrpld/core/Config.h index 852e46218a..a7cb5d053a 100644 --- a/src/xrpld/core/Config.h +++ b/src/xrpld/core/Config.h @@ -229,6 +229,12 @@ public: static constexpr int kMaxJobQueueTx = 1000; static constexpr int kMinJobQueueTx = 100; + // Optional override for the per-connection subscription cap. Unset means + // use the built-in default (kMaxSubscriptionsPerConnection in InfoSub.h). + // Kept as an override here, rather than the default itself, so the core + // module need not depend on the server module that owns the constant. + std::optional maxSubscriptionsPerConnection; + // Amendment majority time std::chrono::seconds amendmentMajorityTime = kDefaultAmendmentMajorityTime; diff --git a/src/xrpld/core/detail/Config.cpp b/src/xrpld/core/detail/Config.cpp index 3b7b57328b..616717c5fd 100644 --- a/src/xrpld/core/detail/Config.cpp +++ b/src/xrpld/core/detail/Config.cpp @@ -677,6 +677,9 @@ Config::loadFromString(std::string const& fileContents) if (getSingleSection(secConfig, Sections::kNetworkQuorum, strTemp, j_)) networkQuorum = beast::lexicalCastThrow(strTemp); + if (getSingleSection(secConfig, Sections::kMaxSubscriptionsPerConnection, strTemp, j_)) + maxSubscriptionsPerConnection = beast::lexicalCastThrow(strTemp); + fees = setupFeeVote(section(Sections::kVoting)); /* [fee_default] is documented in the example config files as useful for * things like offline transaction signing. Until that's completely diff --git a/src/xrpld/rpc/handlers/subscribe/Subscribe.cpp b/src/xrpld/rpc/handlers/subscribe/Subscribe.cpp index 93840bb6d6..cf43501c52 100644 --- a/src/xrpld/rpc/handlers/subscribe/Subscribe.cpp +++ b/src/xrpld/rpc/handlers/subscribe/Subscribe.cpp @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -19,6 +20,7 @@ #include #include +#include #include #include #include @@ -26,6 +28,24 @@ namespace xrpl { +namespace { + +/** + * Test whether admitting `additional` subscriptions would exceed the cap. + * + * @param ispSub The connection's InfoSub, queried for its current count. + * @param additional Number of new items this branch would add. + * @param cap The effective per-connection cap for this request. + * @return true if the request must be rejected to stay within the cap. + */ +[[nodiscard]] bool +wouldExceedSubscriptionCap(InfoSub::ref ispSub, std::size_t additional, std::size_t cap) +{ + return exceedsSubscriptionCap(ispSub->totalSubscriptionCount(), additional, cap); +} + +} // namespace + json::Value doSubscribe(RPC::JsonContext& context) { @@ -105,6 +125,11 @@ doSubscribe(RPC::JsonContext& context) } ispSub->setApiVersion(context.apiVersion); + // Effective per-connection subscription cap: a configured override if set, + // otherwise the built-in default. Resolved once and reused by every branch. + std::size_t const subscriptionCap = + context.app.config().maxSubscriptionsPerConnection.value_or(kMaxSubscriptionsPerConnection); + if (context.params.isMember(jss::streams)) { if (!context.params[jss::streams].isArray()) @@ -166,30 +191,59 @@ doSubscribe(RPC::JsonContext& context) } } + // Parse the proposed (real-time) and normal account sets first, then check + // the cap against their COMBINED net-new total before subscribing either. + // This keeps the account pair all-or-nothing: it never subscribes one set + // and then rejects on the other. Other fields (streams and account_history) + // are still checked and subscribed independently, as they always have been, + // so a later field can be rejected after an earlier one subscribed. The cap + // counts only NET-NEW accounts (those not already tracked on this + // connection), so re-subscribing accounts already held is never wrongly + // rejected. auto accountsProposed = context.params.isMember(jss::accounts_proposed) ? jss::accounts_proposed : jss::rt_accounts; // DEPRECATED - if (context.params.isMember(accountsProposed)) + bool const hasProposed = context.params.isMember(accountsProposed); + bool const hasAccounts = context.params.isMember(jss::accounts); + + hash_set proposedIds; + hash_set accountIds; + + if (hasProposed) { if (!context.params[accountsProposed].isArray()) return rpcError(RpcInvalidParams); - auto ids = RPC::parseAccountIds(context.params[accountsProposed]); - if (ids.empty()) + proposedIds = RPC::parseAccountIds(context.params[accountsProposed]); + if (proposedIds.empty()) return rpcError(RpcActMalformed); - context.netOps.subAccount(ispSub, ids, true); } - if (context.params.isMember(jss::accounts)) + if (hasAccounts) { if (!context.params[jss::accounts].isArray()) return rpcError(RpcInvalidParams); - auto ids = RPC::parseAccountIds(context.params[jss::accounts]); - if (ids.empty()) + accountIds = RPC::parseAccountIds(context.params[jss::accounts]); + if (accountIds.empty()) return rpcError(RpcActMalformed); - context.netOps.subAccount(ispSub, ids, false); - JLOG(context.j.debug()) << "doSubscribe: accounts: " << ids.size(); + } + + if (hasProposed || hasAccounts) + { + // Atomic check-and-reserve, so two concurrent requests sharing this + // InfoSub (admin subscribe-by-url) cannot both pass the cap check. + if (!ispSub->tryReserveAccountSubscriptions(proposedIds, accountIds, subscriptionCap)) + return RPC::makeParamError("Too many subscriptions for this connection."); + } + + if (hasProposed) + context.netOps.subAccount(ispSub, proposedIds, true); + + if (hasAccounts) + { + context.netOps.subAccount(ispSub, accountIds, false); + JLOG(context.j.debug()) << "doSubscribe: accounts: " << accountIds.size(); } if (context.params.isMember(jss::account_history_tx_stream)) @@ -206,6 +260,13 @@ doSubscribe(RPC::JsonContext& context) if (!id) return rpcError(RpcInvalidParams); + // Charge the cap only when net-new, like the account branches. Not + // atomic here (subAccountHistory does its own dup-detecting insert), but + // a concurrent race adds at most one entry, so the overshoot is trivial. + std::size_t const historyCharge = ispSub->hasAccountHistorySubscription(*id) ? 0 : 1; + if (wouldExceedSubscriptionCap(ispSub, historyCharge, subscriptionCap)) + return RPC::makeParamError("Too many subscriptions for this connection."); + if (auto result = context.netOps.subAccountHistory(ispSub, *id); result != RpcSuccess) { return rpcError(result); @@ -222,6 +283,10 @@ doSubscribe(RPC::JsonContext& context) if (!context.params[jss::books].isArray()) return rpcError(RpcInvalidParams); + // Book subscriptions are tracked separately (OrderBookDB) and are not + // part of totalSubscriptionCount(), so they are not gated by the + // per-connection account cap. Each book entry is validated and + // subscribed below. for (auto& j : context.params[jss::books]) { if (!j.isObject() || !j.isMember(jss::taker_pays) || !j.isMember(jss::taker_gets) || From 7877ee42a0fa9f37959616e46e458f9f5eabdb5a Mon Sep 17 00:00:00 2001 From: Bart Date: Thu, 16 Jul 2026 16:21:56 -0400 Subject: [PATCH 12/52] fix: Reject oversized validator manifest before decoding --- include/xrpl/basics/base64.h | 29 ++++++++++++++++++ include/xrpl/server/Manifest.h | 33 +++++++++++++++++++++ src/libxrpl/basics/base64.cpp | 18 ----------- src/libxrpl/server/Manifest.cpp | 5 ++++ src/xrpld/app/misc/detail/ValidatorList.cpp | 9 ++++++ 5 files changed, 76 insertions(+), 18 deletions(-) diff --git a/include/xrpl/basics/base64.h b/include/xrpl/basics/base64.h index 24fd660e65..4c743531a6 100644 --- a/include/xrpl/basics/base64.h +++ b/include/xrpl/basics/base64.h @@ -41,6 +41,35 @@ namespace xrpl { +namespace base64 { + +/** + * Returns the maximum number of characters needed to base64-encode @p nBytes bytes. + * + * @param nBytes Number of input bytes. + * @return Size of the encoded string, including padding. + */ +constexpr std::size_t +encodedSize(std::size_t const nBytes) +{ + return 4 * ((nBytes + 2) / 3); +} + +/** + * Returns the maximum number of bytes a base64 string of @p nChars characters + * decodes to. + * + * @param nChars Number of base64 characters. + * @return Upper bound on the number of decoded bytes. + */ +constexpr std::size_t +decodedSize(std::size_t const nChars) +{ + return ((nChars / 4) * 3) + 2; +} + +} // namespace base64 + std::string base64Encode(std::uint8_t const* data, std::size_t len); diff --git a/include/xrpl/server/Manifest.h b/include/xrpl/server/Manifest.h index 710545271a..362080ef36 100644 --- a/include/xrpl/server/Manifest.h +++ b/include/xrpl/server/Manifest.h @@ -3,12 +3,14 @@ #include #include #include +#include #include #include #include #include #include +#include #include #include #include @@ -164,6 +166,37 @@ struct Manifest std::string to_string(Manifest const& m); +/** + *Largest a valid manifest can be, in decoded bytes. + * + * A manifest has a fixed set of fields. Each is serialized as a field header + * (1-2 bytes), an optional length prefix (1 byte for these sizes), and the + * field body. Taking every field at its largest gives the maximum below, so + * anything larger cannot be a valid manifest. + * + * Field header + length + body = bytes + * sfVersion (U16) 2 0 2 4 + * sfSequence (U32) 1 0 4 5 + * sfPublicKey (33) 1 1 33 35 + * sfSigningPubKey (33) 1 1 33 35 + * sfSignature (72) 1 1 72 74 + * sfMasterSignature (72) 2 1 72 75 + * sfDomain (128) 1 1 128 130 + * ----- + * 358 + */ +constexpr std::size_t kMaxManifestBytes = 358; + +/** + * Largest a valid manifest can be, in base64 characters. + * + * base64 encodes 3 bytes as 4 characters, so this is the encoded form of + * @ref kMaxManifestBytes. Callers that receive a base64 manifest should + * reject anything longer than this before decoding, to avoid allocating + * memory for an oversized input. + */ +constexpr std::size_t kMaxManifestBase64 = base64::encodedSize(kMaxManifestBytes); + /** * Constructs Manifest from serialized string * diff --git a/src/libxrpl/basics/base64.cpp b/src/libxrpl/basics/base64.cpp index c980a08669..f067dcbdca 100644 --- a/src/libxrpl/basics/base64.cpp +++ b/src/libxrpl/basics/base64.cpp @@ -76,24 +76,6 @@ getInverse() return &kTab[0]; } -/** - * Returns max chars needed to encode a base64 string - */ -constexpr std::size_t -encodedSize(std::size_t n) -{ - return 4 * ((n + 2) / 3); -} - -/** - * Returns max bytes needed to decode a base64 string - */ -constexpr std::size_t -decodedSize(std::size_t n) -{ - return ((n / 4) * 3) + 2; -} - /** * Encode a series of octets as a padded, base64 string. * diff --git a/src/libxrpl/server/Manifest.cpp b/src/libxrpl/server/Manifest.cpp index b26c67e531..798d8130b7 100644 --- a/src/libxrpl/server/Manifest.cpp +++ b/src/libxrpl/server/Manifest.cpp @@ -62,6 +62,11 @@ deserializeManifest(Slice s, beast::Journal journal) if (s.empty()) return std::nullopt; + // A valid manifest has a fixed maximum size, so reject anything larger + // before parsing it. + if (s.size() > kMaxManifestBytes) + return std::nullopt; + static SOTemplate const kManifestFormat{ // A manifest must include: // - the master public key diff --git a/src/xrpld/app/misc/detail/ValidatorList.cpp b/src/xrpld/app/misc/detail/ValidatorList.cpp index a9e7156158..b77dcc23c2 100644 --- a/src/xrpld/app/misc/detail/ValidatorList.cpp +++ b/src/xrpld/app/misc/detail/ValidatorList.cpp @@ -1127,6 +1127,15 @@ ValidatorList::applyList( json::Value list; auto const& manifest = localManifest ? *localManifest : globalManifest; + // Reject an oversized manifest before decoding it, so we do not allocate + // memory for an input that cannot be a valid manifest. deserializeManifest + // also enforces the decoded-byte limit, but checking here avoids the + // base64 decode entirely. + if (manifest.size() > kMaxManifestBase64) + { + JLOG(j_.warn()) << "UNL manifest exceeds maximum size"; + return PublisherListStats{ListDisposition::Invalid}; + } auto m = deserializeManifest(base64Decode(manifest)); if (!m) { From 7d3611df2ab314b50e13a534fadcd60d4a6ebff3 Mon Sep 17 00:00:00 2001 From: Jingchen Date: Fri, 17 Jul 2026 12:33:44 +0100 Subject: [PATCH 13/52] fix: Compute validation suppression key over canonical serialisation --- include/xrpl/protocol/STObject.h | 8 +++-- include/xrpl/protocol/STValidation.h | 26 +++++++++++--- src/libxrpl/protocol/STObject.cpp | 20 ++++++++--- src/test/protocol/STValidation_test.cpp | 45 ++++++++++++++++++++----- src/xrpld/overlay/detail/PeerImp.cpp | 22 ++++++++---- 5 files changed, 95 insertions(+), 26 deletions(-) diff --git a/include/xrpl/protocol/STObject.h b/include/xrpl/protocol/STObject.h index ad87d106c4..c7fc4fa796 100644 --- a/include/xrpl/protocol/STObject.h +++ b/include/xrpl/protocol/STObject.h @@ -90,7 +90,11 @@ public: operator=(STObject&& other); STObject(SOTemplate const& type, SField const& name); - STObject(SOTemplate const& type, SerialIter& sit, SField const& name); + STObject( + SOTemplate const& type, + SerialIter& sit, + SField const& name, + bool requireCanonicalOrder = false); STObject(SerialIter& sit, SField const& name, int depth = 0); STObject(SerialIter&& sit, SField const& name); explicit STObject(SField const& name); @@ -123,7 +127,7 @@ public: set(SOTemplate const&); bool - set(SerialIter& u, int depth = 0); + set(SerialIter& u, int depth = 0, bool requireCanonicalOrder = false); [[nodiscard]] SerializedTypeID getSType() const override; diff --git a/include/xrpl/protocol/STValidation.h b/include/xrpl/protocol/STValidation.h index 444fdfa600..8101b27341 100644 --- a/include/xrpl/protocol/STValidation.h +++ b/include/xrpl/protocol/STValidation.h @@ -54,6 +54,22 @@ class STValidation final : public STObject, public CountedObject NetClock::time_point seenTime_; public: + /** + * @struct DeserializeOptions + * @brief Options controlling deserialization of a STValidation. + + * @var DeserializeOptions::checkSignature + * Whether to verify the data was signed properly + * + * @var DeserializeOptions::requireCanonicalOrder + * Whether to require the fields to be in canonical order + */ + struct DeserializeOptions + { + bool checkSignature; + bool requireCanonicalOrder; + }; + /** * Construct a STValidation from a peer from serialized data. * @@ -64,12 +80,12 @@ public: * that signed the validation. For manifest based * validators, this should be the NodeID of the master * public key. - * @param checkSignature Whether to verify the data was signed properly + * @param options Options controlling deserialization * * @note Throws if the object is not valid */ template - STValidation(SerialIter& sit, LookupNodeID&& lookupNodeID, bool checkSignature); + STValidation(SerialIter& sit, LookupNodeID&& lookupNodeID, DeserializeOptions options); /** * Construct, sign and trust a new STValidation issued by this node. @@ -163,8 +179,8 @@ private: }; template -STValidation::STValidation(SerialIter& sit, LookupNodeID&& lookupNodeID, bool checkSignature) - : STObject(validationFormat(), sit, sfValidation) +STValidation::STValidation(SerialIter& sit, LookupNodeID&& lookupNodeID, DeserializeOptions options) + : STObject(validationFormat(), sit, sfValidation, options.requireCanonicalOrder) , signingPubKey_([this]() { auto const spk = getFieldVL(sfSigningPubKey); @@ -175,7 +191,7 @@ STValidation::STValidation(SerialIter& sit, LookupNodeID&& lookupNodeID, bool ch }()) , nodeID_(lookupNodeID(signingPubKey_)) { - if (checkSignature && !isValid()) + if (options.checkSignature && !isValid()) { JLOG(debugLog().error()) << "Invalid signature in validation: " << getJson(JsonOptions::Values::None); diff --git a/src/libxrpl/protocol/STObject.cpp b/src/libxrpl/protocol/STObject.cpp index 4b3ace2be3..e8a6df8c0a 100644 --- a/src/libxrpl/protocol/STObject.cpp +++ b/src/libxrpl/protocol/STObject.cpp @@ -56,10 +56,15 @@ STObject::STObject(SOTemplate const& type, SField const& name) : STBase(name) set(type); } -STObject::STObject(SOTemplate const& type, SerialIter& sit, SField const& name) : STBase(name) +STObject::STObject( + SOTemplate const& type, + SerialIter& sit, + SField const& name, + bool requireCanonicalOrder) + : STBase(name) { v_.reserve(type.size()); - set(sit); + set(sit, 0, requireCanonicalOrder); applyTemplate(type); // May throw } @@ -208,12 +213,13 @@ STObject::applyTemplateFromSField(SField const& sField) // return true = terminated with end-of-object bool -STObject::set(SerialIter& sit, int depth) +STObject::set(SerialIter& sit, int depth, bool requireCanonicalOrder) { bool reachedEndOfObject = false; v_.clear(); + std::optional prevFieldCode; // Consume data in the pipe until we run out or reach the end while (!sit.empty()) { @@ -238,7 +244,6 @@ STObject::set(SerialIter& sit, int depth) } auto const& fn = SField::getField(type, field); - if (fn.isInvalid()) { JLOG(debugLog().error()) @@ -246,6 +251,13 @@ STObject::set(SerialIter& sit, int depth) Throw("Unknown field"); } + if (requireCanonicalOrder && prevFieldCode.has_value() && fn.fieldCodeMem <= *prevFieldCode) + { + JLOG(debugLog().error()) << "Fields in object are not in canonical order"; + Throw("Fields in object are not in canonical order"); + } + prevFieldCode = fn.fieldCodeMem; + // Unflatten the field v_.emplace_back(sit, fn, depth + 1); diff --git a/src/test/protocol/STValidation_test.cpp b/src/test/protocol/STValidation_test.cpp index e42411bd3f..eb9aefd0ed 100644 --- a/src/test/protocol/STValidation_test.cpp +++ b/src/test/protocol/STValidation_test.cpp @@ -153,7 +153,10 @@ public: SerialIter sit{kPayload8}; auto val = std::make_shared( - sit, [](PublicKey const& pk) { return calcNodeID(pk); }, true); + sit, + [](PublicKey const& pk) { return calcNodeID(pk); }, + STValidation::DeserializeOptions{ + .checkSignature = true, .requireCanonicalOrder = false}); BEAST_EXPECT(val); BEAST_EXPECT(val->isFieldPresent(sfLedgerSequence)); @@ -174,7 +177,10 @@ public: { SerialIter sit{kPayload1}; auto val = std::make_shared( - sit, [](PublicKey const& pk) { return calcNodeID(pk); }, false); + sit, + [](PublicKey const& pk) { return calcNodeID(pk); }, + STValidation::DeserializeOptions{ + .checkSignature = false, .requireCanonicalOrder = false}); fail("An exception should have been thrown"); } catch (std::exception const& ex) @@ -186,7 +192,10 @@ public: { SerialIter sit{kPayload2}; auto val = std::make_shared( - sit, [](PublicKey const& pk) { return calcNodeID(pk); }, false); + sit, + [](PublicKey const& pk) { return calcNodeID(pk); }, + STValidation::DeserializeOptions{ + .checkSignature = false, .requireCanonicalOrder = false}); fail("An exception should have been thrown"); } catch (std::exception const& ex) @@ -198,7 +207,10 @@ public: { SerialIter sit{kPayload3}; auto val = std::make_shared( - sit, [](PublicKey const& pk) { return calcNodeID(pk); }, false); + sit, + [](PublicKey const& pk) { return calcNodeID(pk); }, + STValidation::DeserializeOptions{ + .checkSignature = false, .requireCanonicalOrder = false}); fail("An exception should have been thrown"); } catch (std::exception const& ex) @@ -210,7 +222,10 @@ public: { SerialIter sit{kPayload4}; auto val = std::make_shared( - sit, [](PublicKey const& pk) { return calcNodeID(pk); }, false); + sit, + [](PublicKey const& pk) { return calcNodeID(pk); }, + STValidation::DeserializeOptions{ + .checkSignature = false, .requireCanonicalOrder = false}); fail("An exception should have been thrown"); } catch (std::exception const& ex) @@ -224,7 +239,10 @@ public: { SerialIter sit{kPayload5}; auto val = std::make_shared( - sit, [](PublicKey const& pk) { return calcNodeID(pk); }, false); + sit, + [](PublicKey const& pk) { return calcNodeID(pk); }, + STValidation::DeserializeOptions{ + .checkSignature = false, .requireCanonicalOrder = false}); fail("Expected exception not thrown from validation"); } catch (std::exception const& ex) @@ -236,7 +254,10 @@ public: { SerialIter sit{kPayload6}; auto val = std::make_shared( - sit, [](PublicKey const& pk) { return calcNodeID(pk); }, false); + sit, + [](PublicKey const& pk) { return calcNodeID(pk); }, + STValidation::DeserializeOptions{ + .checkSignature = false, .requireCanonicalOrder = false}); fail("Expected exception not thrown from validation"); } catch (std::exception const& ex) @@ -249,7 +270,10 @@ public: SerialIter sit{kPayload7}; auto val = std::make_shared( - sit, [](PublicKey const& pk) { return calcNodeID(pk); }, false); + sit, + [](PublicKey const& pk) { return calcNodeID(pk); }, + STValidation::DeserializeOptions{ + .checkSignature = false, .requireCanonicalOrder = false}); fail("Expected exception not thrown from validation"); } @@ -279,7 +303,10 @@ public: SerialIter sit{makeSlice(v2)}; auto val = std::make_shared( - sit, [](PublicKey const& pk) { return calcNodeID(pk); }, true); + sit, + [](PublicKey const& pk) { return calcNodeID(pk); }, + STValidation::DeserializeOptions{ + .checkSignature = true, .requireCanonicalOrder = false}); fail("Mutated validation signature checked out: offset=" + std::to_string(i)); } diff --git a/src/xrpld/overlay/detail/PeerImp.cpp b/src/xrpld/overlay/detail/PeerImp.cpp index 5662b6d33d..962ab0f408 100644 --- a/src/xrpld/overlay/detail/PeerImp.cpp +++ b/src/xrpld/overlay/detail/PeerImp.cpp @@ -2346,12 +2346,22 @@ PeerImp::onMessage(std::shared_ptr const& m) std::shared_ptr val; { SerialIter sit(makeSlice(m->validation())); - val = std::make_shared( - std::ref(sit), - [this](PublicKey const& pk) { - return calcNodeID(app_.getValidatorManifests().getMasterKey(pk)); - }, - false); + try + { + val = std::make_shared( + std::ref(sit), + [this](PublicKey const& pk) { + return calcNodeID(app_.getValidatorManifests().getMasterKey(pk)); + }, + STValidation::DeserializeOptions{ + .checkSignature = false, .requireCanonicalOrder = true}); + } + catch (std::exception const& e) + { + JLOG(pJournal_.warn()) << "Validation: Exception, " << e.what(); + fee_.update(Resource::kFeeInvalidData, e.what()); + return; + } val->setSeen(closeTime); } From 06a9b1b61762e3b7fa71b40720a13e93ce95b1dc Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Fri, 17 Jul 2026 13:07:01 +0100 Subject: [PATCH 14/52] chore: Update mpt-crypto to 1.0.2 --- conan.lock | 2 +- conanfile.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/conan.lock b/conan.lock index 9dfbb86960..b5d0fe2cd4 100644 --- a/conan.lock +++ b/conan.lock @@ -12,7 +12,7 @@ "protobuf/6.33.5#ff253ead763bd8d9904a52979cd21e81%1782392410.233933", "openssl/3.6.3#f806de8933e3bf6f01016c6a888cee2e%1783945160.863288", "nudb/2.0.9#11149c73f8f2baff9a0198fe25971fc7%1782392402.297166", - "mpt-crypto/0.4.0-rc4#ffdba12f2332357f0d8b0ae944cfff52%1784138702.932355", + "mpt-crypto/1.0.2#b313cef0c1a493eb970ad185b2e9bab7%1784285108.866483", "lz4/1.10.0#982d9b673900f665a1da109e09c17cab%1782392402.164188", "libiconv/1.17#9923bc6dc6f106646d6967e0039a5ada%1782392792.775744", "libbacktrace/cci.20210118#a7691bfccd8caaf66309df196790a5a1%1782392402.420732", diff --git a/conanfile.py b/conanfile.py index f0b10cf34b..bde79db65d 100644 --- a/conanfile.py +++ b/conanfile.py @@ -134,7 +134,7 @@ class Xrpl(ConanFile): if self.options.jemalloc: self.requires("jemalloc/5.3.1") self.requires("lz4/1.10.0", force=True) - self.requires("mpt-crypto/0.4.0-rc4", transitive_headers=True) + self.requires("mpt-crypto/1.0.2", transitive_headers=True) self.requires("protobuf/6.33.5", force=True) if self.options.rocksdb: self.requires("rocksdb/10.5.1") From 90b2a68da89646afdceab3fd47545c64837c29a1 Mon Sep 17 00:00:00 2001 From: Valentin Balaschenko <13349202+vlntb@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:07:42 +0100 Subject: [PATCH 15/52] fix: Reject oversized SHAMap nodes in gotStaleData and fetch-pack path --- src/xrpld/app/ledger/InboundLedgers.h | 31 +++++++++++++++++++ .../app/ledger/detail/InboundLedgers.cpp | 3 ++ src/xrpld/overlay/detail/PeerImp.cpp | 13 ++++++++ 3 files changed, 47 insertions(+) diff --git a/src/xrpld/app/ledger/InboundLedgers.h b/src/xrpld/app/ledger/InboundLedgers.h index e288201c66..97182644e7 100644 --- a/src/xrpld/app/ledger/InboundLedgers.h +++ b/src/xrpld/app/ledger/InboundLedgers.h @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -20,6 +21,36 @@ namespace xrpl { +// Per-node cap for AS state leaves stashed via `gotStaleData`. +// +// `gotStaleData` only handles `liAS_NODE` payloads, which carry +// SHAMap state-map leaves (ledger objects). +// +// Sizing: worst-case serialized size across all 31 ledger entry +// types is ~53 KB (`XChainOwnedCreateAccountClaimID`, 256 +// attestations x ~209 B, capped by `kMaxAttestations` in +// `include/xrpl/protocol/XChainAttestations.h`), followed by +// `XChainOwnedClaimID` ~40 KB, `NFTokenPage` ~9.5 KB, and +// `LedgerHashes` ~8.2 KB. 256 KiB leaves ~4.8x headroom over the +// current worst case. +// +// Future-proofing: this cap is NOT derived from a single protocol +// constant — it is a soft bound over independently-tuned caps +// (`kMaxAttestations`, `kDirMaxTokensPerPage`, `kMaxTokenUriLength`, +// etc.). Two types (`Amendments`, `NegativeUNL`) have no hard schema +// cap and grow with network state. Revisit if a new object type or +// a lifted array cap approaches ~256 KiB. The downstream +// `SHAMapAccountStateLeafNode` construction rejects anything above +// the 16 MiB SHAMapItem invariant regardless. +inline constexpr std::size_t kMaxFetchPackNodeBytes = 256 * 1024; + +// Aggregate cap on the sum of `nodedata().size()` across all entries +// in a single `TMLedgerData` message. Rejects amplification-shaped +// payloads (many nodes, each individually under `kMaxFetchPackNodeBytes`, +// that together dwarf the per-message budget) at ingress in PeerImp, +// before dispatch into `InboundLedger::gotData` or `gotStaleData`. +inline constexpr std::size_t kMaxLedgerDataBytes = megabytes(1); + /** * Manages the lifetime of inbound ledgers. * diff --git a/src/xrpld/app/ledger/detail/InboundLedgers.cpp b/src/xrpld/app/ledger/detail/InboundLedgers.cpp index dc361694cf..81544fd234 100644 --- a/src/xrpld/app/ledger/detail/InboundLedgers.cpp +++ b/src/xrpld/app/ledger/detail/InboundLedgers.cpp @@ -259,6 +259,9 @@ public: if (!node.has_nodeid() || !node.has_nodedata()) return; + if (node.nodedata().size() > kMaxFetchPackNodeBytes) + return; + auto newNode = SHAMapTreeNode::makeFromWire(makeSlice(node.nodedata())); if (!newNode) diff --git a/src/xrpld/overlay/detail/PeerImp.cpp b/src/xrpld/overlay/detail/PeerImp.cpp index 962ab0f408..0b969792ba 100644 --- a/src/xrpld/overlay/detail/PeerImp.cpp +++ b/src/xrpld/overlay/detail/PeerImp.cpp @@ -1699,6 +1699,19 @@ PeerImp::onMessage(std::shared_ptr const& m) return; } + { + std::size_t totalNodeBytes = 0; + for (int i = 0; i < m->nodes_size(); ++i) + totalNodeBytes += m->nodes(i).nodedata().size(); + if (totalNodeBytes > kMaxLedgerDataBytes) + { + JLOG(pJournal_.warn()) + << "Ledger data: oversized nodes (" << totalNodeBytes << " bytes)"; + fee_.update(Resource::kFeeInvalidData, "oversized ledger nodes"); + return; + } + } + // If there is a request cookie, attempt to relay the message if (m->has_requestcookie()) { From bf65e5fa7ba42f5fdb6f7ae2cff01ed344730723 Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Fri, 17 Jul 2026 16:20:28 +0100 Subject: [PATCH 16/52] chore: Upload codecov for whole XRPLF org --- .github/workflows/reusable-build-test-config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/reusable-build-test-config.yml b/.github/workflows/reusable-build-test-config.yml index b2327f67ea..a1daeed5fe 100644 --- a/.github/workflows/reusable-build-test-config.yml +++ b/.github/workflows/reusable-build-test-config.yml @@ -366,7 +366,7 @@ jobs: --target coverage - name: Upload coverage report - if: ${{ github.repository == 'XRPLF/rippled' && !inputs.build_only && env.COVERAGE_ENABLED == 'true' }} + if: ${{ github.repository_owner == 'XRPLF' && !inputs.build_only && env.COVERAGE_ENABLED == 'true' }} uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: disable_search: true From 033dca2f0e709360cc305b0e7a18615d642d114a Mon Sep 17 00:00:00 2001 From: yinyiqian1 Date: Fri, 17 Jul 2026 14:02:35 -0400 Subject: [PATCH 17/52] feat: Make DynamicMPT opt-in-immutable --- include/xrpl/protocol/LedgerFormats.h | 22 +- include/xrpl/protocol/TxFlags.h | 59 +- .../xrpl/protocol/detail/ledger_entries.macro | 2 +- include/xrpl/protocol/detail/sfields.macro | 2 +- .../xrpl/protocol/detail/transactions.macro | 4 +- .../ledger_entries/MPTokenIssuance.h | 20 +- .../transactions/MPTokenIssuanceCreate.h | 20 +- .../transactions/MPTokenIssuanceSet.h | 20 +- .../transactors/token/MPTokenIssuanceCreate.h | 2 +- .../tx/transactors/token/MPTokenIssuanceSet.h | 34 ++ .../token/MPTokenIssuanceCreate.cpp | 32 +- .../transactors/token/MPTokenIssuanceSet.cpp | 139 ++--- .../tx/transactors/vault/VaultCreate.cpp | 1 - src/test/app/ConfidentialTransfer_test.cpp | 62 +- src/test/app/Delegate_test.cpp | 7 +- src/test/app/Loan_test.cpp | 5 +- src/test/app/MPToken_test.cpp | 538 +++++++++--------- src/test/app/Vault_test.cpp | 9 +- src/test/jtx/impl/mpt.cpp | 170 +++--- src/test/jtx/mpt.h | 9 +- .../ledger_entries/MPTokenIssuanceTests.cpp | 30 +- .../MPTokenIssuanceCreateTests.cpp | 30 +- .../transactions/MPTokenIssuanceSetTests.cpp | 30 +- 23 files changed, 619 insertions(+), 628 deletions(-) diff --git a/include/xrpl/protocol/LedgerFormats.h b/include/xrpl/protocol/LedgerFormats.h index 7c504f6bdd..68205e27e6 100644 --- a/include/xrpl/protocol/LedgerFormats.h +++ b/include/xrpl/protocol/LedgerFormats.h @@ -190,17 +190,6 @@ enum LedgerEntryType : std::uint16_t { LSF_FLAG(lsfMPTCanClawback, 0x00000040) \ LSF_FLAG(lsfMPTCanHoldConfidentialBalance, 0x00000080)) \ \ - LEDGER_OBJECT(MPTokenIssuanceMutable, \ - LSF_FLAG(lsmfMPTCanEnableCanLock, 0x00000002) \ - LSF_FLAG(lsmfMPTCanEnableRequireAuth, 0x00000004) \ - LSF_FLAG(lsmfMPTCanEnableCanEscrow, 0x00000008) \ - LSF_FLAG(lsmfMPTCanEnableCanTrade, 0x00000010) \ - LSF_FLAG(lsmfMPTCanEnableCanTransfer, 0x00000020) \ - LSF_FLAG(lsmfMPTCanEnableCanClawback, 0x00000040) \ - LSF_FLAG(lsmfMPTCannotEnableCanHoldConfidentialBalance, 0x00000080) \ - LSF_FLAG(lsmfMPTCanMutateMetadata, 0x00010000) \ - LSF_FLAG(lsmfMPTCanMutateTransferFee, 0x00020000)) \ - \ LEDGER_OBJECT(MPToken, \ LSF_FLAG2(lsfMPTLocked, 0x00000001) \ LSF_FLAG(lsfMPTAuthorized, 0x00000002) \ @@ -294,6 +283,17 @@ getAllLedgerFlags() #pragma pop_macro("TO_MAP") #pragma pop_macro("ALL_LEDGER_FLAGS") +// MPTokenIssuance ImmutableFlags (sfImmutableFlags) +inline constexpr std::uint32_t lsifMPTCanLock = 0x00000002; +inline constexpr std::uint32_t lsifMPTRequireAuth = 0x00000004; +inline constexpr std::uint32_t lsifMPTCanEscrow = 0x00000008; +inline constexpr std::uint32_t lsifMPTCanTrade = 0x00000010; +inline constexpr std::uint32_t lsifMPTCanTransfer = 0x00000020; +inline constexpr std::uint32_t lsifMPTCanClawback = 0x00000040; +inline constexpr std::uint32_t lsifMPTCanHoldConfidentialBalance = 0x00000080; +inline constexpr std::uint32_t lsifMPTMetadata = 0x00010000; +inline constexpr std::uint32_t lsifMPTTransferFee = 0x00020000; + //------------------------------------------------------------------------------ /** diff --git a/include/xrpl/protocol/TxFlags.h b/include/xrpl/protocol/TxFlags.h index 0afdebb898..14bc0571e9 100644 --- a/include/xrpl/protocol/TxFlags.h +++ b/include/xrpl/protocol/TxFlags.h @@ -152,7 +152,14 @@ inline constexpr FlagValue tfUniversalMask = ~tfUniversal; \ TRANSACTION(MPTokenIssuanceSet, \ TF_FLAG(tfMPTLock, 0x00000001) \ - TF_FLAG(tfMPTUnlock, 0x00000002), \ + TF_FLAG(tfMPTUnlock, 0x00000002) \ + TF_FLAG(tfMPTSetCanLock, 0x00000004) \ + TF_FLAG(tfMPTSetRequireAuth, 0x00000008) \ + TF_FLAG(tfMPTSetCanEscrow, 0x00000010) \ + TF_FLAG(tfMPTSetCanTrade, 0x00000020) \ + TF_FLAG(tfMPTSetCanTransfer, 0x00000040) \ + TF_FLAG(tfMPTSetCanClawback, 0x00000080) \ + TF_FLAG(tfMPTSetCanHoldConfidentialBalance, 0x00000100), \ MASK_ADJ(0)) \ \ TRANSACTION(NFTokenCreateOffer, \ @@ -356,38 +363,26 @@ inline constexpr FlagValue tfMPTPaymentMask = ~(tfUniversal | tfPartialPayment); inline constexpr FlagValue tfTrustSetPermissionMask = ~(tfUniversal | tfSetfAuth | tfSetFreeze | tfClearFreeze); -// MPTokenIssuanceCreate MutableFlags: -// Indicating specific fields or flags may be changed after issuance. -inline constexpr FlagValue tmfMPTCanEnableCanLock = lsmfMPTCanEnableCanLock; -inline constexpr FlagValue tmfMPTCanEnableRequireAuth = lsmfMPTCanEnableRequireAuth; -inline constexpr FlagValue tmfMPTCanEnableCanEscrow = lsmfMPTCanEnableCanEscrow; -inline constexpr FlagValue tmfMPTCanEnableCanTrade = lsmfMPTCanEnableCanTrade; -inline constexpr FlagValue tmfMPTCanEnableCanTransfer = lsmfMPTCanEnableCanTransfer; -inline constexpr FlagValue tmfMPTCanEnableCanClawback = lsmfMPTCanEnableCanClawback; -inline constexpr FlagValue tmfMPTCanMutateMetadata = lsmfMPTCanMutateMetadata; -inline constexpr FlagValue tmfMPTCanMutateTransferFee = lsmfMPTCanMutateTransferFee; -inline constexpr FlagValue tmfMPTCannotEnableCanHoldConfidentialBalance = - lsmfMPTCannotEnableCanHoldConfidentialBalance; -inline constexpr FlagValue tmfMPTokenIssuanceCreateMutableMask = - ~(tmfMPTCanEnableCanLock | tmfMPTCanEnableRequireAuth | tmfMPTCanEnableCanEscrow | - tmfMPTCanEnableCanTrade | tmfMPTCanEnableCanTransfer | tmfMPTCanEnableCanClawback | - tmfMPTCanMutateMetadata | tmfMPTCanMutateTransferFee | - tmfMPTCannotEnableCanHoldConfidentialBalance); +// MPTokenIssuanceCreate / MPTokenIssuanceSet ImmutableFlags: +// Defines the immutable fields and flags specific to MPTokenIssuance. +inline constexpr FlagValue tifMPTCanLock = lsifMPTCanLock; +inline constexpr FlagValue tifMPTRequireAuth = lsifMPTRequireAuth; +inline constexpr FlagValue tifMPTCanEscrow = lsifMPTCanEscrow; +inline constexpr FlagValue tifMPTCanTrade = lsifMPTCanTrade; +inline constexpr FlagValue tifMPTCanTransfer = lsifMPTCanTransfer; +inline constexpr FlagValue tifMPTCanClawback = lsifMPTCanClawback; +inline constexpr FlagValue tifMPTMetadata = lsifMPTMetadata; +inline constexpr FlagValue tifMPTTransferFee = lsifMPTTransferFee; +inline constexpr FlagValue tifMPTCanHoldConfidentialBalance = lsifMPTCanHoldConfidentialBalance; +inline constexpr FlagValue tifMPTokenIssuanceImmutableMask = + ~(tifMPTCanLock | tifMPTRequireAuth | tifMPTCanEscrow | tifMPTCanTrade | tifMPTCanTransfer | + tifMPTCanClawback | tifMPTMetadata | tifMPTTransferFee | tifMPTCanHoldConfidentialBalance); -// MPTokenIssuanceSet MutableFlags: -// Enable mutable capability flags. These flags are one-way: once enabled, -// the corresponding capability cannot be disabled by MPTokenIssuanceSet. - -inline constexpr FlagValue tmfMPTSetCanLock = 0x00000001; -inline constexpr FlagValue tmfMPTSetRequireAuth = 0x00000002; -inline constexpr FlagValue tmfMPTSetCanEscrow = 0x00000004; -inline constexpr FlagValue tmfMPTSetCanTrade = 0x00000008; -inline constexpr FlagValue tmfMPTSetCanTransfer = 0x00000010; -inline constexpr FlagValue tmfMPTSetCanClawback = 0x00000020; -inline constexpr FlagValue tmfMPTSetCanHoldConfidentialBalance = 0x00000040; -inline constexpr FlagValue tmfMPTokenIssuanceSetMutableMask = - ~(tmfMPTSetCanLock | tmfMPTSetRequireAuth | tmfMPTSetCanEscrow | tmfMPTSetCanTrade | - tmfMPTSetCanTransfer | tmfMPTSetCanClawback | tmfMPTSetCanHoldConfidentialBalance); +// MPTokenIssuanceSet set of flags that is used to enable capabilities on an MPTokenIssuance. +// Used as `txFlags & tfMPTokenIssuanceSetEnableFlagMask` to extract the capability-enabling bits. +inline constexpr FlagValue tfMPTokenIssuanceSetEnableFlagMask = tfMPTSetCanLock | + tfMPTSetRequireAuth | tfMPTSetCanEscrow | tfMPTSetCanTrade | tfMPTSetCanTransfer | + tfMPTSetCanClawback | tfMPTSetCanHoldConfidentialBalance; // Prior to fixRemoveNFTokenAutoTrustLine, transfer of an NFToken between accounts allowed a // TrustLine to be added to the issuer of that token without explicit permission from that issuer. diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index 90810e06d2..cc2eff0c52 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -404,7 +404,7 @@ LEDGER_ENTRY(ltMPTOKEN_ISSUANCE, 0x007e, MPTokenIssuance, mpt_issuance, ({ {sfPreviousTxnID, SoeRequired}, {sfPreviousTxnLgrSeq, SoeRequired}, {sfDomainID, SoeOptional}, - {sfMutableFlags, SoeDefault}, + {sfImmutableFlags, SoeDefault}, {sfReferenceHolding, SoeOptional}, {sfIssuerEncryptionKey, SoeOptional}, {sfAuditorEncryptionKey, SoeOptional}, diff --git a/include/xrpl/protocol/detail/sfields.macro b/include/xrpl/protocol/detail/sfields.macro index 4ef76c8b75..56527628c9 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -97,7 +97,7 @@ TYPED_SFIELD(sfVoteWeight, UINT32, 48) TYPED_SFIELD(sfFirstNFTokenSequence, UINT32, 50) TYPED_SFIELD(sfOracleDocumentID, UINT32, 51) TYPED_SFIELD(sfPermissionValue, UINT32, 52) -TYPED_SFIELD(sfMutableFlags, UINT32, 53) +TYPED_SFIELD(sfImmutableFlags, UINT32, 53) TYPED_SFIELD(sfStartDate, UINT32, 54) TYPED_SFIELD(sfPaymentInterval, UINT32, 55) TYPED_SFIELD(sfGracePeriod, UINT32, 56) diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index e805596c00..dc03cf7c59 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -705,7 +705,7 @@ TRANSACTION(ttMPTOKEN_ISSUANCE_CREATE, 54, MPTokenIssuanceCreate, {sfMaximumAmount, SoeOptional}, {sfMPTokenMetadata, SoeOptional}, {sfDomainID, SoeOptional}, - {sfMutableFlags, SoeOptional}, + {sfImmutableFlags, SoeOptional}, })) /** This transaction type destroys a MPTokensIssuance instance */ @@ -734,7 +734,7 @@ TRANSACTION(ttMPTOKEN_ISSUANCE_SET, 56, MPTokenIssuanceSet, {sfDomainID, SoeOptional}, {sfMPTokenMetadata, SoeOptional}, {sfTransferFee, SoeOptional}, - {sfMutableFlags, SoeOptional}, + {sfImmutableFlags, SoeOptional}, {sfIssuerEncryptionKey, SoeOptional}, {sfAuditorEncryptionKey, SoeOptional}, })) diff --git a/include/xrpl/protocol_autogen/ledger_entries/MPTokenIssuance.h b/include/xrpl/protocol_autogen/ledger_entries/MPTokenIssuance.h index 8518a0fe14..6a2caf52ae 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/MPTokenIssuance.h +++ b/include/xrpl/protocol_autogen/ledger_entries/MPTokenIssuance.h @@ -256,27 +256,27 @@ public: } /** - * @brief Get sfMutableFlags (SoeDefault) + * @brief Get sfImmutableFlags (SoeDefault) * @return The field value, or std::nullopt if not present. */ [[nodiscard]] protocol_autogen::Optional - getMutableFlags() const + getImmutableFlags() const { - if (hasMutableFlags()) - return this->sle_->at(sfMutableFlags); + if (hasImmutableFlags()) + return this->sle_->at(sfImmutableFlags); return std::nullopt; } /** - * @brief Check if sfMutableFlags is present. + * @brief Check if sfImmutableFlags is present. * @return True if the field is present, false otherwise. */ [[nodiscard]] bool - hasMutableFlags() const + hasImmutableFlags() const { - return this->sle_->isFieldPresent(sfMutableFlags); + return this->sle_->isFieldPresent(sfImmutableFlags); } /** @@ -557,13 +557,13 @@ public: } /** - * @brief Set sfMutableFlags (SoeDefault) + * @brief Set sfImmutableFlags (SoeDefault) * @return Reference to this builder for method chaining. */ MPTokenIssuanceBuilder& - setMutableFlags(std::decay_t const& value) + setImmutableFlags(std::decay_t const& value) { - object_[sfMutableFlags] = value; + object_[sfImmutableFlags] = value; return *this; } diff --git a/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceCreate.h b/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceCreate.h index e6fece8354..82ffba9996 100644 --- a/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceCreate.h +++ b/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceCreate.h @@ -178,29 +178,29 @@ public: } /** - * @brief Get sfMutableFlags (SoeOptional) + * @brief Get sfImmutableFlags (SoeOptional) * @return The field value, or std::nullopt if not present. */ [[nodiscard]] protocol_autogen::Optional - getMutableFlags() const + getImmutableFlags() const { - if (hasMutableFlags()) + if (hasImmutableFlags()) { - return this->tx_->at(sfMutableFlags); + return this->tx_->at(sfImmutableFlags); } return std::nullopt; } /** - * @brief Check if sfMutableFlags is present. + * @brief Check if sfImmutableFlags is present. * @return True if the field is present, false otherwise. */ [[nodiscard]] bool - hasMutableFlags() const + hasImmutableFlags() const { - return this->tx_->isFieldPresent(sfMutableFlags); + return this->tx_->isFieldPresent(sfImmutableFlags); } }; @@ -302,13 +302,13 @@ public: } /** - * @brief Set sfMutableFlags (SoeOptional) + * @brief Set sfImmutableFlags (SoeOptional) * @return Reference to this builder for method chaining. */ MPTokenIssuanceCreateBuilder& - setMutableFlags(std::decay_t const& value) + setImmutableFlags(std::decay_t const& value) { - object_[sfMutableFlags] = value; + object_[sfImmutableFlags] = value; return *this; } diff --git a/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceSet.h b/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceSet.h index 803868c640..ed7e1f0f6c 100644 --- a/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceSet.h +++ b/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceSet.h @@ -163,29 +163,29 @@ public: } /** - * @brief Get sfMutableFlags (SoeOptional) + * @brief Get sfImmutableFlags (SoeOptional) * @return The field value, or std::nullopt if not present. */ [[nodiscard]] protocol_autogen::Optional - getMutableFlags() const + getImmutableFlags() const { - if (hasMutableFlags()) + if (hasImmutableFlags()) { - return this->tx_->at(sfMutableFlags); + return this->tx_->at(sfImmutableFlags); } return std::nullopt; } /** - * @brief Check if sfMutableFlags is present. + * @brief Check if sfImmutableFlags is present. * @return True if the field is present, false otherwise. */ [[nodiscard]] bool - hasMutableFlags() const + hasImmutableFlags() const { - return this->tx_->isFieldPresent(sfMutableFlags); + return this->tx_->isFieldPresent(sfImmutableFlags); } /** @@ -341,13 +341,13 @@ public: } /** - * @brief Set sfMutableFlags (SoeOptional) + * @brief Set sfImmutableFlags (SoeOptional) * @return Reference to this builder for method chaining. */ MPTokenIssuanceSetBuilder& - setMutableFlags(std::decay_t const& value) + setImmutableFlags(std::decay_t const& value) { - object_[sfMutableFlags] = value; + object_[sfImmutableFlags] = value; return *this; } diff --git a/include/xrpl/tx/transactors/token/MPTokenIssuanceCreate.h b/include/xrpl/tx/transactors/token/MPTokenIssuanceCreate.h index 5d35f65f44..1aa853d6e2 100644 --- a/include/xrpl/tx/transactors/token/MPTokenIssuanceCreate.h +++ b/include/xrpl/tx/transactors/token/MPTokenIssuanceCreate.h @@ -32,7 +32,7 @@ struct MPTCreateArgs std::optional transferFee = std::nullopt; std::optional const& metadata{}; std::optional domainId = std::nullopt; - std::optional mutableFlags = std::nullopt; + std::optional immutableFlags = std::nullopt; // Set only by callers that issue an MPT representing a wrapped asset // (e.g. VaultCreate's share token). The keylet must point to an // existing MPToken or RippleState owned by `account`. Surfaces on diff --git a/include/xrpl/tx/transactors/token/MPTokenIssuanceSet.h b/include/xrpl/tx/transactors/token/MPTokenIssuanceSet.h index 52f155e8fe..a2a966009d 100644 --- a/include/xrpl/tx/transactors/token/MPTokenIssuanceSet.h +++ b/include/xrpl/tx/transactors/token/MPTokenIssuanceSet.h @@ -3,12 +3,15 @@ #include #include #include +#include #include #include +#include #include #include #include +#include #include namespace xrpl { @@ -22,6 +25,37 @@ public: { } + // Maps each MPTokenIssuanceSet set flag(e.g., tfMPTSetCanLock), to the issuance's + // corresponding immutable flag (e.g., lsifMPTCanLock) and the target ledger flag (e.g., + // lsfMPTCanLock). + struct FlagMapping + { + std::uint32_t setFlag; + std::uint32_t immutableFlag; + std::uint32_t ledgerFlag; + }; + + static constexpr std::array flagMapping = { + {{.setFlag = tfMPTSetCanLock, .immutableFlag = lsifMPTCanLock, .ledgerFlag = lsfMPTCanLock}, + {.setFlag = tfMPTSetRequireAuth, + .immutableFlag = lsifMPTRequireAuth, + .ledgerFlag = lsfMPTRequireAuth}, + {.setFlag = tfMPTSetCanEscrow, + .immutableFlag = lsifMPTCanEscrow, + .ledgerFlag = lsfMPTCanEscrow}, + {.setFlag = tfMPTSetCanTrade, + .immutableFlag = lsifMPTCanTrade, + .ledgerFlag = lsfMPTCanTrade}, + {.setFlag = tfMPTSetCanTransfer, + .immutableFlag = lsifMPTCanTransfer, + .ledgerFlag = lsfMPTCanTransfer}, + {.setFlag = tfMPTSetCanClawback, + .immutableFlag = lsifMPTCanClawback, + .ledgerFlag = lsfMPTCanClawback}, + {.setFlag = tfMPTSetCanHoldConfidentialBalance, + .immutableFlag = lsifMPTCanHoldConfidentialBalance, + .ledgerFlag = lsfMPTCanHoldConfidentialBalance}}}; + static bool checkExtraFeatures(PreflightContext const& ctx); diff --git a/src/libxrpl/tx/transactors/token/MPTokenIssuanceCreate.cpp b/src/libxrpl/tx/transactors/token/MPTokenIssuanceCreate.cpp index aad1642f68..375110c330 100644 --- a/src/libxrpl/tx/transactors/token/MPTokenIssuanceCreate.cpp +++ b/src/libxrpl/tx/transactors/token/MPTokenIssuanceCreate.cpp @@ -35,18 +35,24 @@ MPTokenIssuanceCreate::checkExtraFeatures(PreflightContext const& ctx) ctx.rules.enabled(featureSingleAssetVault))) return false; - if (ctx.tx.isFieldPresent(sfMutableFlags) && !ctx.rules.enabled(featureDynamicMPT)) + if (ctx.tx.isFieldPresent(sfImmutableFlags) && !ctx.rules.enabled(featureDynamicMPT)) return false; if (ctx.tx.isFlag(tfMPTCanHoldConfidentialBalance) && !ctx.rules.enabled(featureConfidentialTransfer)) return false; - // can not set tmfMPTCannotEnableCanHoldConfidentialBalance without featureConfidentialTransfer - auto const mutableFlags = ctx.tx[~sfMutableFlags]; - return !mutableFlags || - ((*mutableFlags & tmfMPTCannotEnableCanHoldConfidentialBalance) == 0u) || - ctx.rules.enabled(featureConfidentialTransfer); + // can not set tifMPTCanHoldConfidentialBalance without featureConfidentialTransfer + auto const immutableFlags = ctx.tx[~sfImmutableFlags]; + // NOLINTBEGIN(readability-simplify-boolean-expr) + if (immutableFlags && ((*immutableFlags & tifMPTCanHoldConfidentialBalance) != 0u) && + !ctx.rules.enabled(featureConfidentialTransfer)) + { + return false; + } + // NOLINTEND(readability-simplify-boolean-expr) + + return true; } std::uint32_t @@ -64,10 +70,10 @@ MPTokenIssuanceCreate::preflight(PreflightContext const& ctx) if (ctx.rules.enabled(fixCleanup3_2_0) && ctx.tx.isFieldPresent(sfReferenceHolding)) return temMALFORMED; - // If the mutable flags field is included, at least one flag must be - // specified. - if (auto const mutableFlags = ctx.tx[~sfMutableFlags]; mutableFlags && - ((*mutableFlags == 0u) || ((*mutableFlags & tmfMPTokenIssuanceCreateMutableMask) != 0u))) + // If the immutable flags field is included, at least one flag must be + // specified, and undefined flags must not be specified. + if (auto const immutableFlags = ctx.tx[~sfImmutableFlags]; immutableFlags && + ((*immutableFlags == 0u) || ((*immutableFlags & tifMPTokenIssuanceImmutableMask) != 0u))) return temINVALID_FLAG; if (auto const fee = ctx.tx[~sfTransferFee]) @@ -170,8 +176,8 @@ MPTokenIssuanceCreate::create( if (args.domainId) (*mptIssuance)[sfDomainID] = *args.domainId; - if (args.mutableFlags) - (*mptIssuance)[sfMutableFlags] = *args.mutableFlags; + if (args.immutableFlags) + (*mptIssuance)[sfImmutableFlags] = *args.immutableFlags; if (args.referenceHolding) { @@ -217,7 +223,7 @@ MPTokenIssuanceCreate::doApply() .transferFee = tx[~sfTransferFee], .metadata = tx[~sfMPTokenMetadata], .domainId = tx[~sfDomainID], - .mutableFlags = tx[~sfMutableFlags], + .immutableFlags = tx[~sfImmutableFlags], }); return result ? tesSUCCESS : result.error(); } diff --git a/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp b/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp index d526251069..e8fd2e22b6 100644 --- a/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp +++ b/src/libxrpl/tx/transactors/token/MPTokenIssuanceSet.cpp @@ -20,7 +20,6 @@ #include #include -#include #include namespace xrpl { @@ -39,56 +38,29 @@ MPTokenIssuanceSet::getFlagsMask(PreflightContext const& ctx) return tfMPTokenIssuanceSetMask; } -// Maps each MPTokenIssuanceSet MutableFlags to the corresponding mutable -// flag and the target ledger flag to mutate. -struct MPTMutabilityFlags -{ - std::uint32_t setFlag; - std::uint32_t canEnableFlag; - std::uint32_t ledgerFlag; -}; - -static constexpr std::array kMptMutabilityFlags = { - {{.setFlag = tmfMPTSetCanLock, - .canEnableFlag = lsmfMPTCanEnableCanLock, - .ledgerFlag = lsfMPTCanLock}, - {.setFlag = tmfMPTSetRequireAuth, - .canEnableFlag = lsmfMPTCanEnableRequireAuth, - .ledgerFlag = lsfMPTRequireAuth}, - {.setFlag = tmfMPTSetCanEscrow, - .canEnableFlag = lsmfMPTCanEnableCanEscrow, - .ledgerFlag = lsfMPTCanEscrow}, - {.setFlag = tmfMPTSetCanTrade, - .canEnableFlag = lsmfMPTCanEnableCanTrade, - .ledgerFlag = lsfMPTCanTrade}, - {.setFlag = tmfMPTSetCanTransfer, - .canEnableFlag = lsmfMPTCanEnableCanTransfer, - .ledgerFlag = lsfMPTCanTransfer}, - {.setFlag = tmfMPTSetCanClawback, - .canEnableFlag = lsmfMPTCanEnableCanClawback, - .ledgerFlag = lsfMPTCanClawback}}}; - NotTEC MPTokenIssuanceSet::preflight(PreflightContext const& ctx) { - auto const mutableFlags = ctx.tx[~sfMutableFlags]; + auto const txFlags = ctx.tx.getFlags(); + auto const enableFlags = txFlags & tfMPTokenIssuanceSetEnableFlagMask; auto const metadata = ctx.tx[~sfMPTokenMetadata]; auto const transferFee = ctx.tx[~sfTransferFee]; - auto const isMutate = mutableFlags || metadata || transferFee; + auto const immutableFlags = ctx.tx[~sfImmutableFlags]; + auto const isMutate = (enableFlags != 0u) || metadata || transferFee || immutableFlags; auto const hasIssuerElGamalKey = ctx.tx.isFieldPresent(sfIssuerEncryptionKey); auto const hasAuditorElGamalKey = ctx.tx.isFieldPresent(sfAuditorEncryptionKey); - auto const txFlags = ctx.tx.getFlags(); - - bool const enablePrivacy = - mutableFlags && (*mutableFlags & tmfMPTSetCanHoldConfidentialBalance) != 0u; + bool const enablePrivacy = (enableFlags & tfMPTSetCanHoldConfidentialBalance) != 0u; auto const hasDomain = ctx.tx.isFieldPresent(sfDomainID); auto const hasHolder = ctx.tx.isFieldPresent(sfHolder); if (isMutate && !ctx.rules.enabled(featureDynamicMPT)) return temDISABLED; - if ((hasIssuerElGamalKey || hasAuditorElGamalKey || enablePrivacy) && + bool const setConfidentialBalanceImmutable = + immutableFlags && (*immutableFlags & tifMPTCanHoldConfidentialBalance) != 0u; + if ((hasIssuerElGamalKey || hasAuditorElGamalKey || enablePrivacy || + setConfidentialBalanceImmutable) && !ctx.rules.enabled(featureConfidentialTransfer)) return temDISABLED; @@ -122,8 +94,9 @@ MPTokenIssuanceSet::preflight(PreflightContext const& ctx) if (isMutate && holderID) return temMALFORMED; - // Can not set flags when mutating MPTokenIssuance - if (isMutate && ((ctx.tx.getFlags() & tfUniversalMask) != 0u)) + // A single transaction may either lock/unlock or mutate capability + // flags, but not both. + if (isMutate && (ctx.tx.isFlag(tfMPTLock) || ctx.tx.isFlag(tfMPTUnlock))) return temMALFORMED; if (transferFee && *transferFee > kMaxTransferFee) @@ -135,11 +108,12 @@ MPTokenIssuanceSet::preflight(PreflightContext const& ctx) if (metadata && metadata->length() > kMaxMpTokenMetadataLength) return temMALFORMED; - if (mutableFlags) - { - if ((*mutableFlags == 0u) || ((*mutableFlags & tmfMPTokenIssuanceSetMutableMask) != 0u)) - return temINVALID_FLAG; - } + // If the immutable flags field is included, at least one flag must be + // specified, and undefined flags must not be specified. + if (immutableFlags && + ((*immutableFlags == 0u) || + ((*immutableFlags & tifMPTokenIssuanceImmutableMask) != 0u))) + return temINVALID_FLAG; } if (hasHolder && (hasIssuerElGamalKey || hasAuditorElGamalKey)) @@ -207,40 +181,32 @@ MPTokenIssuanceSet::preclaim(PreclaimContext const& ctx) } } - // sfMutableFlags is soeDEFAULT, defaulting to 0 if not specified on + // sfImmutableFlags is soeDEFAULT, defaulting to 0 if not specified on // the ledger. - auto const currentMutableFlags = sleMptIssuance->getFieldU32(sfMutableFlags); + auto const currentImmutableFlags = sleMptIssuance->getFieldU32(sfImmutableFlags); - auto isMutableFlag = [&](std::uint32_t mutableFlag) -> bool { - return currentMutableFlags & mutableFlag; - }; + auto isImmutable = [&](std::uint32_t flag) -> bool { return currentImmutableFlags & flag; }; - auto const mutableFlags = ctx.tx[~sfMutableFlags]; - // Whether the transaction is enabling confidential amounts. - bool const enablesConfidentialAmount = - mutableFlags && (*mutableFlags & tmfMPTSetCanHoldConfidentialBalance) != 0u; - if (mutableFlags) + auto const enableFlags = ctx.tx.getFlags() & tfMPTokenIssuanceSetEnableFlagMask; + if (enableFlags != 0u) { - if (std::ranges::any_of(kMptMutabilityFlags, [mutableFlags, &isMutableFlag](auto const& f) { - return !isMutableFlag(f.canEnableFlag) && ((*mutableFlags & f.setFlag) != 0u); + // If any of the flags to be set is immutable, return tecNO_PERMISSION. + if (std::ranges::any_of(flagMapping, [&](auto const& f) { + return isImmutable(f.immutableFlag) && ctx.tx.isFlag(f.setFlag); })) return tecNO_PERMISSION; - - if (enablesConfidentialAmount && - isMutableFlag(lsmfMPTCannotEnableCanHoldConfidentialBalance)) - return tecNO_PERMISSION; } - if (!isMutableFlag(lsmfMPTCanMutateMetadata) && ctx.tx.isFieldPresent(sfMPTokenMetadata)) + if (isImmutable(lsifMPTMetadata) && ctx.tx.isFieldPresent(sfMPTokenMetadata)) return tecNO_PERMISSION; if (auto const fee = ctx.tx[~sfTransferFee]) { // A non-zero TransferFee is only valid if the lsfMPTCanTransfer flag - // was previously enabled (at issuance or via a prior mutation). Setting - // it by tmfMPTSetCanTransfer in the current transaction does not meet - // this requirement. - if (fee > 0u && !sleMptIssuance->isFlag(lsfMPTCanTransfer)) + // is already set on the ledger object, or is being enabled by this + // same transaction. The Immutability of lsfMPTCanTransfer is checked above. + if (fee > 0u && !sleMptIssuance->isFlag(lsfMPTCanTransfer) && + (enableFlags & tfMPTSetCanTransfer) == 0u) return tecNO_PERMISSION; // Cannot set a non-zero TransferFee on an issuance that has confidential @@ -248,7 +214,8 @@ MPTokenIssuanceSet::preclaim(PreclaimContext const& ctx) if (fee > 0u && sleMptIssuance->isFlag(lsfMPTCanHoldConfidentialBalance)) return tecNO_PERMISSION; - if (!isMutableFlag(lsmfMPTCanMutateTransferFee)) + // Cannot set TransferFee if it is immutable + if (isImmutable(lsifMPTTransferFee)) return tecNO_PERMISSION; } @@ -266,27 +233,29 @@ MPTokenIssuanceSet::preclaim(PreclaimContext const& ctx) return tecNO_PERMISSION; // LCOV_EXCL_LINE } - if (enablesConfidentialAmount && sleMptIssuance->isFieldPresent(sfTransferFee) && + auto const enablesConfidentialBalance = + (enableFlags & tfMPTSetCanHoldConfidentialBalance) != 0u; + if (enablesConfidentialBalance && sleMptIssuance->isFieldPresent(sfTransferFee) && (*sleMptIssuance)[sfTransferFee] > 0u) return tecNO_PERMISSION; // Encryption keys can only be set if confidential amounts are already // enabled on the issuance OR if the transaction is enabling it if (ctx.tx.isFieldPresent(sfIssuerEncryptionKey) && - !sleMptIssuance->isFlag(lsfMPTCanHoldConfidentialBalance) && !enablesConfidentialAmount) + !sleMptIssuance->isFlag(lsfMPTCanHoldConfidentialBalance) && !enablesConfidentialBalance) { return tecNO_PERMISSION; } if (ctx.tx.isFieldPresent(sfAuditorEncryptionKey) && - !sleMptIssuance->isFlag(lsfMPTCanHoldConfidentialBalance) && !enablesConfidentialAmount) + !sleMptIssuance->isFlag(lsfMPTCanHoldConfidentialBalance) && !enablesConfidentialBalance) { return tecNO_PERMISSION; } // cannot upload key if there's circulating supply of COA if ((ctx.tx.isFieldPresent(sfIssuerEncryptionKey) || - ctx.tx.isFieldPresent(sfAuditorEncryptionKey) || enablesConfidentialAmount) && + ctx.tx.isFieldPresent(sfAuditorEncryptionKey) || enablesConfidentialBalance) && (*sleMptIssuance)[~sfConfidentialOutstandingAmount].value_or(0) > 0) { return tecNO_PERMISSION; // LCOV_EXCL_LINE @@ -327,23 +296,41 @@ MPTokenIssuanceSet::doApply() flagsOut &= ~lsfMPTLocked; } - if (auto const mutableFlags = ctx_.tx[~sfMutableFlags].value_or(0)) + if (auto const enableFlags = (ctx_.tx.getFlags() & tfMPTokenIssuanceSetEnableFlagMask); + enableFlags != 0u) { - for (auto const& f : kMptMutabilityFlags) + for (auto const& f : flagMapping) { - if ((mutableFlags & f.setFlag) != 0u) + if (ctx_.tx.isFlag(f.setFlag)) { flagsOut |= f.ledgerFlag; } } - - if ((mutableFlags & tmfMPTSetCanHoldConfidentialBalance) != 0u) - flagsOut |= lsfMPTCanHoldConfidentialBalance; } if (flagsIn != flagsOut) sle->setFieldU32(sfFlags, flagsOut); + if (auto const immutableFlags = ctx_.tx[~sfImmutableFlags]) + { + // sle is guaranteed to be an ltMPTOKEN_ISSUANCE rather than an ltMPTOKEN. + // Preflight verification ensures that sfHolder and sfImmutableFlags can + // never both be present in the same transaction. Therefore, if + // sfImmutableFlags is present, sfHolder must be absent. + // + // In doApply, the absence of sfHolder causes the MPTokenIssuance keylet + // to be peeked. The runtime check below is a defensive fallback in case + // this invariant is ever broken by a future change. + XRPL_ASSERT( + sle->getType() == ltMPTOKEN_ISSUANCE, + "MPTokenIssuanceSet::doApply : modifying MPTokenIssuance"); + + if (sle->getType() != ltMPTOKEN_ISSUANCE) + return tecINTERNAL; // LCOV_EXCL_LINE + + (*sle)[sfImmutableFlags] = (*sle)[sfImmutableFlags] | *immutableFlags; + } + if (auto const transferFee = ctx_.tx[~sfTransferFee]) { // TransferFee uses soeDEFAULT style: diff --git a/src/libxrpl/tx/transactors/vault/VaultCreate.cpp b/src/libxrpl/tx/transactors/vault/VaultCreate.cpp index e1f5873a89..d793b3a1e2 100644 --- a/src/libxrpl/tx/transactors/vault/VaultCreate.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultCreate.cpp @@ -209,7 +209,6 @@ VaultCreate::doApply() .transferFee = std::nullopt, .metadata = tx[~sfMPTokenMetadata], .domainId = tx[~sfDomainID], - .mutableFlags = std::nullopt, .referenceHolding = referenceHolding, }); if (!maybeShare) diff --git a/src/test/app/ConfidentialTransfer_test.cpp b/src/test/app/ConfidentialTransfer_test.cpp index d3e0182db5..864de165da 100644 --- a/src/test/app/ConfidentialTransfer_test.cpp +++ b/src/test/app/ConfidentialTransfer_test.cpp @@ -616,7 +616,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase mptAlice.set({ .account = alice, - .mutableFlags = tmfMPTSetCanHoldConfidentialBalance, + .flags = tfMPTSetCanHoldConfidentialBalance, }); } @@ -637,7 +637,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase mptAlice.set({ .account = alice, - .mutableFlags = tmfMPTSetCanHoldConfidentialBalance, + .flags = tfMPTSetCanHoldConfidentialBalance, .issuerPubKey = mptAlice.getPubKey(alice), .auditorPubKey = mptAlice.getPubKey(auditor), }); @@ -880,11 +880,11 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase Account const alice("alice"); MPTTester mptAlice(env, alice, {.holders = {}}); - // Create with tmfMPTCannotEnableCanHoldConfidentialBalance + // Create with tifMPTCanHoldConfidentialBalance mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock, - .mutableFlags = tmfMPTCannotEnableCanHoldConfidentialBalance, + .immutableFlags = tifMPTCanHoldConfidentialBalance, }); mptAlice.generateKeyPair(alice); @@ -893,7 +893,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase // because the issuance cannot mutate canConfidentialAmount mptAlice.set({ .account = alice, - .mutableFlags = tmfMPTSetCanHoldConfidentialBalance, + .flags = tfMPTSetCanHoldConfidentialBalance, .issuerPubKey = mptAlice.getPubKey(alice), .err = tecNO_PERMISSION, }); @@ -965,15 +965,11 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase Account const alice("alice"); MPTTester mptAlice(env, alice, {.holders = {}}); - mptAlice.create({ - .ownerCount = 1, - .flags = tfMPTCanTransfer | tfMPTCanLock, - .mutableFlags = tmfMPTCanMutateTransferFee, - }); + mptAlice.create({.ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock}); mptAlice.set({ .account = alice, - .mutableFlags = tmfMPTSetCanHoldConfidentialBalance, + .flags = tfMPTSetCanHoldConfidentialBalance, .transferFee = 100, .err = temBAD_TRANSFER_FEE, }); @@ -986,16 +982,12 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase Account const alice("alice"); MPTTester mptAlice(env, alice, {.holders = {}}); - mptAlice.create({ - .transferFee = 100, - .ownerCount = 1, - .flags = tfMPTCanTransfer | tfMPTCanLock, - .mutableFlags = tmfMPTCanMutateTransferFee, - }); + mptAlice.create( + {.transferFee = 100, .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanLock}); mptAlice.set({ .account = alice, - .mutableFlags = tmfMPTSetCanHoldConfidentialBalance, + .flags = tfMPTSetCanHoldConfidentialBalance, .err = tecNO_PERMISSION, }); } @@ -1007,11 +999,9 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase Account const alice("alice"); MPTTester mptAlice(env, alice, {.holders = {}}); - mptAlice.create({ - .ownerCount = 1, - .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance, - .mutableFlags = tmfMPTCanMutateTransferFee, - }); + mptAlice.create( + {.ownerCount = 1, + .flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanHoldConfidentialBalance}); mptAlice.set({ .account = alice, @@ -5087,7 +5077,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase testcase("mutate lsfMPTCanHoldConfidentialBalance"); using namespace test::jtx; - // can not create mpt issuance with tmfMPTCannotEnableCanHoldConfidentialBalance + // can not create mpt issuance with tifMPTCanHoldConfidentialBalance // when featureDynamicMPT is disabled { Env env{*this, features - featureDynamicMPT}; @@ -5097,12 +5087,12 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase mptAlice.create({ .ownerCount = 0, - .mutableFlags = tmfMPTCannotEnableCanHoldConfidentialBalance, + .immutableFlags = tifMPTCanHoldConfidentialBalance, .err = temDISABLED, }); } - // can not create mpt issuance with tmfMPTCannotEnableCanHoldConfidentialBalance when + // can not create mpt issuance with tifMPTCanHoldConfidentialBalance when // featureConfidentialTransfer is disabled { Env env{*this, features - featureConfidentialTransfer}; @@ -5112,12 +5102,12 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase mptAlice.create({ .ownerCount = 0, - .mutableFlags = tmfMPTCannotEnableCanHoldConfidentialBalance, + .immutableFlags = tifMPTCanHoldConfidentialBalance, .err = temDISABLED, }); } - // if lsmfMPTCannotEnableCanHoldConfidentialBalance is set, can not set/clear + // if lsifMPTCanHoldConfidentialBalance is set, can not set/clear // lsfMPTCanHoldConfidentialBalance { Env env{*this, features}; @@ -5128,12 +5118,12 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer, - .mutableFlags = tmfMPTCannotEnableCanHoldConfidentialBalance, + .immutableFlags = tifMPTCanHoldConfidentialBalance, }); mptAlice.set({ .account = alice, - .mutableFlags = tmfMPTSetCanHoldConfidentialBalance, + .flags = tfMPTSetCanHoldConfidentialBalance, .err = tecNO_PERMISSION, }); } @@ -5148,7 +5138,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase mptAlice.create({ .ownerCount = 1, .flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance, - .mutableFlags = tmfMPTCanEnableCanLock, + .immutableFlags = tifMPTCanLock, }); mptAlice.authorize({ @@ -5200,14 +5190,14 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase // lsfMPTCanHoldConfidentialBalance was already set mptAlice.set({ .account = alice, - .mutableFlags = tmfMPTSetCanHoldConfidentialBalance, + .flags = tfMPTSetCanHoldConfidentialBalance, }); verifyToggle(tesSUCCESS, 10); - // set tmfMPTSetCanHoldConfidentialBalance again + // set tfMPTSetCanHoldConfidentialBalance again mptAlice.set({ .account = alice, - .mutableFlags = tmfMPTSetCanHoldConfidentialBalance, + .flags = tfMPTSetCanHoldConfidentialBalance, }); verifyToggle(tesSUCCESS, 30); } @@ -5220,7 +5210,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); - // lsmfMPTCannotEnableCanHoldConfidentialBalance is false by default, + // lsifMPTCanHoldConfidentialBalance is false by default, // so that lsfMPTCanHoldConfidentialBalance can be mutated mptAlice.create({ .ownerCount = 1, @@ -5243,7 +5233,7 @@ class ConfidentialTransfer_test : public ConfidentialTransferTestBase // confidential outstanding balance mptAlice.set({ .account = alice, - .mutableFlags = tmfMPTSetCanHoldConfidentialBalance, + .flags = tfMPTSetCanHoldConfidentialBalance, .err = tecNO_PERMISSION, }); } diff --git a/src/test/app/Delegate_test.cpp b/src/test/app/Delegate_test.cpp index a8ddc27e6f..a3fa2ac0b5 100644 --- a/src/test/app/Delegate_test.cpp +++ b/src/test/app/Delegate_test.cpp @@ -2167,11 +2167,12 @@ class Delegate_test : public beast::unit_test::Suite env(delegate::set(alice, bob, {"MPTokenIssuanceLock"})); env.close(); - // Field is not permitted, permitted fields for delegation is defined in - // permissions.macro. + // tfMPTSetCanLock is a valid MPTokenIssuanceSet flag but is not + // covered by the MPTokenIssuanceLock granular permission, so a + // delegate holding only that permission cannot set it. mpt.set( {.account = alice, - .mutableFlags = 2, + .flags = tfMPTSetCanLock, .delegate = bob, .err = terNO_DELEGATE_PERMISSION}); diff --git a/src/test/app/Loan_test.cpp b/src/test/app/Loan_test.cpp index 371fcae54f..7035d1dca3 100644 --- a/src/test/app/Loan_test.cpp +++ b/src/test/app/Loan_test.cpp @@ -5448,8 +5448,7 @@ protected: {.env = env, .issuer = issuer, .holders = {lender, borrower}, - .flags = tfMPTCanTransfer | tfMPTCanLock, - .mutableFlags = tmfMPTCanEnableCanTrade}); + .flags = tfMPTCanTransfer | tfMPTCanLock}); PrettyAsset const asset = mpt.issuanceID(); env(pay(issuer, lender, asset(10'000'000))); env(pay(issuer, borrower, asset(100'000))); @@ -5484,7 +5483,7 @@ protected: env.close(); // Enable CanTrade and verify the DEX path is restored. - mpt.set({.mutableFlags = tmfMPTSetCanTrade}); + mpt.set({.flags = tfMPTSetCanTrade}); env.close(); env(offer(lender, XRP(1), asset(10))); diff --git a/src/test/app/MPToken_test.cpp b/src/test/app/MPToken_test.cpp index befc46e2ae..f3f967af81 100644 --- a/src/test/app/MPToken_test.cpp +++ b/src/test/app/MPToken_test.cpp @@ -43,6 +43,7 @@ #include #include #include +#include #include #include #include @@ -59,6 +60,7 @@ #include #include #include +#include #include #include @@ -602,9 +604,9 @@ class MPToken_test : public beast::unit_test::Suite mptAlice.authorize({.account = bob, .holderCount = 1}); - // test invalid flag - only valid flags are tfMPTLock (1) and Unlock - // (2) - mptAlice.set({.account = alice, .flags = 0x00000008, .err = temINVALID_FLAG}); + // test invalid flag - an unrecognized flag bit is always + // rejected, regardless of which amendments are enabled + mptAlice.set({.account = alice, .flags = 0x00001000, .err = temINVALID_FLAG}); if (!features[featureSingleAssetVault] && !features[featureDynamicMPT] && !features[featureConfidentialTransfer]) @@ -3393,26 +3395,26 @@ class MPToken_test : public beast::unit_test::Suite using namespace test::jtx; Account const alice("alice"); - // Can not provide MutableFlags when DynamicMPT amendment is not enabled + // Can not provide ImmutableFlags when DynamicMPT amendment is not enabled { Env env{*this, features - featureDynamicMPT}; MPTTester mptAlice(env, alice); - mptAlice.create({.ownerCount = 0, .mutableFlags = 2, .err = temDISABLED}); - mptAlice.create({.ownerCount = 0, .mutableFlags = 0, .err = temDISABLED}); + mptAlice.create({.ownerCount = 0, .immutableFlags = 2, .err = temDISABLED}); + mptAlice.create({.ownerCount = 0, .immutableFlags = 0, .err = temDISABLED}); } - // MutableFlags contains invalid values + // ImmutableFlags contains invalid values { Env env{*this, features}; MPTTester mptAlice(env, alice); // Value 1 is reserved for MPT lock. - mptAlice.create({.ownerCount = 0, .mutableFlags = 1, .err = temINVALID_FLAG}); - mptAlice.create({.ownerCount = 0, .mutableFlags = 17, .err = temINVALID_FLAG}); - mptAlice.create({.ownerCount = 0, .mutableFlags = 65535, .err = temINVALID_FLAG}); + mptAlice.create({.ownerCount = 0, .immutableFlags = 1, .err = temINVALID_FLAG}); + mptAlice.create({.ownerCount = 0, .immutableFlags = 17, .err = temINVALID_FLAG}); + mptAlice.create({.ownerCount = 0, .immutableFlags = 65535, .err = temINVALID_FLAG}); - // MutableFlags can not be 0 - mptAlice.create({.ownerCount = 0, .mutableFlags = 0, .err = temINVALID_FLAG}); + // ImmutableFlags can not be 0 + mptAlice.create({.ownerCount = 0, .immutableFlags = 0, .err = temINVALID_FLAG}); } } @@ -3425,16 +3427,16 @@ class MPToken_test : public beast::unit_test::Suite Account const alice("alice"); Account const bob("bob"); - // Can not provide MutableFlags, MPTokenMetadata or TransferFee when + // Can not provide mutate related flags, MPTokenMetadata or TransferFee when // DynamicMPT amendment is not enabled { Env env{*this, features - featureDynamicMPT}; MPTTester mptAlice(env, alice, {.holders = {bob}}); auto const mptID = makeMptID(env.seq(alice), alice); - // MutableFlags is not allowed when DynamicMPT is not enabled - mptAlice.set({.account = alice, .id = mptID, .mutableFlags = 2, .err = temDISABLED}); - mptAlice.set({.account = alice, .id = mptID, .mutableFlags = 0, .err = temDISABLED}); + // Mutate related flags is not allowed when DynamicMPT is not enabled + mptAlice.set( + {.account = alice, .id = mptID, .flags = tfMPTSetCanLock, .err = temDISABLED}); // MPTokenMetadata is not allowed when DynamicMPT is not enabled mptAlice.set({.account = alice, .id = mptID, .metadata = "test", .err = temDISABLED}); @@ -3445,19 +3447,19 @@ class MPToken_test : public beast::unit_test::Suite mptAlice.set({.account = alice, .id = mptID, .transferFee = 0, .err = temDISABLED}); } - // Can not provide holder when MutableFlags, MPTokenMetadata or + // Can not provide holder when mutate related flags, MPTokenMetadata or // TransferFee is present { Env env{*this, features}; MPTTester mptAlice(env, alice, {.holders = {bob}}); auto const mptID = makeMptID(env.seq(alice), alice); - // Holder is not allowed when MutableFlags is present + // Holder is not allowed when mutate related flags is present mptAlice.set( {.account = alice, .holder = bob, .id = mptID, - .mutableFlags = 2, + .flags = tfMPTSetCanLock, .err = temMALFORMED}); // Holder is not allowed when MPTokenMetadata is present @@ -3477,27 +3479,24 @@ class MPToken_test : public beast::unit_test::Suite .err = temMALFORMED}); } - // Can not set Flags when MutableFlags, MPTokenMetadata or + // Can not lock when mutate related flags, MPTokenMetadata or // TransferFee is present { Env env{*this, features}; MPTTester mptAlice(env, alice, {.holders = {bob}}); - mptAlice.create( - {.ownerCount = 1, - .mutableFlags = tmfMPTCanMutateMetadata | tmfMPTCanEnableCanLock | - tmfMPTCanMutateTransferFee}); + mptAlice.create({.ownerCount = 1}); - // Setting flags is not allowed when MutableFlags is present + // Lock is not allowed when mutate related flags is present mptAlice.set( - {.account = alice, .flags = tfMPTCanLock, .mutableFlags = 2, .err = temMALFORMED}); + {.account = alice, .flags = tfMPTLock | tfMPTSetCanLock, .err = temMALFORMED}); - // Setting flags is not allowed when MPTokenMetadata is present + // Lock is not allowed when MPTokenMetadata is present mptAlice.set( - {.account = alice, .flags = tfMPTCanLock, .metadata = "test", .err = temMALFORMED}); + {.account = alice, .flags = tfMPTLock, .metadata = "test", .err = temMALFORMED}); - // setting flags is not allowed when TransferFee is present + // Lock is not allowed when TransferFee is present mptAlice.set( - {.account = alice, .flags = tfMPTCanLock, .transferFee = 100, .err = temMALFORMED}); + {.account = alice, .flags = tfMPTLock, .transferFee = 100, .err = temMALFORMED}); } // Flags being 0 or tfFullyCanonicalSig is fine @@ -3509,48 +3508,39 @@ class MPToken_test : public beast::unit_test::Suite {.transferFee = 10, .ownerCount = 1, .flags = tfMPTCanTransfer, - .mutableFlags = tmfMPTCanMutateTransferFee | tmfMPTCanMutateMetadata}); + .immutableFlags = tifMPTTransferFee}); - mptAlice.set({.account = alice, .flags = 0, .transferFee = 100, .metadata = "test"}); - mptAlice.set( - {.account = alice, - .flags = tfFullyCanonicalSig, - .transferFee = 200, - .metadata = "test2"}); + mptAlice.set({.account = alice, .flags = 0, .metadata = "test"}); + mptAlice.set({.account = alice, .flags = tfFullyCanonicalSig, .metadata = "test2"}); } - // Invalid MutableFlags + // Invalid flags { Env env{*this, features}; MPTTester mptAlice(env, alice, {.holders = {bob}}); auto const mptID = makeMptID(env.seq(alice), alice); - for (auto const flags : {10000, 0, 5000}) + for (auto const flags : {0x0200u, 0x0800u, 0x2000u, 0x0201u}) { mptAlice.set( - {.account = alice, .id = mptID, .mutableFlags = flags, .err = temINVALID_FLAG}); + {.account = alice, .id = mptID, .flags = flags, .err = temINVALID_FLAG}); } } - // Can not mutate flag which is not mutable + // Can not set flag which is immutable { Env env{*this, features}; MPTTester mptAlice(env, alice, {.holders = {bob}}); - mptAlice.create({.ownerCount = 1}); + mptAlice.create( + {.ownerCount = 1, + .immutableFlags = tifMPTCanLock | tifMPTCanTrade | tifMPTCanTransfer | + tifMPTCanClawback | tifMPTCanEscrow | tifMPTRequireAuth | + tifMPTCanHoldConfidentialBalance}); - auto const mutableFlags = { - tmfMPTSetCanLock, - tmfMPTSetRequireAuth, - tmfMPTSetCanEscrow, - tmfMPTSetCanTrade, - tmfMPTSetCanTransfer, - tmfMPTSetCanClawback}; - - for (auto const& mutableFlag : mutableFlags) + for (auto const& f : MPTokenIssuanceSet::flagMapping) { - mptAlice.set( - {.account = alice, .mutableFlags = mutableFlag, .err = tecNO_PERMISSION}); + mptAlice.set({.account = alice, .flags = f.setFlag, .err = tecNO_PERMISSION}); } } @@ -3559,18 +3549,18 @@ class MPToken_test : public beast::unit_test::Suite Env env{*this, features}; MPTTester mptAlice(env, alice, {.holders = {bob}}); - mptAlice.create({.ownerCount = 1, .mutableFlags = tmfMPTCanMutateMetadata}); + mptAlice.create({.ownerCount = 1}); std::string const metadata(kMaxMpTokenMetadataLength + 1, 'a'); mptAlice.set({.account = alice, .metadata = metadata, .err = temMALFORMED}); } - // Can not mutate metadata when it is not mutable + // Can not set metadata when it is immutable { Env env{*this, features}; MPTTester mptAlice(env, alice, {.holders = {bob}}); - mptAlice.create({.ownerCount = 1}); + mptAlice.create({.ownerCount = 1, .immutableFlags = tifMPTMetadata}); mptAlice.set({.account = alice, .metadata = "test", .err = tecNO_PERMISSION}); } @@ -3580,7 +3570,7 @@ class MPToken_test : public beast::unit_test::Suite MPTTester mptAlice(env, alice, {.holders = {bob}}); auto const mptID = makeMptID(env.seq(alice), alice); - mptAlice.create({.ownerCount = 1, .mutableFlags = tmfMPTCanMutateTransferFee}); + mptAlice.create({.ownerCount = 1}); mptAlice.set( {.account = alice, @@ -3594,83 +3584,70 @@ class MPToken_test : public beast::unit_test::Suite Env env{*this, features}; MPTTester mptAlice(env, alice, {.holders = {bob}}); - mptAlice.create( - {.ownerCount = 1, - .mutableFlags = tmfMPTCanMutateTransferFee | tmfMPTCanEnableCanTransfer}); + mptAlice.create({.ownerCount = 1}); + // MPTCanTransfer is not set, return tecNO_PERMISSION mptAlice.set({.account = alice, .transferFee = 100, .err = tecNO_PERMISSION}); - // Can not set transfer fee even when trying to set MPTCanTransfer - // at the same time. MPTCanTransfer must be set first, then transfer - // fee can be set in a separate transaction. - mptAlice.set( - {.account = alice, - .mutableFlags = tmfMPTSetCanTransfer, - .transferFee = 100, - .err = tecNO_PERMISSION}); + // Setting a non-zero transfer fee is fine if MPTCanTransfer is + // being enabled in the same transaction + mptAlice.set({.account = alice, .flags = tfMPTSetCanTransfer, .transferFee = 100}); + BEAST_EXPECT(mptAlice.checkFlags(lsfMPTCanTransfer)); + BEAST_EXPECT(mptAlice.checkTransferFee(100)); } - // Can not mutate transfer fee when it is not mutable + // Can not set transfer fee when it is immutable { Env env{*this, features}; MPTTester mptAlice(env, alice, {.holders = {bob}}); - mptAlice.create({.transferFee = 10, .ownerCount = 1, .flags = tfMPTCanTransfer}); + mptAlice.create( + {.transferFee = 10, + .ownerCount = 1, + .flags = tfMPTCanTransfer, + .immutableFlags = tifMPTTransferFee}); mptAlice.set({.account = alice, .transferFee = 100, .err = tecNO_PERMISSION}); - mptAlice.set({.account = alice, .transferFee = 0, .err = tecNO_PERMISSION}); } - // Set some flags mutable. Can not mutate the others + // Set some flags immutable. Others can still be set. { Env env{*this, features}; MPTTester mptAlice(env, alice, {.holders = {bob}}); mptAlice.create( {.ownerCount = 1, - .mutableFlags = tmfMPTCanEnableCanTrade | tmfMPTCanEnableCanTransfer | - tmfMPTCanMutateMetadata}); + .immutableFlags = tifMPTCanTrade | tifMPTCanTransfer | tifMPTMetadata}); - // Can not mutate transfer fee - mptAlice.set({.account = alice, .transferFee = 100, .err = tecNO_PERMISSION}); + auto const canEnableFlags = { + tfMPTSetCanLock, tfMPTSetRequireAuth, tfMPTSetCanEscrow, tfMPTSetCanClawback}; - auto const invalidFlags = { - tmfMPTSetCanLock, tmfMPTSetRequireAuth, tmfMPTSetCanEscrow, tmfMPTSetCanClawback}; + // Can not enable immutable flags + mptAlice.set({.account = alice, .flags = tfMPTSetCanTrade, .err = tecNO_PERMISSION}); + mptAlice.set({.account = alice, .flags = tfMPTSetCanTransfer, .err = tecNO_PERMISSION}); - // Can not mutate flags which are not mutable - for (auto const& mutableFlag : invalidFlags) + // Can enable flags which are not immutable + for (auto const& mutableFlag : canEnableFlags) { - mptAlice.set( - {.account = alice, .mutableFlags = mutableFlag, .err = tecNO_PERMISSION}); + mptAlice.set({.account = alice, .flags = mutableFlag}); } - - // Can mutate MPTCanTrade - mptAlice.set({.account = alice, .mutableFlags = tmfMPTSetCanTrade}); - - // Can mutate MPTCanTransfer - mptAlice.set({.account = alice, .mutableFlags = tmfMPTSetCanTransfer}); - - // Can mutate metadata - mptAlice.set({.account = alice, .metadata = "test"}); - mptAlice.set({.account = alice, .metadata = ""}); } } void - testMutateMPT(FeatureBitset features) + testSetMPT(FeatureBitset features) { - testcase("Mutate MPT"); + testcase("Set MPT"); using namespace test::jtx; Account const alice("alice"); - // Mutate metadata + // Set metadata { Env env{*this, features}; MPTTester mptAlice(env, alice); - mptAlice.create( - {.metadata = "test", .ownerCount = 1, .mutableFlags = tmfMPTCanMutateMetadata}); + mptAlice.create({.metadata = "test", .ownerCount = 1}); std::vector const metadatas = { "mutate metadata", @@ -3691,7 +3668,7 @@ class MPToken_test : public beast::unit_test::Suite BEAST_EXPECT(!mptAlice.isMetadataPresent()); } - // Mutate transfer fee + // Set transfer fee { Env env{*this, features}; MPTTester mptAlice(env, alice); @@ -3699,8 +3676,7 @@ class MPToken_test : public beast::unit_test::Suite {.transferFee = 100, .metadata = "test", .ownerCount = 1, - .flags = tfMPTCanTransfer, - .mutableFlags = tmfMPTCanMutateTransferFee}); + .flags = tfMPTCanTransfer}); for (std::uint16_t const fee : std::initializer_list{1, 10, 100, 200, 500, 1000, kMaxTransferFee}) @@ -3718,33 +3694,29 @@ class MPToken_test : public beast::unit_test::Suite BEAST_EXPECT(mptAlice.checkTransferFee(10)); } - // Test mutable flag enablement + // Test setting flags { - auto testFlagSet = [&](std::uint32_t createFlags, std::uint32_t setFlags) { + auto testFlagSet = [&](std::uint32_t setFlags) { Env env{*this, features}; MPTTester mptAlice(env, alice); - // Create the MPT object with the specified initial flags - mptAlice.create({.metadata = "test", .ownerCount = 1, .mutableFlags = createFlags}); + // Create issuance and the flags can be enabled once by default. + mptAlice.create({.metadata = "test", .ownerCount = 1}); - // Setting the same mutable capability more than once is harmless. - mptAlice.set({.account = alice, .mutableFlags = setFlags}); - mptAlice.set({.account = alice, .mutableFlags = setFlags}); + // Setting the same immutable flag more than once is harmless. + mptAlice.set({.account = alice, .flags = setFlags}); + mptAlice.set({.account = alice, .flags = setFlags}); }; - testFlagSet(tmfMPTCanEnableCanLock, tmfMPTSetCanLock); - testFlagSet(tmfMPTCanEnableRequireAuth, tmfMPTSetRequireAuth); - testFlagSet(tmfMPTCanEnableCanEscrow, tmfMPTSetCanEscrow); - testFlagSet(tmfMPTCanEnableCanTrade, tmfMPTSetCanTrade); - testFlagSet(tmfMPTCanEnableCanTransfer, tmfMPTSetCanTransfer); - testFlagSet(tmfMPTCanEnableCanClawback, tmfMPTSetCanClawback); + for (auto const& f : MPTokenIssuanceSet::flagMapping) + testFlagSet(f.setFlag); } } void - testMutateCanLock(FeatureBitset features) + testSetCanLock(FeatureBitset features) { - testcase("Mutate MPTCanLock"); + testcase("Set MPTCanLock"); using namespace test::jtx; Account const alice("alice"); @@ -3754,78 +3726,41 @@ class MPToken_test : public beast::unit_test::Suite { Env env{*this, features}; MPTTester mptAlice(env, alice, {.holders = {bob}}); - mptAlice.create( - {.ownerCount = 1, - .holderCount = 0, - .flags = tfMPTCanLock | tfMPTCanTransfer, - .mutableFlags = tmfMPTCanEnableCanLock | tmfMPTCanEnableCanTrade | - tmfMPTCanMutateTransferFee}); + mptAlice.create({.ownerCount = 1, .holderCount = 0}); mptAlice.authorize({.account = bob, .holderCount = 1}); - // Lock bob's mptoken - mptAlice.set({.account = alice, .holder = bob, .flags = tfMPTLock}); + // Lock bob's mptoken fails because alice has not enabled MPTCanLock + mptAlice.set( + {.account = alice, .holder = bob, .flags = tfMPTLock, .err = tecNO_PERMISSION}); - // Can mutate the mutable flags and fields - mptAlice.set({.account = alice, .mutableFlags = tmfMPTSetCanLock}); - mptAlice.set({.account = alice, .mutableFlags = tmfMPTSetCanTrade}); - mptAlice.set({.account = alice, .transferFee = 200}); + // set CanLock + mptAlice.set({.account = alice, .flags = tfMPTSetCanLock}); + + // Now can lock + mptAlice.set({.account = alice, .holder = bob, .flags = tfMPTLock}); } // Global lock { Env env{*this, features}; MPTTester mptAlice(env, alice, {.holders = {bob}}); - mptAlice.create( - {.ownerCount = 1, - .holderCount = 0, - .flags = tfMPTCanLock, - .mutableFlags = tmfMPTCanEnableCanLock | tmfMPTCanEnableCanClawback | - tmfMPTCanMutateMetadata}); + mptAlice.create({.ownerCount = 1, .holderCount = 0}); mptAlice.authorize({.account = bob, .holderCount = 1}); - // Lock issuance - mptAlice.set({.account = alice, .flags = tfMPTLock}); - - // Can mutate the mutable flags and fields - mptAlice.set({.account = alice, .mutableFlags = tmfMPTSetCanLock}); - mptAlice.set({.account = alice, .mutableFlags = tmfMPTSetCanClawback}); - mptAlice.set({.account = alice, .metadata = "mutate"}); - } - - // Test lock and unlock after enabling MPTCanLock - { - Env env{*this, features}; - MPTTester mptAlice(env, alice, {.holders = {bob}}); - mptAlice.create( - {.ownerCount = 1, - .holderCount = 0, - .mutableFlags = tmfMPTCanEnableCanLock | tmfMPTCanEnableCanClawback | - tmfMPTCanMutateMetadata}); - mptAlice.authorize({.account = bob, .holderCount = 1}); - - // Can not lock or unlock before MPTCanLock is enabled + // Lock issuance fails because alice has not enabled MPTCanLock mptAlice.set({.account = alice, .flags = tfMPTLock, .err = tecNO_PERMISSION}); - mptAlice.set({.account = alice, .flags = tfMPTUnlock, .err = tecNO_PERMISSION}); - mptAlice.set( - {.account = alice, .holder = bob, .flags = tfMPTLock, .err = tecNO_PERMISSION}); - mptAlice.set( - {.account = alice, .holder = bob, .flags = tfMPTUnlock, .err = tecNO_PERMISSION}); - // Set MPTCanLock - mptAlice.set({.account = alice, .mutableFlags = tmfMPTSetCanLock}); - - // Can lock and unlock + // Set CanLock + mptAlice.set({.account = alice, .flags = tfMPTSetCanLock}); + // Now can lock mptAlice.set({.account = alice, .flags = tfMPTLock}); - mptAlice.set({.account = alice, .holder = bob, .flags = tfMPTLock}); - mptAlice.set({.account = alice, .flags = tfMPTUnlock}); - mptAlice.set({.account = alice, .holder = bob, .flags = tfMPTUnlock}); } } void - testMutateRequireAuth(FeatureBitset features) + testSetRequireAuth(FeatureBitset features) { - testcase("Mutate MPTRequireAuth"); + testcase("Set MPTRequireAuth"); using namespace test::jtx; // test enabling RequireAuth flag on the issuance and its effect on payment @@ -3835,16 +3770,13 @@ class MPToken_test : public beast::unit_test::Suite Account const bob("bob"); MPTTester mptAlice(env, alice, {.holders = {bob}}); - mptAlice.create( - {.ownerCount = 1, - .flags = tfMPTCanTransfer, - .mutableFlags = tmfMPTCanEnableRequireAuth}); + mptAlice.create({.ownerCount = 1, .flags = tfMPTCanTransfer}); mptAlice.authorize({.account = bob}); mptAlice.pay(alice, bob, 1000); - // Set RequireAuth because it is mutable. - mptAlice.set({.account = alice, .mutableFlags = tmfMPTSetRequireAuth}); + // Set RequireAuth + mptAlice.set({.account = alice, .flags = tfMPTSetRequireAuth}); // This should fail because bob is not authorized yet. mptAlice.pay(alice, bob, 1000, tecNO_AUTH); @@ -3855,9 +3787,9 @@ class MPToken_test : public beast::unit_test::Suite } void - testMutateCanEscrow(FeatureBitset features) + testSetCanEscrow(FeatureBitset features) { - testcase("Mutate MPTCanEscrow"); + testcase("Set MPTCanEscrow"); using namespace test::jtx; using namespace std::literals; @@ -3868,11 +3800,7 @@ class MPToken_test : public beast::unit_test::Suite auto const carol = Account("carol"); MPTTester mptAlice(env, alice, {.holders = {carol, bob}}); - mptAlice.create( - {.ownerCount = 1, - .holderCount = 0, - .flags = tfMPTCanTransfer, - .mutableFlags = tmfMPTCanEnableCanEscrow}); + mptAlice.create({.ownerCount = 1, .flags = tfMPTCanTransfer}); mptAlice.authorize({.account = carol}); mptAlice.authorize({.account = bob}); @@ -3888,8 +3816,8 @@ class MPToken_test : public beast::unit_test::Suite Fee(baseFee * 150), Ter(tecNO_PERMISSION)); - // MPTCanEscrow is enabled now - mptAlice.set({.account = alice, .mutableFlags = tmfMPTSetCanEscrow}); + // Set MPTCanEscrow + mptAlice.set({.account = alice, .flags = tfMPTSetCanEscrow}); env(escrow::create(carol, bob, mpt(3)), escrow::kCondition(escrow::kCb1), escrow::kFinishTime(env.now() + 1s), @@ -3897,9 +3825,9 @@ class MPToken_test : public beast::unit_test::Suite } void - testMutateCanTransfer(FeatureBitset features) + testSetCanTransfer(FeatureBitset features) { - testcase("Mutate MPTCanTransfer"); + testcase("Set MPTCanTransfer"); using namespace test::jtx; Account const alice("alice"); @@ -3910,9 +3838,7 @@ class MPToken_test : public beast::unit_test::Suite Env env{*this, features}; MPTTester mptAlice(env, alice, {.holders = {bob, carol}}); - mptAlice.create( - {.ownerCount = 1, - .mutableFlags = tmfMPTCanEnableCanTransfer | tmfMPTCanMutateTransferFee}); + mptAlice.create({.ownerCount = 1}); mptAlice.authorize({.account = bob}); mptAlice.authorize({.account = carol}); @@ -3926,20 +3852,10 @@ class MPToken_test : public beast::unit_test::Suite // Can not set non-zero transfer fee when MPTCanTransfer is not set mptAlice.set({.account = alice, .transferFee = 100, .err = tecNO_PERMISSION}); - // Can not set non-zero transfer fee even when trying to set - // MPTCanTransfer at the same time - mptAlice.set( - {.account = alice, - .mutableFlags = tmfMPTSetCanTransfer, - .transferFee = 100, - .err = tecNO_PERMISSION}); - - // Alice sets MPTCanTransfer - mptAlice.set({.account = alice, .mutableFlags = tmfMPTSetCanTransfer}); - - // Can set transfer fee now + // Set MPTCanTransfer BEAST_EXPECT(!mptAlice.isTransferFeePresent()); - mptAlice.set({.account = alice, .transferFee = 100}); + mptAlice.set({.account = alice, .flags = tfMPTSetCanTransfer, .transferFee = 100}); + BEAST_EXPECT(mptAlice.checkFlags(lsfMPTCanTransfer)); BEAST_EXPECT(mptAlice.isTransferFeePresent()); // Bob can pay carol @@ -3958,16 +3874,13 @@ class MPToken_test : public beast::unit_test::Suite } } - // Can set transfer fee to zero when tmfMPTCanMutateTransferFee is set. + // Can set transfer fee to zero when transfer fee is mutable (i.e. + // tifMPTTransferFee is not set). { Env env{*this, features}; MPTTester mptAlice(env, alice, {.holders = {bob, carol}}); - mptAlice.create( - {.transferFee = 100, - .ownerCount = 1, - .flags = tfMPTCanTransfer, - .mutableFlags = tmfMPTCanMutateTransferFee}); + mptAlice.create({.transferFee = 100, .ownerCount = 1, .flags = tfMPTCanTransfer}); BEAST_EXPECT(mptAlice.checkTransferFee(100)); @@ -3978,9 +3891,9 @@ class MPToken_test : public beast::unit_test::Suite } void - testMutateCanClawback(FeatureBitset features) + testSetCanClawback(FeatureBitset features) { - testcase("Mutate MPTCanClawback"); + testcase("Set MPTCanClawback"); using namespace test::jtx; Env env(*this, features); @@ -3989,8 +3902,7 @@ class MPToken_test : public beast::unit_test::Suite MPTTester mptAlice(env, alice, {.holders = {bob}}); - mptAlice.create( - {.ownerCount = 1, .holderCount = 0, .mutableFlags = tmfMPTCanEnableCanClawback}); + mptAlice.create({.ownerCount = 1, .holderCount = 0}); // Bob creates an MPToken mptAlice.authorize({.account = bob}); @@ -4001,13 +3913,117 @@ class MPToken_test : public beast::unit_test::Suite // MPTCanClawback is not enabled mptAlice.claw(alice, bob, 1, tecNO_PERMISSION); - // Enable MPTCanClawback - mptAlice.set({.account = alice, .mutableFlags = tmfMPTSetCanClawback}); + // Set MPTCanClawback + mptAlice.set({.account = alice, .flags = tfMPTSetCanClawback}); // Can clawback now mptAlice.claw(alice, bob, 1); } + void + testSetImmutableFlags(FeatureBitset features) + { + testcase("Set MPT ImmutableFlags via MPTokenIssuanceSet"); + + using namespace test::jtx; + Account const alice{"alice"}; + Account const bob{"bob"}; + + // ImmutableFlags requires featureDynamicMPT. + { + Env env(*this, features - featureDynamicMPT); + MPTTester mptAlice(env, alice); + mptAlice.create({.ownerCount = 1}); + + mptAlice.set( + {.account = alice, .immutableFlags = tifMPTCanClawback, .err = temDISABLED}); + } + + // ImmutableFlags containing tifMPTCanHoldConfidentialBalance requires + // featureConfidentialTransfer. + { + Env env(*this, features - featureConfidentialTransfer); + MPTTester mptAlice(env, alice); + mptAlice.create({.ownerCount = 1}); + + mptAlice.set( + {.account = alice, + .immutableFlags = tifMPTCanHoldConfidentialBalance, + .err = temDISABLED}); + } + + // ImmutableFlags of 0, or containing unknown bits, is rejected. + { + Env env(*this, features); + MPTTester mptAlice(env, alice); + mptAlice.create({.ownerCount = 1}); + + mptAlice.set({.account = alice, .immutableFlags = 0, .err = temINVALID_FLAG}); + mptAlice.set({.account = alice, .immutableFlags = 1, .err = temINVALID_FLAG}); + } + + // Holder is not allowed alongside ImmutableFlags, and ImmutableFlags + // can not be combined with Lock/Unlock in the same transaction. + { + Env env(*this, features); + MPTTester mptAlice(env, alice, {.holders = {bob}}); + mptAlice.create({.ownerCount = 1}); + + mptAlice.set( + {.account = alice, + .holder = bob, + .immutableFlags = tifMPTCanClawback, + .err = temMALFORMED}); + + mptAlice.set( + {.account = alice, + .flags = tfMPTLock, + .immutableFlags = tifMPTCanClawback, + .err = temMALFORMED}); + } + + // Can sets ImmutableFlags and the capability flags in the same transaction + { + Env env(*this, features); + MPTTester mptAlice(env, alice); + mptAlice.create({.ownerCount = 1}); + + mptAlice.set( + {.account = alice, + .flags = tfMPTSetCanClawback, + .immutableFlags = tifMPTCanClawback}); + + mptAlice.set( + {.account = alice, + .flags = tfMPTSetCanTransfer | tfMPTSetRequireAuth, + .immutableFlags = tifMPTCanTrade}); + } + + // Setting ImmutableFlags persists to the ledger, permanently blocks + // enabling the corresponding capability, and merges (rather than + // overwrites) across multiple transactions. + { + Env env(*this, features); + MPTTester mptAlice(env, alice); + mptAlice.create({.ownerCount = 1}); + + mptAlice.set({.account = alice, .immutableFlags = tifMPTCanClawback}); + BEAST_EXPECT(mptAlice.checkImmutableFlags(tifMPTCanClawback)); + + // The CanClawback can no longer be enabled. + mptAlice.set({.account = alice, .flags = tfMPTSetCanClawback, .err = tecNO_PERMISSION}); + + // A distinct bit merges with the first rather than overwriting it. + // Both CanClawback and CanTrade are now immutable. + mptAlice.set({.account = alice, .immutableFlags = tifMPTCanTrade}); + BEAST_EXPECT(mptAlice.checkImmutableFlags(tifMPTCanClawback | tifMPTCanTrade)); + + // Setting the same bit again is a harmless no-op. + mptAlice.set({.account = alice, .immutableFlags = tifMPTCanClawback}); + BEAST_EXPECT(mptAlice.checkImmutableFlags(tifMPTCanClawback | tifMPTCanTrade)); + } + } + void testMultiSendMaximumAmount(FeatureBitset features) { @@ -4398,14 +4414,14 @@ class MPToken_test : public beast::unit_test::Suite .holders = {alice, carol}, .pay = 100, .flags = tfMPTCanTransfer, - .mutableFlags = tmfMPTCanEnableCanTrade}); + .immutableFlags = tifMPTCanTrade}); MPTTester const eth( {.env = env, .issuer = gw, .holders = {alice, carol}, .pay = 100, .flags = tfMPTCanTrade, - .mutableFlags = tmfMPTCanEnableCanTrade}); + .immutableFlags = tifMPTCanTrade}); // Can't create env(offer(gw, eth(10), btc(10)), Ter(tecNO_PERMISSION)); @@ -4641,30 +4657,25 @@ class MPToken_test : public beast::unit_test::Suite .issuer = gw, .holders = {alice, carol, bob}, .pay = 1'000, - .flags = tfMPTCanLock | kMptDexFlags, - .mutableFlags = tmfMPTCanEnableRequireAuth | tmfMPTCanEnableCanTrade | - tmfMPTCanEnableCanTransfer}); + .flags = tfMPTCanLock | kMptDexFlags}); MPTTester eth( {.env = env, .issuer = gw, .holders = {alice, carol, bob}, .pay = 1'000, - .flags = tfMPTCanLock | kMptDexFlags, - .mutableFlags = tmfMPTCanEnableCanTransfer}); + .flags = tfMPTCanLock | kMptDexFlags}); MPTTester const usd( {.env = env, .issuer = gw, .holders = {alice, carol, bob}, .pay = 1'000, - .flags = kMptDexFlags | tfMPTCanLock, - .mutableFlags = tmfMPTCanEnableCanTransfer}); + .flags = kMptDexFlags | tfMPTCanLock}); MPTTester const cad( {.env = env, .issuer = gw, .holders = {alice, carol, bob}, .pay = 1'000, - .flags = kMptDexFlags | tfMPTCanLock, - .mutableFlags = tmfMPTCanEnableCanTransfer}); + .flags = kMptDexFlags | tfMPTCanLock}); env(offer(bob, eth(1'000), btc(1'000)), Txflags(tfPassive)); env.close(); @@ -4694,7 +4705,7 @@ class MPToken_test : public beast::unit_test::Suite env(pay(gw, ed, eth(100))); env(pay(gw, ed, btc(100))); env.close(); - btc.set({.mutableFlags = tmfMPTSetRequireAuth}); + btc.set({.flags = tfMPTSetRequireAuth}); // authorize bob to enable the offers trading btc.authorize({.account = gw, .holder = bob}); env.close(); @@ -4932,8 +4943,7 @@ class MPToken_test : public beast::unit_test::Suite .issuer = gw, .holders = {alice, carol, bob}, .pay = 1'000, - .flags = tfMPTCanTransfer, - .mutableFlags = tmfMPTCanEnableCanTrade}); + .flags = tfMPTCanTransfer}); MPTTester const eth( {.env = env, .issuer = gw, @@ -4952,7 +4962,7 @@ class MPToken_test : public beast::unit_test::Suite env.close(); // Enable MPTCanTrade so BTC can be crossed through offers. - btc.set({.mutableFlags = tmfMPTSetCanTrade}); + btc.set({.flags = tfMPTSetCanTrade}); env(offer(bob, XRP(1), btc(1))); env(offer(bob, btc(1), eth(1))); env(offer(bob, eth(1), usd(1))); @@ -6753,11 +6763,7 @@ class MPToken_test : public beast::unit_test::Suite env.close(); MPTTester mpt( - {.env = env, - .issuer = gw, - .holders = {alice, carol}, - .flags = tfMPTCanTrade, - .mutableFlags = tmfMPTCanEnableCanTransfer}); + {.env = env, .issuer = gw, .holders = {alice, carol}, .flags = tfMPTCanTrade}); // src is issuer uint256 checkId{keylet::check(gw, env.seq(gw)).key}; @@ -6793,7 +6799,7 @@ class MPToken_test : public beast::unit_test::Suite env.close(); // can create now - mpt.set({.account = gw, .mutableFlags = tmfMPTSetCanTransfer}); + mpt.set({.account = gw, .flags = tfMPTSetCanTransfer}); checkId = keylet::check(alice, env.seq(alice)).key; env(check::create(alice, carol, mpt(100))); env.close(); @@ -7223,37 +7229,26 @@ class MPToken_test : public beast::unit_test::Suite auto const txfee = Fee(drops(increment)); auto const badMPT = MPT(gw, 1'000); - auto const makeMPT = [&](std::uint32_t const flags, - Holders holders = {}, - std::uint64_t const pay = 0, - std::optional const mutableFlags = - std::nullopt) { - return MPTTester( - {.env = env, - .issuer = gw, - .holders = holders, - .pay = pay ? std::optional{pay} : std::nullopt, - .flags = flags, - .mutableFlags = mutableFlags}); - }; + auto const makeMPT = + [&](std::uint32_t const flags, Holders holders = {}, std::uint64_t const pay = 0) { + return MPTTester( + {.env = env, + .issuer = gw, + .holders = holders, + .pay = pay ? std::optional{pay} : std::nullopt, + .flags = flags}); + }; auto const makeDexMPT = [&](Holders holders = {}, std::uint64_t const pay = 0) { - return makeMPT( - tfMPTCanLock | kMptDexFlags, - holders, - pay, - tmfMPTCanEnableRequireAuth | tmfMPTCanEnableCanTransfer | - tmfMPTCanEnableCanTrade); + return makeMPT(tfMPTCanLock | kMptDexFlags, holders, pay); }; auto const makeNoTransferMPT = [&](Holders holders = {}, std::uint64_t const pay = 0) { - return makeMPT( - tfMPTCanLock | tfMPTCanTrade, holders, pay, tmfMPTCanEnableCanTransfer); + return makeMPT(tfMPTCanLock | tfMPTCanTrade, holders, pay); }; auto const makeNoTradeMPT = [&](Holders holders = {}, std::uint64_t const pay = 0) { - return makeMPT( - tfMPTCanLock | tfMPTCanTransfer, holders, pay, tmfMPTCanEnableCanTrade); + return makeMPT(tfMPTCanLock | tfMPTCanTransfer, holders, pay); }; // AMMCreate @@ -7299,7 +7294,7 @@ class MPToken_test : public beast::unit_test::Suite // MPTRequireAuth is set // alice is not authorized usd.set({.flags = tfMPTUnlock}); - usd.set({.mutableFlags = tmfMPTSetRequireAuth}); + usd.set({.flags = tfMPTSetRequireAuth}); createFail(usd, alice, tecNO_AUTH); // issuer can create createDeleteAMM(usd, gw); @@ -7316,7 +7311,7 @@ class MPToken_test : public beast::unit_test::Suite createFail(usd2, alice, tecNO_AUTH); // issuer can create createDeleteAMM(usd2, gw); - usd2.set({.mutableFlags = tmfMPTSetCanTransfer}); + usd2.set({.flags = tfMPTSetCanTransfer}); // alice can create createDeleteAMM(usd2, alice); } @@ -7328,7 +7323,7 @@ class MPToken_test : public beast::unit_test::Suite // alice and issuer can't create createFail(usd3, alice, tecNO_PERMISSION); createFail(usd3, gw, tecNO_PERMISSION); - usd3.set({.mutableFlags = tmfMPTSetCanTrade}); + usd3.set({.flags = tfMPTSetCanTrade}); // alice can create createDeleteAMM(usd3, alice); } @@ -7383,7 +7378,7 @@ class MPToken_test : public beast::unit_test::Suite // MPTRequireAuth is set // carol is not authorized by the issuer - usd.set({.mutableFlags = tmfMPTSetRequireAuth}); + usd.set({.flags = tfMPTSetRequireAuth}); env.close(); amm.deposit( {.account = carol, @@ -7429,7 +7424,7 @@ class MPToken_test : public beast::unit_test::Suite .err = Ter(tecNO_AUTH)}); // issuer can deposit amm2.deposit({.account = gw, .tokens = 1'000}); - usd2.set({.mutableFlags = tmfMPTSetCanTransfer}); + usd2.set({.flags = tfMPTSetCanTransfer}); // carol can deposit amm2.deposit({.account = carol, .tokens = 1'000}); } @@ -7499,7 +7494,7 @@ class MPToken_test : public beast::unit_test::Suite usd.set({.flags = tfMPTUnlock}); // MPTRequireAuth is set - usd.set({.mutableFlags = tmfMPTSetRequireAuth}); + usd.set({.flags = tfMPTSetRequireAuth}); usd.authorize({.account = gw, .holder = carol, .flags = tfMPTUnauthorize}); // carol can't withdraw amm.withdraw( @@ -7543,7 +7538,7 @@ class MPToken_test : public beast::unit_test::Suite usd2.authorize({.account = bob, .flags = tfMPTUnauthorize}); // Can redeem env(pay(carol, gw, usd2(1))); - usd2.set({.mutableFlags = tmfMPTSetCanTransfer}); + usd2.set({.flags = tfMPTSetCanTransfer}); // carol can withdraw amm2.withdraw({.account = carol, .asset1Out = usd2(1), .asset2Out = eur(1)}); } @@ -7739,13 +7734,14 @@ public: // Dynamic MPT testInvalidCreateDynamic(all); testInvalidSetDynamic(all); - testMutateMPT(all); - testMutateCanLock(all); - testMutateRequireAuth(all); - testMutateCanEscrow(all); - testMutateCanTransfer(all); - testMutateCanTransfer(all - featureMPTokensV2); - testMutateCanClawback(all); + testSetMPT(all); + testSetCanLock(all); + testSetRequireAuth(all); + testSetCanEscrow(all); + testSetCanTransfer(all); + testSetCanTransfer(all - featureMPTokensV2); + testSetCanClawback(all); + testSetImmutableFlags(all); // Test offer crossing testOfferCrossing(all); diff --git a/src/test/app/Vault_test.cpp b/src/test/app/Vault_test.cpp index 617820c89c..62cf25f495 100644 --- a/src/test/app/Vault_test.cpp +++ b/src/test/app/Vault_test.cpp @@ -1602,8 +1602,7 @@ class Vault_test : public beast::unit_test::Suite mptt.create( {.flags = tfMPTCanTransfer | tfMPTCanLock | (args.enableClawback ? tfMPTCanClawback : kNone) | - (args.requireAuth ? tfMPTRequireAuth : kNone), - .mutableFlags = tmfMPTCanEnableCanTransfer}); + (args.requireAuth ? tfMPTRequireAuth : kNone)}); PrettyAsset const asset = mptt.issuanceID(); mptt.authorize({.account = owner}); mptt.authorize({.account = depositor}); @@ -2206,9 +2205,7 @@ class Vault_test : public beast::unit_test::Suite Vault const vault{env}; MPTTester mptt{env, issuer, kMptInitNoFund}; - mptt.create( - {.flags = tfMPTCanTransfer | tfMPTCanLock, - .mutableFlags = tmfMPTCanEnableCanTrade}); + mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock}); PrettyAsset const asset = mptt.issuanceID(); mptt.authorize({.account = owner}); mptt.authorize({.account = alice}); @@ -2252,7 +2249,7 @@ class Vault_test : public beast::unit_test::Suite env.close(); // Enable CanTrade on the underlying. - mptt.set({.mutableFlags = tmfMPTSetCanTrade}); + mptt.set({.flags = tfMPTSetCanTrade}); env.close(); env(offer(alice, XRP(1), asset(10))); diff --git a/src/test/jtx/impl/mpt.cpp b/src/test/jtx/impl/mpt.cpp index dddfc88c7f..c6cd49fa26 100644 --- a/src/test/jtx/impl/mpt.cpp +++ b/src/test/jtx/impl/mpt.cpp @@ -30,6 +30,7 @@ #include #include #include +#include #include @@ -38,7 +39,6 @@ #include #include -#include #include #include #include @@ -94,21 +94,6 @@ makePedersenParams(PedersenProofParams const& params) } // namespace -struct MPTSetFlagMapping -{ - std::uint32_t setFlag; - std::uint32_t ledgerFlag; -}; - -static constexpr std::array mptSetFlagMappings = {{ - {.setFlag = tmfMPTSetCanLock, .ledgerFlag = lsfMPTCanLock}, - {.setFlag = tmfMPTSetRequireAuth, .ledgerFlag = lsfMPTRequireAuth}, - {.setFlag = tmfMPTSetCanEscrow, .ledgerFlag = lsfMPTCanEscrow}, - {.setFlag = tmfMPTSetCanClawback, .ledgerFlag = lsfMPTCanClawback}, - {.setFlag = tmfMPTSetCanTrade, .ledgerFlag = lsfMPTCanTrade}, - {.setFlag = tmfMPTSetCanTransfer, .ledgerFlag = lsfMPTCanTransfer}, -}}; - void MptFlags::operator()(Env& env) const { @@ -195,7 +180,7 @@ makeMPTCreate(MPTInitDef const& arg) .transferFee = arg.transferFee, .pay = {{arg.holders, *arg.pay}}, .flags = arg.flags, - .mutableFlags = arg.mutableFlags, + .immutableFlags = arg.immutableFlags, .authHolder = arg.authHolder}; } return { @@ -203,7 +188,7 @@ makeMPTCreate(MPTInitDef const& arg) .transferFee = arg.transferFee, .authorize = arg.holders, .flags = arg.flags, - .mutableFlags = arg.mutableFlags, + .immutableFlags = arg.immutableFlags, .authHolder = arg.authHolder}; } @@ -245,8 +230,8 @@ MPTTester::createJV(MPTCreate const& arg) jv[sfMaximumAmount] = std::to_string(*arg.maxAmt); if (arg.domainID) jv[sfDomainID] = to_string(*arg.domainID); - if (arg.mutableFlags) - jv[sfMutableFlags] = *arg.mutableFlags; + if (arg.immutableFlags) + jv[sfImmutableFlags] = *arg.immutableFlags; jv[sfTransactionType] = jss::MPTokenIssuanceCreate; return jv; @@ -264,7 +249,7 @@ MPTTester::create(MPTCreate const& arg) .assetScale = arg.assetScale, .transferFee = arg.transferFee, .metadata = arg.metadata, - .mutableFlags = arg.mutableFlags, + .immutableFlags = arg.immutableFlags, .domainID = arg.domainID}); if (!isTesSuccess(submit(arg, jv))) { @@ -463,8 +448,8 @@ MPTTester::setJV(MPTSet const& arg) jv[sfDelegate] = arg.delegate->human(); if (arg.domainID) jv[sfDomainID] = to_string(*arg.domainID); - if (arg.mutableFlags) - jv[sfMutableFlags] = *arg.mutableFlags; + if (arg.immutableFlags) + jv[sfImmutableFlags] = *arg.immutableFlags; if (arg.transferFee) jv[sfTransferFee] = *arg.transferFee; if (arg.metadata) @@ -487,95 +472,85 @@ MPTTester::set(MPTSet const& arg) {.account = arg.account ? arg.account : issuer_, .holder = arg.holder, .id = arg.id ? arg.id : id_, - .mutableFlags = arg.mutableFlags, + .immutableFlags = arg.immutableFlags, .transferFee = arg.transferFee, .metadata = arg.metadata, .delegate = arg.delegate, .domainID = arg.domainID, .issuerPubKey = arg.issuerPubKey, .auditorPubKey = arg.auditorPubKey}); - if (submit(arg, jv) == tesSUCCESS && ((arg.flags.value_or(0) != 0u) || arg.mutableFlags)) + if (submit(arg, jv) == tesSUCCESS && arg.flags.value_or(0) != 0u) { - if (((arg.flags.value_or(0) != 0u) || arg.mutableFlags)) - { - auto require = [&](std::optional const& holder, bool unchanged) { - auto flags = getFlags(holder); - if (!unchanged) + auto require = [&](std::optional const& holder, bool unchanged) { + auto flags = getFlags(holder); + if (!unchanged) + { + if (arg.flags) { - if (arg.flags) + if (*arg.flags & tfMPTLock) { - if (*arg.flags & tfMPTLock) - { - flags |= lsfMPTLocked; - } - else if (*arg.flags & tfMPTUnlock) - { - flags &= ~lsfMPTLocked; - } + flags |= lsfMPTLocked; + } + else if (*arg.flags & tfMPTUnlock) + { + flags &= ~lsfMPTLocked; } - if (arg.mutableFlags) + for (auto const& f : MPTokenIssuanceSet::flagMapping) { - for (auto const& [setFlag, ledgerFlag] : mptSetFlagMappings) + if ((*arg.flags & f.setFlag) != 0u) { - if ((*arg.mutableFlags & setFlag) != 0u) - { - flags |= ledgerFlag; - } + flags |= f.ledgerFlag; } - - if (*arg.mutableFlags & tmfMPTSetCanHoldConfidentialBalance) - flags |= tfMPTCanHoldConfidentialBalance; } } - env_.require(MptFlags(*this, flags, holder)); - }; - if (arg.account) - require(std::nullopt, arg.holder.has_value()); - if (auto const account = (arg.holder ? std::get_if(&(*arg.holder)) : nullptr)) - require(*account, false); - - if (arg.issuerPubKey) - { - env_.require(RequireAny([&]() -> bool { - return forObject([&](SLEP const& sle) -> bool { - if (sle) - { - auto const issuerPubKey = getPubKey(issuer_); - if (!issuerPubKey) - { - Throw( - "MPTTester::set: issuer's pubkey is not set"); - } - - return strHex((*sle)[sfIssuerEncryptionKey]) == strHex(*issuerPubKey); - } - return false; - }); - })); } - if (arg.auditorPubKey) - { - env_.require(RequireAny([&]() -> bool { - return forObject([&](SLEP const& sle) -> bool { - if (sle) + env_.require(MptFlags(*this, flags, holder)); + }; + if (arg.account) + require(std::nullopt, arg.holder.has_value()); + if (auto const account = (arg.holder ? std::get_if(&(*arg.holder)) : nullptr)) + require(*account, false); + + if (arg.issuerPubKey) + { + env_.require(RequireAny([&]() -> bool { + return forObject([&](SLEP const& sle) -> bool { + if (sle) + { + auto const issuerPubKey = getPubKey(issuer_); + if (!issuerPubKey) { - if (!auditor_.has_value()) - Throw("MPTTester::set: auditor is not set"); - - auto const auditorPubKey = getPubKey(*auditor_); - if (!auditorPubKey) - { - Throw( - "MPTTester::set: auditor's pubkey is not set"); - } - - return strHex((*sle)[sfAuditorEncryptionKey]) == strHex(*auditorPubKey); + Throw("MPTTester::set: issuer's pubkey is not set"); } - return false; - }); - })); - } + + return strHex((*sle)[sfIssuerEncryptionKey]) == strHex(*issuerPubKey); + } + return false; + }); + })); + } + if (arg.auditorPubKey) + { + env_.require(RequireAny([&]() -> bool { + return forObject([&](SLEP const& sle) -> bool { + if (sle) + { + if (!auditor_.has_value()) + Throw("MPTTester::set: auditor is not set"); + + auto const auditorPubKey = getPubKey(*auditor_); + if (!auditorPubKey) + { + Throw( + "MPTTester::set: auditor's pubkey is not set"); + } + + return strHex((*sle)[sfAuditorEncryptionKey]) == strHex(*auditorPubKey); + } + return false; + }); + })); } } } @@ -664,6 +639,15 @@ MPTTester::isTransferFeePresent() const return forObject([&](SLEP const& sle) -> bool { return sle->isFieldPresent(sfTransferFee); }); } +[[nodiscard]] bool +MPTTester::checkImmutableFlags(std::uint32_t expectedFlags) const +{ + // sfImmutableFlags is soeDEFAULT, defaulting to 0 if not present. + return forObject([&](SLEP const& sle) -> bool { + return sle->getFieldU32(sfImmutableFlags) == expectedFlags; + }); +} + void MPTTester::pay( Account const& src, diff --git a/src/test/jtx/mpt.h b/src/test/jtx/mpt.h index c6532ab14a..35ab7264bd 100644 --- a/src/test/jtx/mpt.h +++ b/src/test/jtx/mpt.h @@ -147,7 +147,7 @@ struct MPTCreate // if empty vector then pay to either authorize or all holders. std::optional, std::uint64_t>> pay = std::nullopt; std::optional flags = {0}; - std::optional mutableFlags = std::nullopt; + std::optional immutableFlags = std::nullopt; bool authHolder = false; std::optional domainID = std::nullopt; std::optional err = std::nullopt; @@ -183,7 +183,7 @@ struct MPTInitDef std::uint16_t transferFee = 0; std::optional pay = std::nullopt; std::uint32_t flags = kMptDexFlags; - std::optional mutableFlags = std::nullopt; + std::optional immutableFlags = std::nullopt; bool authHolder = false; bool fund = false; bool close = true; @@ -229,7 +229,7 @@ struct MPTSet std::optional ownerCount = std::nullopt; std::optional holderCount = std::nullopt; std::optional flags = std::nullopt; - std::optional mutableFlags = std::nullopt; + std::optional immutableFlags = std::nullopt; std::optional transferFee = std::nullopt; std::optional metadata = std::nullopt; std::optional delegate = std::nullopt; @@ -609,6 +609,9 @@ public: [[nodiscard]] bool isTransferFeePresent() const; + [[nodiscard]] bool + checkImmutableFlags(std::uint32_t expectedFlags) const; + [[nodiscard]] Account const& issuer() const { diff --git a/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenIssuanceTests.cpp b/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenIssuanceTests.cpp index 8dc5960ee0..974d0e81d7 100644 --- a/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenIssuanceTests.cpp +++ b/src/tests/libxrpl/protocol_autogen/ledger_entries/MPTokenIssuanceTests.cpp @@ -32,7 +32,7 @@ TEST(MPTokenIssuanceTests, BuilderSettersRoundTrip) auto const previousTxnIDValue = canonical_UINT256(); auto const previousTxnLgrSeqValue = canonical_UINT32(); auto const domainIDValue = canonical_UINT256(); - auto const mutableFlagsValue = canonical_UINT32(); + auto const immutableFlagsValue = canonical_UINT32(); auto const referenceHoldingValue = canonical_UINT256(); auto const issuerEncryptionKeyValue = canonical_VL(); auto const auditorEncryptionKeyValue = canonical_VL(); @@ -53,7 +53,7 @@ TEST(MPTokenIssuanceTests, BuilderSettersRoundTrip) builder.setLockedAmount(lockedAmountValue); builder.setMPTokenMetadata(mPTokenMetadataValue); builder.setDomainID(domainIDValue); - builder.setMutableFlags(mutableFlagsValue); + builder.setImmutableFlags(immutableFlagsValue); builder.setReferenceHolding(referenceHoldingValue); builder.setIssuerEncryptionKey(issuerEncryptionKeyValue); builder.setAuditorEncryptionKey(auditorEncryptionKeyValue); @@ -153,11 +153,11 @@ TEST(MPTokenIssuanceTests, BuilderSettersRoundTrip) } { - auto const& expected = mutableFlagsValue; - auto const actualOpt = entry.getMutableFlags(); + auto const& expected = immutableFlagsValue; + auto const actualOpt = entry.getImmutableFlags(); ASSERT_TRUE(actualOpt.has_value()); - expectEqualField(expected, *actualOpt, "sfMutableFlags"); - EXPECT_TRUE(entry.hasMutableFlags()); + expectEqualField(expected, *actualOpt, "sfImmutableFlags"); + EXPECT_TRUE(entry.hasImmutableFlags()); } { @@ -217,7 +217,7 @@ TEST(MPTokenIssuanceTests, BuilderFromSleRoundTrip) auto const previousTxnIDValue = canonical_UINT256(); auto const previousTxnLgrSeqValue = canonical_UINT32(); auto const domainIDValue = canonical_UINT256(); - auto const mutableFlagsValue = canonical_UINT32(); + auto const immutableFlagsValue = canonical_UINT32(); auto const referenceHoldingValue = canonical_UINT256(); auto const issuerEncryptionKeyValue = canonical_VL(); auto const auditorEncryptionKeyValue = canonical_VL(); @@ -237,7 +237,7 @@ TEST(MPTokenIssuanceTests, BuilderFromSleRoundTrip) sle->at(sfPreviousTxnID) = previousTxnIDValue; sle->at(sfPreviousTxnLgrSeq) = previousTxnLgrSeqValue; sle->at(sfDomainID) = domainIDValue; - sle->at(sfMutableFlags) = mutableFlagsValue; + sle->at(sfImmutableFlags) = immutableFlagsValue; sle->at(sfReferenceHolding) = referenceHoldingValue; sle->at(sfIssuerEncryptionKey) = issuerEncryptionKeyValue; sle->at(sfAuditorEncryptionKey) = auditorEncryptionKeyValue; @@ -391,16 +391,16 @@ TEST(MPTokenIssuanceTests, BuilderFromSleRoundTrip) } { - auto const& expected = mutableFlagsValue; + auto const& expected = immutableFlagsValue; - auto const fromSleOpt = entryFromSle.getMutableFlags(); - auto const fromBuilderOpt = entryFromBuilder.getMutableFlags(); + auto const fromSleOpt = entryFromSle.getImmutableFlags(); + auto const fromBuilderOpt = entryFromBuilder.getImmutableFlags(); ASSERT_TRUE(fromSleOpt.has_value()); ASSERT_TRUE(fromBuilderOpt.has_value()); - expectEqualField(expected, *fromSleOpt, "sfMutableFlags"); - expectEqualField(expected, *fromBuilderOpt, "sfMutableFlags"); + expectEqualField(expected, *fromSleOpt, "sfImmutableFlags"); + expectEqualField(expected, *fromBuilderOpt, "sfImmutableFlags"); } { @@ -531,8 +531,8 @@ TEST(MPTokenIssuanceTests, OptionalFieldsReturnNullopt) EXPECT_FALSE(entry.getMPTokenMetadata().has_value()); EXPECT_FALSE(entry.hasDomainID()); EXPECT_FALSE(entry.getDomainID().has_value()); - EXPECT_FALSE(entry.hasMutableFlags()); - EXPECT_FALSE(entry.getMutableFlags().has_value()); + EXPECT_FALSE(entry.hasImmutableFlags()); + EXPECT_FALSE(entry.getImmutableFlags().has_value()); EXPECT_FALSE(entry.hasReferenceHolding()); EXPECT_FALSE(entry.getReferenceHolding().has_value()); EXPECT_FALSE(entry.hasIssuerEncryptionKey()); diff --git a/src/tests/libxrpl/protocol_autogen/transactions/MPTokenIssuanceCreateTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/MPTokenIssuanceCreateTests.cpp index f7151fc749..8228188950 100644 --- a/src/tests/libxrpl/protocol_autogen/transactions/MPTokenIssuanceCreateTests.cpp +++ b/src/tests/libxrpl/protocol_autogen/transactions/MPTokenIssuanceCreateTests.cpp @@ -34,7 +34,7 @@ TEST(TransactionsMPTokenIssuanceCreateTests, BuilderSettersRoundTrip) auto const maximumAmountValue = canonical_UINT64(); auto const mPTokenMetadataValue = canonical_VL(); auto const domainIDValue = canonical_UINT256(); - auto const mutableFlagsValue = canonical_UINT32(); + auto const immutableFlagsValue = canonical_UINT32(); MPTokenIssuanceCreateBuilder builder{ accountValue, @@ -48,7 +48,7 @@ TEST(TransactionsMPTokenIssuanceCreateTests, BuilderSettersRoundTrip) builder.setMaximumAmount(maximumAmountValue); builder.setMPTokenMetadata(mPTokenMetadataValue); builder.setDomainID(domainIDValue); - builder.setMutableFlags(mutableFlagsValue); + builder.setImmutableFlags(immutableFlagsValue); auto tx = builder.build(publicKey, secretKey); @@ -107,11 +107,11 @@ TEST(TransactionsMPTokenIssuanceCreateTests, BuilderSettersRoundTrip) } { - auto const& expected = mutableFlagsValue; - auto const actualOpt = tx.getMutableFlags(); - ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfMutableFlags should be present"; - expectEqualField(expected, *actualOpt, "sfMutableFlags"); - EXPECT_TRUE(tx.hasMutableFlags()); + auto const& expected = immutableFlagsValue; + auto const actualOpt = tx.getImmutableFlags(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfImmutableFlags should be present"; + expectEqualField(expected, *actualOpt, "sfImmutableFlags"); + EXPECT_TRUE(tx.hasImmutableFlags()); } } @@ -135,7 +135,7 @@ TEST(TransactionsMPTokenIssuanceCreateTests, BuilderFromStTxRoundTrip) auto const maximumAmountValue = canonical_UINT64(); auto const mPTokenMetadataValue = canonical_VL(); auto const domainIDValue = canonical_UINT256(); - auto const mutableFlagsValue = canonical_UINT32(); + auto const immutableFlagsValue = canonical_UINT32(); // Build an initial transaction MPTokenIssuanceCreateBuilder initialBuilder{ @@ -149,7 +149,7 @@ TEST(TransactionsMPTokenIssuanceCreateTests, BuilderFromStTxRoundTrip) initialBuilder.setMaximumAmount(maximumAmountValue); initialBuilder.setMPTokenMetadata(mPTokenMetadataValue); initialBuilder.setDomainID(domainIDValue); - initialBuilder.setMutableFlags(mutableFlagsValue); + initialBuilder.setImmutableFlags(immutableFlagsValue); auto initialTx = initialBuilder.build(publicKey, secretKey); @@ -204,10 +204,10 @@ TEST(TransactionsMPTokenIssuanceCreateTests, BuilderFromStTxRoundTrip) } { - auto const& expected = mutableFlagsValue; - auto const actualOpt = rebuiltTx.getMutableFlags(); - ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfMutableFlags should be present"; - expectEqualField(expected, *actualOpt, "sfMutableFlags"); + auto const& expected = immutableFlagsValue; + auto const actualOpt = rebuiltTx.getImmutableFlags(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfImmutableFlags should be present"; + expectEqualField(expected, *actualOpt, "sfImmutableFlags"); } } @@ -275,8 +275,8 @@ TEST(TransactionsMPTokenIssuanceCreateTests, OptionalFieldsReturnNullopt) EXPECT_FALSE(tx.getMPTokenMetadata().has_value()); EXPECT_FALSE(tx.hasDomainID()); EXPECT_FALSE(tx.getDomainID().has_value()); - EXPECT_FALSE(tx.hasMutableFlags()); - EXPECT_FALSE(tx.getMutableFlags().has_value()); + EXPECT_FALSE(tx.hasImmutableFlags()); + EXPECT_FALSE(tx.getImmutableFlags().has_value()); } } diff --git a/src/tests/libxrpl/protocol_autogen/transactions/MPTokenIssuanceSetTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/MPTokenIssuanceSetTests.cpp index e7b34590b2..af696ce47b 100644 --- a/src/tests/libxrpl/protocol_autogen/transactions/MPTokenIssuanceSetTests.cpp +++ b/src/tests/libxrpl/protocol_autogen/transactions/MPTokenIssuanceSetTests.cpp @@ -34,7 +34,7 @@ TEST(TransactionsMPTokenIssuanceSetTests, BuilderSettersRoundTrip) auto const domainIDValue = canonical_UINT256(); auto const mPTokenMetadataValue = canonical_VL(); auto const transferFeeValue = canonical_UINT16(); - auto const mutableFlagsValue = canonical_UINT32(); + auto const immutableFlagsValue = canonical_UINT32(); auto const issuerEncryptionKeyValue = canonical_VL(); auto const auditorEncryptionKeyValue = canonical_VL(); @@ -50,7 +50,7 @@ TEST(TransactionsMPTokenIssuanceSetTests, BuilderSettersRoundTrip) builder.setDomainID(domainIDValue); builder.setMPTokenMetadata(mPTokenMetadataValue); builder.setTransferFee(transferFeeValue); - builder.setMutableFlags(mutableFlagsValue); + builder.setImmutableFlags(immutableFlagsValue); builder.setIssuerEncryptionKey(issuerEncryptionKeyValue); builder.setAuditorEncryptionKey(auditorEncryptionKeyValue); @@ -109,11 +109,11 @@ TEST(TransactionsMPTokenIssuanceSetTests, BuilderSettersRoundTrip) } { - auto const& expected = mutableFlagsValue; - auto const actualOpt = tx.getMutableFlags(); - ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfMutableFlags should be present"; - expectEqualField(expected, *actualOpt, "sfMutableFlags"); - EXPECT_TRUE(tx.hasMutableFlags()); + auto const& expected = immutableFlagsValue; + auto const actualOpt = tx.getImmutableFlags(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfImmutableFlags should be present"; + expectEqualField(expected, *actualOpt, "sfImmutableFlags"); + EXPECT_TRUE(tx.hasImmutableFlags()); } { @@ -153,7 +153,7 @@ TEST(TransactionsMPTokenIssuanceSetTests, BuilderFromStTxRoundTrip) auto const domainIDValue = canonical_UINT256(); auto const mPTokenMetadataValue = canonical_VL(); auto const transferFeeValue = canonical_UINT16(); - auto const mutableFlagsValue = canonical_UINT32(); + auto const immutableFlagsValue = canonical_UINT32(); auto const issuerEncryptionKeyValue = canonical_VL(); auto const auditorEncryptionKeyValue = canonical_VL(); @@ -169,7 +169,7 @@ TEST(TransactionsMPTokenIssuanceSetTests, BuilderFromStTxRoundTrip) initialBuilder.setDomainID(domainIDValue); initialBuilder.setMPTokenMetadata(mPTokenMetadataValue); initialBuilder.setTransferFee(transferFeeValue); - initialBuilder.setMutableFlags(mutableFlagsValue); + initialBuilder.setImmutableFlags(immutableFlagsValue); initialBuilder.setIssuerEncryptionKey(issuerEncryptionKeyValue); initialBuilder.setAuditorEncryptionKey(auditorEncryptionKeyValue); @@ -225,10 +225,10 @@ TEST(TransactionsMPTokenIssuanceSetTests, BuilderFromStTxRoundTrip) } { - auto const& expected = mutableFlagsValue; - auto const actualOpt = rebuiltTx.getMutableFlags(); - ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfMutableFlags should be present"; - expectEqualField(expected, *actualOpt, "sfMutableFlags"); + auto const& expected = immutableFlagsValue; + auto const actualOpt = rebuiltTx.getImmutableFlags(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfImmutableFlags should be present"; + expectEqualField(expected, *actualOpt, "sfImmutableFlags"); } { @@ -310,8 +310,8 @@ TEST(TransactionsMPTokenIssuanceSetTests, OptionalFieldsReturnNullopt) EXPECT_FALSE(tx.getMPTokenMetadata().has_value()); EXPECT_FALSE(tx.hasTransferFee()); EXPECT_FALSE(tx.getTransferFee().has_value()); - EXPECT_FALSE(tx.hasMutableFlags()); - EXPECT_FALSE(tx.getMutableFlags().has_value()); + EXPECT_FALSE(tx.hasImmutableFlags()); + EXPECT_FALSE(tx.getImmutableFlags().has_value()); EXPECT_FALSE(tx.hasIssuerEncryptionKey()); EXPECT_FALSE(tx.getIssuerEncryptionKey().has_value()); EXPECT_FALSE(tx.hasAuditorEncryptionKey()); From 68a765d92946dc718d6cd29a3bbc600e2cbb4844 Mon Sep 17 00:00:00 2001 From: Pratik Mankawde <3397372+pratikmankawde@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:07:57 +0100 Subject: [PATCH 18/52] fix: Bound untrusted manifest cache --- include/xrpl/server/Manifest.h | 113 +++++++++++++++----- src/libxrpl/server/Manifest.cpp | 105 ++++++++++++++---- src/libxrpl/server/Wallet.cpp | 21 +++- src/test/app/Manifest_test.cpp | 72 +++++++++---- src/test/app/ValidatorList_test.cpp | 59 ++++++---- src/xrpld/app/misc/detail/ValidatorList.cpp | 10 +- src/xrpld/overlay/detail/OverlayImpl.cpp | 30 ++++-- 7 files changed, 313 insertions(+), 97 deletions(-) diff --git a/include/xrpl/server/Manifest.h b/include/xrpl/server/Manifest.h index 362080ef36..19dfbfd54f 100644 --- a/include/xrpl/server/Manifest.h +++ b/include/xrpl/server/Manifest.h @@ -45,12 +45,15 @@ namespace xrpl { dynamically generates the signatureless form when it needs to verify the signature. - An instance of ManifestCache stores, for each trusted validator, (a) its + An instance of ManifestCache stores, for each known validator, (a) its master public key, and (b) the most senior of all valid manifests it has seen for that validator, if any. On startup, the [validator_token] config entry (which contains the manifest for this validator) is decoded and added to the manifest cache. Other manifests are added as "gossip" - received from xrpld peers. + received from xrpld peers, including ones for validators this node does not + list. Manifests for unlisted validators are capped (kMaxUntrustedCount) + so peer gossip cannot grow the cache without bound; listed validators are + not capped. Entries are never evicted, so a stored revocation is permanent. When an ephemeral key is compromised, a new signing key pair is created, along with a new manifest vouching for it (with a higher sequence number), @@ -258,30 +261,17 @@ loadValidatorToken( beast::Journal journal = beast::Journal(beast::Journal::getNullSink())); enum class ManifestDisposition { - /** - * Manifest is valid - */ - Accepted = 0, + Accepted = 0, ///< Manifest is valid - /** - * Sequence is too old - */ - Stale, + Stale, ///< Sequence is too old - /** - * The master key is not acceptable to us - */ - BadMasterKey, + BadMasterKey, ///< The master key is not acceptable to us - /** - * The ephemeral key is not acceptable to us - */ - BadEphemeralKey, + BadEphemeralKey, ///< The ephemeral key is not acceptable to us - /** - * Timely, but invalid signature - */ - Invalid + Invalid, ///< Timely, but invalid signature + + UntrustedCapacity ///< Unlisted and limit reached }; inline std::string @@ -299,11 +289,25 @@ to_string(ManifestDisposition m) return "badEphemeralKey"; case ManifestDisposition::Invalid: return "invalid"; + case ManifestDisposition::UntrustedCapacity: + return "untrustedCapacity"; default: return "unknown"; } } +/** + * Whether a manifest counts against the 'untrusted' cache cap. + * + * Passed to `ManifestCache::applyManifest` with no default, so every caller + * must choose. `Capped` is the safe, flood-resistant value; only listed or + * configured keys should use `Uncapped`. + */ +enum class ManifestRateLimitCap : std::uint8_t { + Capped, ///< Subject to the untrusted cap (unlisted peer gossip) + Uncapped ///< Bypasses the cap (listed/trusted or config manifests) +}; + class DatabaseCon; /** @@ -327,6 +331,38 @@ private: std::atomic seq_{0}; + /** + * Master keys of cached manifests for validators this node does not list. + * + * One entry per capped key in `map_`; its size enforces the cap below. + * A key is added when first cached under `Capped` and removed when it + * becomes listed (see `promoteToTrusted`) or an `Uncapped` update arrives, + * never re-added on de-listing. Uncapped keys are not tracked here. + */ + hash_set untrustedKeys_; + + /** + * Maximum number of untrusted master keys kept in the cache. + * + * Once reached, a manifest for a brand-new unlisted key is rejected. + */ + static constexpr std::size_t kMaxUntrustedCount = 50000; + + /** + * Running count of manifests rejected because the untrusted cap was full. + * + * Drives throttled logging (see `kUntrustedRejectCount`). Atomic because + * `applyManifest` may run concurrently. + */ + std::atomic untrustedRejectCount_{0}; + + /** + * Number of cap rejections between summary warnings. + * + * @see untrustedRejectCount_ + */ + static constexpr std::uint64_t kUntrustedRejectCount = 10000; + public: explicit ManifestCache(beast::Journal j = beast::Journal(beast::Journal::getNullSink())) : j_(j) { @@ -411,17 +447,44 @@ public: /** * Add manifest to cache. * + * A brand-new unlisted key is rejected once the untrusted cap is full; + * updates to a cached key and `Uncapped` manifests bypass the cap. The + * caller decides `cap` before calling so the cache lock is not held while + * consulting the validator list, which would risk a lock-ordering deadlock. + * * @param m Manifest to add * - * @return `ManifestDisposition::accepted` if successful, or - * `stale` or `invalid` otherwise + * @param cap `Uncapped` skips the untrusted cap; use it for keys that are + * listed, configured, or loaded from the DB. Note `Uncapped` does not + * assert the key is currently trusted (a DB entry may predate a + * de-listing). Callers must state this explicitly so a manifest is + * never left uncapped by omission. + * + * @return `Accepted` if stored, `Stale` if superseded, `Invalid`/ + * `BadEphemeralKey` if malformed, or `UntrustedCapacity` if the + * untrusted cap is full. * * @par Thread Safety * * May be called concurrently */ ManifestDisposition - applyManifest(Manifest m); + applyManifest(Manifest m, ManifestRateLimitCap cap); + + /** + * Stop counting a master key against the untrusted cap. + * + * Called when a cached untrusted key becomes listed, freeing its slot. + * Idempotent and a no-op for keys that were never counted. + * + * @param pk Master public key that is now listed/trusted + * + * @par Thread Safety + * + * May be called concurrently + */ + void + promoteToTrusted(PublicKey const& pk); /** * Populate manifest cache with manifests in database and config. diff --git a/src/libxrpl/server/Manifest.cpp b/src/libxrpl/server/Manifest.cpp index 798d8130b7..b34955dc28 100644 --- a/src/libxrpl/server/Manifest.cpp +++ b/src/libxrpl/server/Manifest.cpp @@ -382,16 +382,20 @@ ManifestCache::revoked(PublicKey const& pk) const } ManifestDisposition -ManifestCache::applyManifest(Manifest m) +ManifestCache::applyManifest(Manifest m, ManifestRateLimitCap const cap) { + bool const uncapped = cap == ManifestRateLimitCap::Uncapped; + + // The signature is checked only on the first `prewriteCheck` run (under the + // read lock). It is expensive, so `checkSignature` is cleared the first + // time it is read; the second run (under the write lock) skips it. + bool checkSignature = true; + // Check the manifest against the conditions that do not require a - // `unique_lock` (write lock) on the `mutex_`. Since the signature can be - // relatively expensive, the `checkSignature` parameter determines if the - // signature should be checked. Since `prewriteCheck` is run twice (see - // comment below), `checkSignature` only needs to be set to true on the - // first run. - auto prewriteCheck = [this, &m](auto const& iter, bool checkSignature, auto const& lock) - -> std::optional { + // `unique_lock` (write lock) on the `mutex_`. + auto prewriteCheck = [this, &m, &checkSignature]( + auto const& iter, + auto const& lock) -> std::optional { XRPL_ASSERT(lock.owns_lock(), "xrpl::ManifestCache::applyManifest::prewriteCheck : locked"); (void)lock; // not used. parameter is present to ensure the mutex is // locked when the lambda is called. @@ -406,11 +410,15 @@ ManifestCache::applyManifest(Manifest m) return ManifestDisposition::Stale; } - if (checkSignature && !m.verify()) + if (checkSignature) { - if (auto stream = j_.warn()) - logMftAct(stream, "Invalid", m.masterKey, m.sequence); - return ManifestDisposition::Invalid; + checkSignature = false; + if (!m.verify()) + { + if (auto stream = j_.warn()) + logMftAct(stream, "Invalid", m.masterKey, m.sequence); + return ManifestDisposition::Invalid; + } } // If the master key associated with a manifest is or might be @@ -470,14 +478,51 @@ ManifestCache::applyManifest(Manifest m) return std::nullopt; }; + // Reject a brand-new manifest for an unlisted key once the untrusted cap + // is full. Updates to a cached key and uncapped manifests always pass. + // Called under both the read and write lock, since the cap can be reached + // between the two. The lock param enforces that. + auto atUntrustedCap = [this, &m, uncapped](auto const& iter, auto const& lock) { + XRPL_ASSERT( + lock.owns_lock(), "xrpl::ManifestCache::applyManifest::atUntrustedCap : locked"); + (void)lock; // not used. parameter is present to ensure the mutex is + // locked when the lambda is called. + if (iter == map_.end() && !uncapped && untrustedKeys_.size() >= kMaxUntrustedCount) + { + // Log each rejection at debug, but warn only once per interval so a + // flood does not fill the log. + if (auto stream = j_.debug()) + logMftAct(stream, "UntrustedCapacity", m.masterKey, m.sequence); + if (auto const n = untrustedRejectCount_.fetch_add(1) + 1; + n % kUntrustedRejectCount == 0) + { + JLOG(j_.warn()) << "Untrusted manifest cap reached; " << n + << " manifests rejected so far"; + } + return true; + } + return false; + }; + { std::shared_lock const sl{mutex_}; - if (auto d = prewriteCheck(map_.find(m.masterKey), /*checkSig*/ true, sl)) + auto const iter = map_.find(m.masterKey); + + if (atUntrustedCap(iter, sl)) + return ManifestDisposition::UntrustedCapacity; + + if (auto d = prewriteCheck(iter, sl); d.has_value()) return *d; } std::unique_lock const sl{mutex_}; auto const iter = map_.find(m.masterKey); + + // Re-check the cap under the write lock: the cache may have grown while the + // read lock above was released. + if (atUntrustedCap(iter, sl)) + return ManifestDisposition::UntrustedCapacity; + // Since we released the previously held read lock, it's possible that the // collections have been written to. This means we need to run // `prewriteCheck` again. This re-does work, but `prewriteCheck` is @@ -487,7 +532,7 @@ ManifestCache::applyManifest(Manifest m) // doesn't need to happen again (signature checks are somewhat expensive). // Note: It's a mistake to use an upgradable lock. This is a recipe for // deadlock. - if (auto d = prewriteCheck(iter, /*checkSig*/ false, sl)) + if (auto d = prewriteCheck(iter, sl); d.has_value()) return *d; bool const revoked = m.revoked(); @@ -506,6 +551,12 @@ ManifestCache::applyManifest(Manifest m) } auto masterKey = m.masterKey; + + // Count this key against the untrusted cap. Uncapped keys (listed, + // configured, or DB-loaded) are not tracked. + if (!uncapped) + untrustedKeys_.insert(masterKey); + map_.emplace(std::move(masterKey), std::move(m)); // Something has changed. Keep track of it. @@ -519,6 +570,11 @@ ManifestCache::applyManifest(Manifest m) if (auto stream = j_.info()) logMftAct(stream, "AcceptedUpdate", m.masterKey, m.sequence, iter->second.sequence); + // If this key was counted against the cap but now arrives uncapped, free + // its slot without waiting for promoteToTrusted. + if (uncapped) + untrustedKeys_.erase(m.masterKey); + signingToMasterKeys_.erase( *iter->second.signingKey); // NOLINT(bugprone-unchecked-optional-access) prewriteCheck // ensures old manifest is not revoked @@ -526,8 +582,8 @@ ManifestCache::applyManifest(Manifest m) if (!revoked) { signingToMasterKeys_.emplace( - *m.signingKey, m.masterKey); // NOLINT(bugprone-unchecked-optional-access) non-revoked - // manifest always has signingKey + *m.signingKey, m.masterKey); // NOLINT(bugprone-unchecked-optional-access) + // non-revoked manifest always has signingKey } iter->second = std::move(m); @@ -538,6 +594,16 @@ ManifestCache::applyManifest(Manifest m) return ManifestDisposition::Accepted; } +void +ManifestCache::promoteToTrusted(PublicKey const& pk) +{ + // Frees the key's untrusted slot; a no-op (and idempotent) if the key was + // never counted. Not re-added on de-listing, so list/de-list cannot grow + // the count. + std::unique_lock const sl{mutex_}; + untrustedKeys_.erase(pk); +} + void ManifestCache::load(DatabaseCon& dbCon, std::string const& dbTable) { @@ -568,7 +634,8 @@ ManifestCache::load( JLOG(j_.warn()) << "Configured manifest revokes public key"; } - if (applyManifest(std::move(*mo)) == ManifestDisposition::Invalid) + if (applyManifest(std::move(*mo), ManifestRateLimitCap::Uncapped) == + ManifestDisposition::Invalid) { JLOG(j_.error()) << "Manifest in config was rejected"; return false; @@ -590,7 +657,9 @@ ManifestCache::load( auto mo = deserializeManifest(base64Decode(revocationStr)); - if (!mo || !mo->revoked() || applyManifest(std::move(*mo)) == ManifestDisposition::Invalid) + if (!mo || !mo->revoked() || + applyManifest(std::move(*mo), ManifestRateLimitCap::Uncapped) == + ManifestDisposition::Invalid) { JLOG(j_.error()) << "Invalid validator key revocation in config"; return false; diff --git a/src/libxrpl/server/Wallet.cpp b/src/libxrpl/server/Wallet.cpp index f3a7ff76ba..e6af5b6411 100644 --- a/src/libxrpl/server/Wallet.cpp +++ b/src/libxrpl/server/Wallet.cpp @@ -29,6 +29,7 @@ #include #include +#include #include #include #include @@ -77,7 +78,9 @@ getManifests( continue; } - cache.applyManifest(std::move(*mo)); + // Only trusted manifests are persisted (see saveManifests), so + // anything loaded from the DB bypasses the untrusted cap. + cache.applyManifest(std::move(*mo), ManifestRateLimitCap::Uncapped); } else { @@ -107,19 +110,27 @@ saveManifests( { soci::transaction tr(session); session << "DELETE FROM " << dbTable; + // Count skipped untrusted manifests and log one summary afterwards, since + // the cache can hold many and per-entry logging would flood at shutdown. + std::size_t skipped = 0; for (auto const& v : map) { - // Save all revocation manifests, - // but only save trusted non-revocation manifests. - if (!v.second.revoked() && !isTrusted(v.second.masterKey)) + // Persist only trusted keys. Untrusted gossip is left out so a flood + // cannot survive a restart on disk. + if (!isTrusted(v.second.masterKey)) { - JLOG(j.info()) << "Untrusted manifest in cache not saved to db"; + ++skipped; continue; } saveManifest(session, dbTable, v.second.serialized); } tr.commit(); + + if (skipped != 0) + { + JLOG(j.info()) << skipped << " untrusted manifest(s) in cache not saved to db"; + } } void diff --git a/src/test/app/Manifest_test.cpp b/src/test/app/Manifest_test.cpp index 50e8ab4a8d..ae30414b92 100644 --- a/src/test/app/Manifest_test.cpp +++ b/src/test/app/Manifest_test.cpp @@ -399,7 +399,8 @@ public: BEAST_EXPECT( ManifestDisposition::Accepted == cache.applyManifest( - makeManifest(sk, KeyType::Ed25519, kp0.second, KeyType::Secp256k1, 0))); + makeManifest(sk, KeyType::Ed25519, kp0.second, KeyType::Secp256k1, 0), + ManifestRateLimitCap::Capped)); BEAST_EXPECT(cache.getSigningKey(pk) == kp0.first); BEAST_EXPECT(cache.getMasterKey(kp0.first) == pk); @@ -411,7 +412,8 @@ public: BEAST_EXPECT( ManifestDisposition::Accepted == cache.applyManifest( - makeManifest(sk, KeyType::Ed25519, kp1.second, KeyType::Secp256k1, 1))); + makeManifest(sk, KeyType::Ed25519, kp1.second, KeyType::Secp256k1, 1), + ManifestRateLimitCap::Capped)); BEAST_EXPECT(cache.getSigningKey(pk) == kp1.first); BEAST_EXPECT(cache.getMasterKey(kp1.first) == pk); BEAST_EXPECT(cache.getMasterKey(kp0.first) == kp0.first); @@ -421,7 +423,8 @@ public: BEAST_EXPECT( ManifestDisposition::BadEphemeralKey == cache.applyManifest( - makeManifest(sk, KeyType::Ed25519, kp1.second, KeyType::Secp256k1, 2))); + makeManifest(sk, KeyType::Ed25519, kp1.second, KeyType::Secp256k1, 2), + ManifestRateLimitCap::Capped)); BEAST_EXPECT(cache.getSigningKey(pk) == kp1.first); BEAST_EXPECT(cache.getMasterKey(kp1.first) == pk); BEAST_EXPECT(cache.getMasterKey(kp0.first) == kp0.first); @@ -431,7 +434,8 @@ public: // key from a revoked master public key BEAST_EXPECT( ManifestDisposition::Accepted == - cache.applyManifest(makeRevocation(sk, KeyType::Ed25519))); + cache.applyManifest( + makeRevocation(sk, KeyType::Ed25519), ManifestRateLimitCap::Capped)); BEAST_EXPECT(cache.revoked(pk)); BEAST_EXPECT(cache.getSigningKey(pk) == pk); BEAST_EXPECT(cache.getMasterKey(kp0.first) == kp0.first); @@ -902,39 +906,69 @@ public: // applyManifest should accept new manifests with // higher sequence numbers auto const seq0 = cache.sequence(); - BEAST_EXPECT(cache.applyManifest(clone(sA0)) == ManifestDisposition::Accepted); + BEAST_EXPECT( + cache.applyManifest(clone(sA0), ManifestRateLimitCap::Capped) == + ManifestDisposition::Accepted); BEAST_EXPECT(cache.sequence() > seq0); auto const seq1 = cache.sequence(); - BEAST_EXPECT(cache.applyManifest(clone(sA0)) == ManifestDisposition::Stale); + BEAST_EXPECT( + cache.applyManifest(clone(sA0), ManifestRateLimitCap::Capped) == + ManifestDisposition::Stale); BEAST_EXPECT(cache.sequence() == seq1); - BEAST_EXPECT(cache.applyManifest(clone(sA1)) == ManifestDisposition::Accepted); - BEAST_EXPECT(cache.applyManifest(clone(sA1)) == ManifestDisposition::Stale); - BEAST_EXPECT(cache.applyManifest(clone(sA0)) == ManifestDisposition::Stale); + BEAST_EXPECT( + cache.applyManifest(clone(sA1), ManifestRateLimitCap::Capped) == + ManifestDisposition::Accepted); + BEAST_EXPECT( + cache.applyManifest(clone(sA1), ManifestRateLimitCap::Capped) == + ManifestDisposition::Stale); + BEAST_EXPECT( + cache.applyManifest(clone(sA0), ManifestRateLimitCap::Capped) == + ManifestDisposition::Stale); - BEAST_EXPECT(cache.applyManifest(clone(sA2)) == ManifestDisposition::BadEphemeralKey); + BEAST_EXPECT( + cache.applyManifest(clone(sA2), ManifestRateLimitCap::Capped) == + ManifestDisposition::BadEphemeralKey); // applyManifest should accept manifests with max sequence numbers // that revoke the master public key BEAST_EXPECT(!cache.revoked(pkA)); BEAST_EXPECT(sAMax.revoked()); - BEAST_EXPECT(cache.applyManifest(clone(sAMax)) == ManifestDisposition::Accepted); - BEAST_EXPECT(cache.applyManifest(clone(sAMax)) == ManifestDisposition::Stale); - BEAST_EXPECT(cache.applyManifest(clone(sA1)) == ManifestDisposition::Stale); - BEAST_EXPECT(cache.applyManifest(clone(sA0)) == ManifestDisposition::Stale); + BEAST_EXPECT( + cache.applyManifest(clone(sAMax), ManifestRateLimitCap::Capped) == + ManifestDisposition::Accepted); + BEAST_EXPECT( + cache.applyManifest(clone(sAMax), ManifestRateLimitCap::Capped) == + ManifestDisposition::Stale); + BEAST_EXPECT( + cache.applyManifest(clone(sA1), ManifestRateLimitCap::Capped) == + ManifestDisposition::Stale); + BEAST_EXPECT( + cache.applyManifest(clone(sA0), ManifestRateLimitCap::Capped) == + ManifestDisposition::Stale); BEAST_EXPECT(cache.revoked(pkA)); // applyManifest should reject manifests with invalid signatures - BEAST_EXPECT(cache.applyManifest(clone(sB0)) == ManifestDisposition::Accepted); - BEAST_EXPECT(cache.applyManifest(clone(sB0)) == ManifestDisposition::Stale); + BEAST_EXPECT( + cache.applyManifest(clone(sB0), ManifestRateLimitCap::Capped) == + ManifestDisposition::Accepted); + BEAST_EXPECT( + cache.applyManifest(clone(sB0), ManifestRateLimitCap::Capped) == + ManifestDisposition::Stale); BEAST_EXPECT(!deserializeManifest(fake)); - BEAST_EXPECT(cache.applyManifest(clone(sB1)) == ManifestDisposition::Invalid); - BEAST_EXPECT(cache.applyManifest(clone(sB2)) == ManifestDisposition::Accepted); + BEAST_EXPECT( + cache.applyManifest(clone(sB1), ManifestRateLimitCap::Capped) == + ManifestDisposition::Invalid); + BEAST_EXPECT( + cache.applyManifest(clone(sB2), ManifestRateLimitCap::Capped) == + ManifestDisposition::Accepted); auto const sC0 = makeManifest( kpB2.second, KeyType::Ed25519, randomSecretKey(), KeyType::Ed25519, 47); - BEAST_EXPECT(cache.applyManifest(clone(sC0)) == ManifestDisposition::BadMasterKey); + BEAST_EXPECT( + cache.applyManifest(clone(sC0), ManifestRateLimitCap::Capped) == + ManifestDisposition::BadMasterKey); } testLoadStore(cache); diff --git a/src/test/app/ValidatorList_test.cpp b/src/test/app/ValidatorList_test.cpp index 60228f6723..0340b24680 100644 --- a/src/test/app/ValidatorList_test.cpp +++ b/src/test/app/ValidatorList_test.cpp @@ -278,8 +278,10 @@ private: trustedKeys->load(localSigningPublicOuter, emptyCfgKeys, emptyCfgPublishers)); BEAST_EXPECT(trustedKeys->listed(localSigningPublicOuter)); - // NOLINTNEXTLINE(bugprone-unchecked-optional-access) - manifests.applyManifest(*deserializeManifest(cfgManifest)); + // NOLINTBEGIN(bugprone-unchecked-optional-access) + manifests.applyManifest( + *deserializeManifest(cfgManifest), ManifestRateLimitCap::Capped); + // NOLINTEND(bugprone-unchecked-optional-access) BEAST_EXPECT( trustedKeys->load(localSigningPublicOuter, emptyCfgKeys, emptyCfgPublishers)); @@ -369,8 +371,10 @@ private: app.config().legacy(Sections::kDatabasePath), env.journal); - // NOLINTNEXTLINE(bugprone-unchecked-optional-access) - manifests.applyManifest(*deserializeManifest(cfgManifest)); + // NOLINTBEGIN(bugprone-unchecked-optional-access) + manifests.applyManifest( + *deserializeManifest(cfgManifest), ManifestRateLimitCap::Capped); + // NOLINTEND(bugprone-unchecked-optional-access) BEAST_EXPECT(trustedKeys->load(localSigningPublicOuter, cfgKeys, emptyCfgPublishers)); @@ -455,13 +459,16 @@ private: auto const pubRevokedSigning = randomKeyPair(KeyType::Secp256k1); // make this manifest revoked (seq num = max) // -- thus should not be loaded - // NOLINTNEXTLINE(bugprone-unchecked-optional-access) - pubManifests.applyManifest(*deserializeManifest(makeManifestString( - pubRevokedPublic, - pubRevokedSecret, - pubRevokedSigning.first, - pubRevokedSigning.second, - std::numeric_limits::max()))); + // NOLINTBEGIN(bugprone-unchecked-optional-access) + pubManifests.applyManifest( + *deserializeManifest(makeManifestString( + pubRevokedPublic, + pubRevokedSecret, + pubRevokedSigning.first, + pubRevokedSigning.second, + std::numeric_limits::max())), + ManifestRateLimitCap::Capped); + // NOLINTEND(bugprone-unchecked-optional-access) // these two are not revoked (and not in the manifest cache at all.) auto legitKey1 = randomMasterKey(); @@ -494,13 +501,16 @@ private: auto const pubRevokedSigning = randomKeyPair(KeyType::Secp256k1); // make this manifest revoked (seq num = max) // -- thus should not be loaded - // NOLINTNEXTLINE(bugprone-unchecked-optional-access) - pubManifests.applyManifest(*deserializeManifest(makeManifestString( - pubRevokedPublic, - pubRevokedSecret, - pubRevokedSigning.first, - pubRevokedSigning.second, - std::numeric_limits::max()))); + // NOLINTBEGIN(bugprone-unchecked-optional-access) + pubManifests.applyManifest( + *deserializeManifest(makeManifestString( + pubRevokedPublic, + pubRevokedSecret, + pubRevokedSigning.first, + pubRevokedSigning.second, + std::numeric_limits::max())), + ManifestRateLimitCap::Capped); + // NOLINTEND(bugprone-unchecked-optional-access) // this one is not revoked (and not in the manifest cache at all.) auto legitKey = randomMasterKey(); @@ -1218,7 +1228,8 @@ private: BEAST_EXPECT( // NOLINTNEXTLINE(bugprone-unchecked-optional-access) - manifestsOuter.applyManifest(std::move(*m1)) == ManifestDisposition::Accepted); + manifestsOuter.applyManifest(std::move(*m1), ManifestRateLimitCap::Capped) == + ManifestDisposition::Accepted); BEAST_EXPECT(trustedKeysOuter->listed(masterPublic)); BEAST_EXPECT(trustedKeysOuter->trusted(masterPublic)); BEAST_EXPECT(trustedKeysOuter->listed(signingPublic1)); @@ -1232,7 +1243,8 @@ private: masterPublic, masterPrivate, signingPublic2, signingKeys2.second, 2)); BEAST_EXPECT( // NOLINTNEXTLINE(bugprone-unchecked-optional-access) - manifestsOuter.applyManifest(std::move(*m2)) == ManifestDisposition::Accepted); + manifestsOuter.applyManifest(std::move(*m2), ManifestRateLimitCap::Capped) == + ManifestDisposition::Accepted); BEAST_EXPECT(trustedKeysOuter->listed(masterPublic)); BEAST_EXPECT(trustedKeysOuter->trusted(masterPublic)); BEAST_EXPECT(trustedKeysOuter->listed(signingPublic2)); @@ -1249,7 +1261,8 @@ private: // NOLINTBEGIN(bugprone-unchecked-optional-access) BEAST_EXPECT(max->revoked()); BEAST_EXPECT( - manifestsOuter.applyManifest(std::move(*max)) == ManifestDisposition::Accepted); + manifestsOuter.applyManifest(std::move(*max), ManifestRateLimitCap::Capped) == + ManifestDisposition::Accepted); // NOLINTEND(bugprone-unchecked-optional-access) BEAST_EXPECT(manifestsOuter.getSigningKey(masterPublic) == masterPublic); @@ -2668,7 +2681,9 @@ private: auto threshold = listThreshold > 0 ? std::optional(listThreshold) : std::nullopt; if (self) { - valManifests.applyManifest(*deserializeManifest(base64Decode(self->manifest))); + valManifests.applyManifest( + *deserializeManifest(base64Decode(self->manifest)), + ManifestRateLimitCap::Capped); BEAST_EXPECT( result->load(self->signingPublic, emptyCfgKeys, cfgPublishers, threshold)); } diff --git a/src/xrpld/app/misc/detail/ValidatorList.cpp b/src/xrpld/app/misc/detail/ValidatorList.cpp index b77dcc23c2..1b4d2bab80 100644 --- a/src/xrpld/app/misc/detail/ValidatorList.cpp +++ b/src/xrpld/app/misc/detail/ValidatorList.cpp @@ -1065,6 +1065,8 @@ ValidatorList::updatePublisherList( { // Increment list count for added keys ++keyListings_[*iNew]; + // Key is now listed: free its untrusted slot if it had one. + validatorManifests_.promoteToTrusted(*iNew); ++iNew; } else if (iNew == publisherList.end() || (iOld != oldList.end() && *iOld < *iNew)) @@ -1103,7 +1105,8 @@ ValidatorList::updatePublisherList( continue; } - if (auto const r = validatorManifests_.applyManifest(std::move(*m)); + if (auto const r = + validatorManifests_.applyManifest(std::move(*m), ManifestRateLimitCap::Uncapped); r == ManifestDisposition::Invalid) { JLOG(j_.warn()) << "List for " << strHex(pubKey) @@ -1357,7 +1360,10 @@ ValidatorList::verify( PublicKey masterPubKey = manifest.masterKey; auto const revoked = manifest.revoked(); - auto const result = publisherManifests_.applyManifest(std::move(manifest)); + // Publisher keys are configured/trusted (checked above), so bypass the + // untrusted cap. + auto const result = + publisherManifests_.applyManifest(std::move(manifest), ManifestRateLimitCap::Uncapped); if (revoked && result == ManifestDisposition::Accepted) { diff --git a/src/xrpld/overlay/detail/OverlayImpl.cpp b/src/xrpld/overlay/detail/OverlayImpl.cpp index 6a6a6edace..e59cff85d8 100644 --- a/src/xrpld/overlay/detail/OverlayImpl.cpp +++ b/src/xrpld/overlay/detail/OverlayImpl.cpp @@ -675,13 +675,22 @@ OverlayImpl::onManifests( if (auto mo = deserializeManifest(s)) { auto const serialized = mo->serialized; + // Resolve trust before applyManifest takes the manifest-cache + // lock: listed() takes the validator-list lock, so ordering it + // first avoids holding the two locks in opposite orders. + bool const isTrusted = app_.getValidators().listed(mo->masterKey); + // Updates to a known key are relayed even when untrusted. Use + // getSequence, not getManifest, to avoid copying the cached payload + // on this hot path. + bool const isKnown = + app_.getValidatorManifests().getSequence(mo->masterKey).has_value(); - auto const result = app_.getValidatorManifests().applyManifest(std::move(*mo)); + auto const result = app_.getValidatorManifests().applyManifest( + std::move(*mo), + isTrusted ? ManifestRateLimitCap::Uncapped : ManifestRateLimitCap::Capped); if (result == ManifestDisposition::Accepted) { - relay.add_list()->set_stobject(s); - // N.B.: this is important; the applyManifest call above moves // the loaded Manifest out of the optional so we need to // reload it here. @@ -693,10 +702,19 @@ OverlayImpl::onManifests( // NOLINTBEGIN(bugprone-unchecked-optional-access) assert above app_.getOPs().pubManifest(*mo); - if (app_.getValidators().listed(mo->masterKey)) + // Relay only trusted manifests or updates to known keys, so + // untrusted gossip for a brand-new key cannot be amplified. + // Persist to the wallet DB only for trusted keys, so untrusted + // gossip never survives a restart. + if (isTrusted || isKnown) { - auto db = app_.getWalletDB().checkoutDb(); - addValidatorManifest(*db, serialized); + relay.add_list()->set_stobject(s); + + if (isTrusted) + { + auto db = app_.getWalletDB().checkoutDb(); + addValidatorManifest(*db, serialized); + } } // NOLINTEND(bugprone-unchecked-optional-access) } From 6b3eaf091b64d653115186384c575453e2cf18aa Mon Sep 17 00:00:00 2001 From: Shawn Xie <35279399+shawnxie999@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:06:17 -0400 Subject: [PATCH 19/52] fix: Change ConfidentialMPTConvert to no delegate --- .../xrpl/protocol/detail/transactions.macro | 2 +- .../transactions/ConfidentialMPTConvert.h | 2 +- .../app/ConfidentialTransferExtended_test.cpp | 198 +++++++++--------- src/test/app/Delegate_test.cpp | 2 +- 4 files changed, 105 insertions(+), 99 deletions(-) diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index dc03cf7c59..6d809f7ab5 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -1085,7 +1085,7 @@ TRANSACTION(ttLOAN_PAY, 84, LoanPay, # include #endif TRANSACTION(ttCONFIDENTIAL_MPT_CONVERT, 85, ConfidentialMPTConvert, - Delegation::Delegable, + Delegation::NotDelegable, featureConfidentialTransfer, NoPriv, ({ diff --git a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvert.h b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvert.h index dec7f733c9..284b7f9e70 100644 --- a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvert.h +++ b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvert.h @@ -19,7 +19,7 @@ class ConfidentialMPTConvertBuilder; * @brief Transaction: ConfidentialMPTConvert * * Type: ttCONFIDENTIAL_MPT_CONVERT (85) - * Delegable: Delegation::Delegable + * Delegable: Delegation::NotDelegable * Amendment: featureConfidentialTransfer * Privileges: NoPriv * diff --git a/src/test/app/ConfidentialTransferExtended_test.cpp b/src/test/app/ConfidentialTransferExtended_test.cpp index 953325a6e9..fe5e0b3064 100644 --- a/src/test/app/ConfidentialTransferExtended_test.cpp +++ b/src/test/app/ConfidentialTransferExtended_test.cpp @@ -1653,30 +1653,35 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase mptAlice.generateKeyPair(carol); mptAlice.set({.issuerPubKey = mptAlice.getPubKey(alice)}); - // Bob delegates Convert, MergeInbox to dave. - env(delegate::set(bob, dave, {"ConfidentialMPTConvert", "ConfidentialMPTMergeInbox"})); + // ConfidentialMPTConvert is not delegable: attempting to grant it as a + // delegated permission is rejected at preflight of DelegateSet. + env(delegate::set(bob, dave, {"ConfidentialMPTConvert"}), Ter(temMALFORMED)); env.close(); - // Carol has no permission from bob to convert on his behalf. + // Bob delegates MergeInbox to dave. + env(delegate::set(bob, dave, {"ConfidentialMPTMergeInbox"})); + env.close(); + + // A Convert carrying a Delegate is rejected at preflight because the + // transaction type is not delegable at all. mptAlice.convert({ .account = bob, .amt = 10, .holderPubKey = mptAlice.getPubKey(bob), - .delegate = carol, - .err = terNO_DELEGATE_PERMISSION, + .delegate = dave, + .err = temINVALID, }); - // Dave executes Convert on behalf of bob, registering bob's key. + // Bob converts, registering bob's key. mptAlice.convert({ .account = bob, .amt = 100, .holderPubKey = mptAlice.getPubKey(bob), - .delegate = dave, }); env.require(MptBalance(mptAlice, bob, 100)); - // Dave executes Convert again on behalf of bob (no key registration). - mptAlice.convert({.account = bob, .amt = 50, .delegate = dave}); + // Bob converts again (no key registration). + mptAlice.convert({.account = bob, .amt = 50}); // Dave executes MergeInbox on behalf of bob. mptAlice.mergeInbox({.account = bob, .delegate = dave}); @@ -1698,10 +1703,7 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase .err = terNO_DELEGATE_PERMISSION}); // Bob delegates ConfidentialMPTSend to dave. - env(delegate::set( - bob, - dave, - {"ConfidentialMPTConvert", "ConfidentialMPTMergeInbox", "ConfidentialMPTSend"})); + env(delegate::set(bob, dave, {"ConfidentialMPTMergeInbox", "ConfidentialMPTSend"})); env.close(); // Dave executes Send on behalf of bob. @@ -1716,10 +1718,7 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase env(delegate::set( bob, dave, - {"ConfidentialMPTConvert", - "ConfidentialMPTMergeInbox", - "ConfidentialMPTSend", - "ConfidentialMPTConvertBack"})); + {"ConfidentialMPTMergeInbox", "ConfidentialMPTSend", "ConfidentialMPTConvertBack"})); env.close(); // Dave executes ConvertBack on behalf of bob. @@ -1766,16 +1765,15 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase // Creating the Delegate SLE consumes one owner reserve slot for bob. auto const bobOwnersBefore = ownerCount(env, bob); - env(delegate::set(bob, carol, {"ConfidentialMPTConvert", "ConfidentialMPTMergeInbox"})); + env(delegate::set(bob, carol, {"ConfidentialMPTMergeInbox"})); env.close(); env.require(Owners(bob, bobOwnersBefore + 1)); - // Carol converts and merge inbox on behalf of bob. + // Bob converts; carol merges inbox on behalf of bob. mptAlice.convert({ .account = bob, .amt = 50, .holderPubKey = mptAlice.getPubKey(bob), - .delegate = carol, }); mptAlice.mergeInbox({.account = bob, .delegate = carol}); @@ -1784,16 +1782,18 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase env.close(); env.require(Owners(bob, bobOwnersBefore)); - // Carol can no longer convert on behalf of bob. - mptAlice.convert({ + // Bob converts again to populate a fresh inbox. + mptAlice.convert({.account = bob, .amt = 30}); + + // Carol can no longer merge inbox on behalf of bob. + mptAlice.mergeInbox({ .account = bob, - .amt = 30, .delegate = carol, .err = terNO_DELEGATE_PERMISSION, }); - // Bob can still convert by himself. - mptAlice.convert({.account = bob, .amt = 30}); + // Bob can still merge his inbox. + mptAlice.mergeInbox({.account = bob}); } // Verifies that a delegated confidential transfer works correctly when an @@ -1833,16 +1833,15 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase .auditorPubKey = mptAlice.getPubKey(auditor), }); - // Bob delegates Convert and Send permissions to dave. - env(delegate::set(bob, dave, {"ConfidentialMPTSend", "ConfidentialMPTConvert"})); + // Bob delegates Send permission to dave (Convert is not delegable). + env(delegate::set(bob, dave, {"ConfidentialMPTSend"})); env.close(); - // Dave converts on behalf of bob. + // Bob converts. mptAlice.convert({ .account = bob, .amt = 50, .holderPubKey = mptAlice.getPubKey(bob), - .delegate = dave, }); mptAlice.mergeInbox({.account = bob}); @@ -2229,7 +2228,7 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase mpt.pay(alice, frank, 40); mpt.generateKeyPair(frank); - env(delegate::set(bob, dave, {"ConfidentialMPTConvert", "ConfidentialMPTConvertBack"})); + env(delegate::set(bob, dave, {"ConfidentialMPTConvertBack"})); env(delegate::set(carol, erin, {"ConfidentialMPTSend"})); env(delegate::set(bob, erin, {"ConfidentialMPTMergeInbox"})); env.close(); @@ -2238,15 +2237,15 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase auto const bobSeq = env.seq(bob); auto const carolSeq = env.seq(carol); auto const frankSeq = env.seq(frank); - auto const batchFee = batch::calcConfidentialBatchFee(env, 3, 6); + auto const batchFee = batch::calcConfidentialBatchFee(env, 4, 6); - // Dave submits the batch. Bob's convert and convertback use Dave as Delegate; + // Dave submits the batch. Bob's convertback uses Dave as Delegate; + // Convert is not delegable, so Bob signs his own convert inner tx. // Carol's send and Bob's mergeInbox use Erin as Delegate. Frank's // convert and mergeInbox are non-delegated. auto jv1 = mpt.convertBackJV({.account = bob, .amt = 30}, bobSeq); jv1[jss::Delegate] = dave.human(); - auto jv2 = mpt.convertJV({.account = bob, .amt = 20}, bobSeq + 1); - jv2[jss::Delegate] = dave.human(); + auto const jv2 = mpt.convertJV({.account = bob, .amt = 20}, bobSeq + 1); auto jv3 = mpt.sendJV({.account = carol, .dest = bob, .amt = 15}, carolSeq); jv3[jss::Delegate] = erin.human(); auto const jv4 = mpt.convertJV( @@ -2262,7 +2261,7 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase batch::Inner(jv4, frankSeq), batch::Inner(jv5, frankSeq + 1), batch::Inner(jv6, bobSeq + 2), - batch::Sig(erin, frank), + batch::Sig(erin, frank, bob), Ter(tesSUCCESS)); env.close(); @@ -2283,7 +2282,10 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase BEAST_EXPECT(mpt.getIssuanceConfidentialBalance() == 175); } - // Test invalid scenarios for delegation with tickets. + // Test invalid scenarios for delegation with tickets. ConfidentialMPTConvert + // is not delegable, so ConfidentialMPTConvertBack (which is delegable and + // whose ZK proof also binds to the transaction/ticket sequence) is used as + // the delegated operation. Carol acts as bob's delegate throughout. void testInvalidDelegationWithTickets(FeatureBitset features) { @@ -2309,33 +2311,52 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase mptAlice.generateKeyPair(bob); mptAlice.set({.issuerPubKey = mptAlice.getPubKey(alice)}); - // Bob grants carol permissions. - env(delegate::set(bob, carol, {"ConfidentialMPTConvert"})); + // Give bob a confidential spending balance to convert back from. + mptAlice.convert({.account = bob, .amt = 100, .holderPubKey = mptAlice.getPubKey(bob)}); + mptAlice.mergeInbox({.account = bob}); + + // Bob delegates ConfidentialMPTConvertBack to carol. + env(delegate::set(bob, carol, {"ConfidentialMPTConvertBack"})); env.close(); uint64_t const amt = 10; - auto const bf = generateBlindingFactor(); - auto const holderCt = mptAlice.encryptAmount(bob, amt, bf); - auto const issuerCt = mptAlice.encryptAmount(alice, amt, bf); + + // Every case below fails, so bob's spending balance and version never + // change; capture the crypto material needed to build proofs once. + auto const spendingBalance = requireOptional( + mptAlice.getDecryptedBalance(bob, MPTTester::holderEncryptedSpending), + "Missing spending balance."); + auto const encSpending = requireOptional( + mptAlice.getEncryptedBalance(bob, MPTTester::holderEncryptedSpending), + "Missing encrypted spending balance."); + auto const version = mptAlice.getMPTokenVersion(bob); + auto const pcBf = generateBlindingFactor(); + auto const pc = mptAlice.getPedersenCommitment(spendingBalance, pcBf); + + // Build a ConvertBack proof bound to a given sequence. + auto proofForSeq = [&](std::uint32_t seq) { + return mptAlice.getConvertBackProof( + bob, + amt, + getConvertBackContextHash(bob, mptAlice.issuanceID(), seq, version), + { + .pedersenCommitment = pc, + .amt = spendingBalance, + .encryptedAmt = encSpending, + .blindingFactor = pcBf, + }); + }; // Invalid: proof built with wrong ticket sequence (ticketSeq + 1). { auto const ticketSeq = env.seq(bob) + 1; env(ticket::create(bob, 1)); - auto const badCtxHash = - getConvertContextHash(bob, mptAlice.issuanceID(), ticketSeq + 1); - auto const badProof = requireOptional( - mptAlice.getSchnorrProof(bob, badCtxHash), "Missing Schnorr Proof."); - - mptAlice.convert({ + mptAlice.convertBack({ .account = bob, .amt = amt, - .proof = strHex(badProof), - .holderPubKey = mptAlice.getPubKey(bob), - .holderEncryptedAmt = holderCt, - .issuerEncryptedAmt = issuerCt, - .blindingFactor = bf, + .proof = proofForSeq(ticketSeq + 1), + .pedersenCommitment = pc, .delegate = carol, .ticketSeq = ticketSeq, .err = tecBAD_PROOF, @@ -2346,18 +2367,12 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase { auto const ticketSeq = env.seq(bob) + 1; env(ticket::create(bob, 1)); - auto const badCtxHash = getConvertContextHash(bob, mptAlice.issuanceID(), env.seq(bob)); - auto const badProof = requireOptional( - mptAlice.getSchnorrProof(bob, badCtxHash), "Missing Schnorr Proof."); - mptAlice.convert({ + mptAlice.convertBack({ .account = bob, .amt = amt, - .proof = strHex(badProof), - .holderPubKey = mptAlice.getPubKey(bob), - .holderEncryptedAmt = holderCt, - .issuerEncryptedAmt = issuerCt, - .blindingFactor = bf, + .proof = proofForSeq(env.seq(bob)), + .pedersenCommitment = pc, .delegate = carol, .ticketSeq = ticketSeq, .err = tecBAD_PROOF, @@ -2366,13 +2381,9 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase // Invalid: ticket sequence is far in the future and hasn't been created yet. { - mptAlice.convert({ + mptAlice.convertBack({ .account = bob, .amt = amt, - .holderPubKey = mptAlice.getPubKey(bob), - .holderEncryptedAmt = holderCt, - .issuerEncryptedAmt = issuerCt, - .blindingFactor = bf, .delegate = carol, .ticketSeq = env.seq(bob) + 100, .err = terPRE_TICKET, @@ -2381,13 +2392,9 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase // Invalid: ticket sequence is in the past but was never created. { - mptAlice.convert({ + mptAlice.convertBack({ .account = bob, .amt = amt, - .holderPubKey = mptAlice.getPubKey(bob), - .holderEncryptedAmt = holderCt, - .issuerEncryptedAmt = issuerCt, - .blindingFactor = bf, .delegate = carol, .ticketSeq = 1, .err = tefNO_TICKET, @@ -2395,17 +2402,14 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase } // Invalid: the delegated account, carol, creates a ticket and uses it. + // The ticket must belong to the delegator (bob), not the delegate. { auto const carolTicketSeq = env.seq(carol) + 1; env(ticket::create(carol, 1)); - mptAlice.convert({ + mptAlice.convertBack({ .account = bob, .amt = amt, - .holderPubKey = mptAlice.getPubKey(bob), - .holderEncryptedAmt = holderCt, - .issuerEncryptedAmt = issuerCt, - .blindingFactor = bf, .delegate = carol, .ticketSeq = carolTicketSeq, .err = tefNO_TICKET, @@ -2418,25 +2422,31 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase auto const ticketSeq = env.seq(bob) + 1; env(ticket::create(bob, 1)); - // Build proof using ticketSeq. - auto const ctxHashForTicket = - getConvertContextHash(bob, mptAlice.issuanceID(), ticketSeq); - auto const proof = requireOptional( - mptAlice.getSchnorrProof(bob, ctxHashForTicket), "Missing Schnorr Proof."); - - // Submit without ticket. - mptAlice.convert({ + // Submit without a ticket; proof is bound to ticketSeq. + mptAlice.convertBack({ .account = bob, .amt = amt, - .proof = strHex(proof), - .holderPubKey = mptAlice.getPubKey(bob), - .holderEncryptedAmt = holderCt, - .issuerEncryptedAmt = issuerCt, - .blindingFactor = bf, + .proof = proofForSeq(ticketSeq), + .pedersenCommitment = pc, .delegate = carol, .err = tecBAD_PROOF, }); } + + // Valid: carol converts back on bob's behalf using a ticket owned by bob, + // with a proof correctly bound to that ticket sequence. bob's spending + // balance drops from 100 to 90. + { + auto const ticketSeq = env.seq(bob) + 1; + env(ticket::create(bob, 1)); + + mptAlice.convertBack({ + .account = bob, + .amt = amt, + .delegate = carol, + .ticketSeq = ticketSeq, + }); + } } // Verifies that delegation works correctly when the delegating account uses @@ -2471,19 +2481,16 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase mptAlice.generateKeyPair(carol); mptAlice.set({.issuerPubKey = mptAlice.getPubKey(alice)}); - // Bob grants dave permissions. + // Bob grants dave permissions (Convert is not delegable). env(delegate::set( bob, dave, - {"ConfidentialMPTConvert", - "ConfidentialMPTMergeInbox", - "ConfidentialMPTSend", - "ConfidentialMPTConvertBack"})); + {"ConfidentialMPTMergeInbox", "ConfidentialMPTSend", "ConfidentialMPTConvertBack"})); // Alice grants dave permission to clawback on her behalf. env(delegate::set(alice, dave, {"ConfidentialMPTClawback"})); env.close(); - // Dave executes Convert on behalf of bob using ticket. + // Bob converts using a ticket. auto ticketSeq = env.seq(bob) + 1; env(ticket::create(bob, 1)); BEAST_EXPECT(env.seq(bob) != ticketSeq); @@ -2491,7 +2498,6 @@ class ConfidentialTransferExtended_test : public ConfidentialTransferTestBase .account = bob, .amt = 100, .holderPubKey = mptAlice.getPubKey(bob), - .delegate = dave, .ticketSeq = ticketSeq, }); env.require(MptBalance(mptAlice, bob, 100)); diff --git a/src/test/app/Delegate_test.cpp b/src/test/app/Delegate_test.cpp index a3fa2ac0b5..d61220a1ef 100644 --- a/src/test/app/Delegate_test.cpp +++ b/src/test/app/Delegate_test.cpp @@ -2750,7 +2750,7 @@ class Delegate_test : public beast::unit_test::Suite // DO NOT modify expectedDelegableCount unless all scenarios, including // edge cases, have been fully tested and verified. // ==================================================================== - std::size_t const expectedDelegableCount = 57; + std::size_t const expectedDelegableCount = 56; BEAST_EXPECTS( delegableCount == expectedDelegableCount, From faca302adfb4dec96b36d850d906b5df6708f859 Mon Sep 17 00:00:00 2001 From: Denis Angell Date: Fri, 17 Jul 2026 17:08:57 -0400 Subject: [PATCH 20/52] fix: Check transaction type before RawTransactions --- src/libxrpl/protocol/STTx.cpp | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/libxrpl/protocol/STTx.cpp b/src/libxrpl/protocol/STTx.cpp index 17d7617590..6aadefee27 100644 --- a/src/libxrpl/protocol/STTx.cpp +++ b/src/libxrpl/protocol/STTx.cpp @@ -812,16 +812,19 @@ invalidMPTAmountInTx(STObject const& tx) static bool isBatchRawTransactionOkay(STTx const& tx, std::string& reason) { - if (!tx.isFieldPresent(sfRawTransactions)) + XRPL_ASSERT( + tx.getTxnType() == ttBATCH || !tx.isFieldPresent(sfRawTransactions), + "xrpl::isBatchRawTransactionOkay : raw transactions only on batch"); + + if (tx.getTxnType() != ttBATCH) return true; - // sfRawTransactions only appears on a Batch. passesLocalChecks runs on - // unverified user and peer input, so reject (rather than assert) a non-batch - // transaction that carries it. - if (tx.getTxnType() != ttBATCH) + if (!tx.isFieldPresent(sfRawTransactions)) { - reason = "Only Batch transactions may contain raw transactions."; + // LCOV_EXCL_START + reason = "Batch transactions must contain raw transactions."; return false; + // LCOV_EXCL_STOP } if (tx.isFieldPresent(sfBatchSigners) && From 982bf36dd8f162a504182a0c78d2c8b19d99c4cd Mon Sep 17 00:00:00 2001 From: Bart Date: Fri, 17 Jul 2026 17:09:13 -0400 Subject: [PATCH 21/52] chore: Bump version to 3.3.0-rc2 --- src/libxrpl/protocol/BuildInfo.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libxrpl/protocol/BuildInfo.cpp b/src/libxrpl/protocol/BuildInfo.cpp index 6ac352f3e1..a5462d9c09 100644 --- a/src/libxrpl/protocol/BuildInfo.cpp +++ b/src/libxrpl/protocol/BuildInfo.cpp @@ -23,7 +23,7 @@ namespace { //------------------------------------------------------------------------------ // clang-format off // NOLINTNEXTLINE(readability-identifier-naming) -char const* const versionString = "3.3.0-rc1" +char const* const versionString = "3.3.0-rc2" // clang-format on ; From 9cd531659ad12b7778f7566f0bea36a8b82b65da Mon Sep 17 00:00:00 2001 From: Valentin Balaschenko <13349202+vlntb@users.noreply.github.com> Date: Thu, 23 Jul 2026 19:53:15 +0100 Subject: [PATCH 22/52] fix: Revert "fix: Reject oversized SHAMap nodes in gotStaleData and fetch-pack path" --- src/xrpld/app/ledger/InboundLedgers.h | 31 ------------------- .../app/ledger/detail/InboundLedgers.cpp | 3 -- src/xrpld/overlay/detail/PeerImp.cpp | 13 -------- 3 files changed, 47 deletions(-) diff --git a/src/xrpld/app/ledger/InboundLedgers.h b/src/xrpld/app/ledger/InboundLedgers.h index 97182644e7..e288201c66 100644 --- a/src/xrpld/app/ledger/InboundLedgers.h +++ b/src/xrpld/app/ledger/InboundLedgers.h @@ -4,7 +4,6 @@ #include #include -#include #include #include #include @@ -21,36 +20,6 @@ namespace xrpl { -// Per-node cap for AS state leaves stashed via `gotStaleData`. -// -// `gotStaleData` only handles `liAS_NODE` payloads, which carry -// SHAMap state-map leaves (ledger objects). -// -// Sizing: worst-case serialized size across all 31 ledger entry -// types is ~53 KB (`XChainOwnedCreateAccountClaimID`, 256 -// attestations x ~209 B, capped by `kMaxAttestations` in -// `include/xrpl/protocol/XChainAttestations.h`), followed by -// `XChainOwnedClaimID` ~40 KB, `NFTokenPage` ~9.5 KB, and -// `LedgerHashes` ~8.2 KB. 256 KiB leaves ~4.8x headroom over the -// current worst case. -// -// Future-proofing: this cap is NOT derived from a single protocol -// constant — it is a soft bound over independently-tuned caps -// (`kMaxAttestations`, `kDirMaxTokensPerPage`, `kMaxTokenUriLength`, -// etc.). Two types (`Amendments`, `NegativeUNL`) have no hard schema -// cap and grow with network state. Revisit if a new object type or -// a lifted array cap approaches ~256 KiB. The downstream -// `SHAMapAccountStateLeafNode` construction rejects anything above -// the 16 MiB SHAMapItem invariant regardless. -inline constexpr std::size_t kMaxFetchPackNodeBytes = 256 * 1024; - -// Aggregate cap on the sum of `nodedata().size()` across all entries -// in a single `TMLedgerData` message. Rejects amplification-shaped -// payloads (many nodes, each individually under `kMaxFetchPackNodeBytes`, -// that together dwarf the per-message budget) at ingress in PeerImp, -// before dispatch into `InboundLedger::gotData` or `gotStaleData`. -inline constexpr std::size_t kMaxLedgerDataBytes = megabytes(1); - /** * Manages the lifetime of inbound ledgers. * diff --git a/src/xrpld/app/ledger/detail/InboundLedgers.cpp b/src/xrpld/app/ledger/detail/InboundLedgers.cpp index 81544fd234..dc361694cf 100644 --- a/src/xrpld/app/ledger/detail/InboundLedgers.cpp +++ b/src/xrpld/app/ledger/detail/InboundLedgers.cpp @@ -259,9 +259,6 @@ public: if (!node.has_nodeid() || !node.has_nodedata()) return; - if (node.nodedata().size() > kMaxFetchPackNodeBytes) - return; - auto newNode = SHAMapTreeNode::makeFromWire(makeSlice(node.nodedata())); if (!newNode) diff --git a/src/xrpld/overlay/detail/PeerImp.cpp b/src/xrpld/overlay/detail/PeerImp.cpp index 0b969792ba..962ab0f408 100644 --- a/src/xrpld/overlay/detail/PeerImp.cpp +++ b/src/xrpld/overlay/detail/PeerImp.cpp @@ -1699,19 +1699,6 @@ PeerImp::onMessage(std::shared_ptr const& m) return; } - { - std::size_t totalNodeBytes = 0; - for (int i = 0; i < m->nodes_size(); ++i) - totalNodeBytes += m->nodes(i).nodedata().size(); - if (totalNodeBytes > kMaxLedgerDataBytes) - { - JLOG(pJournal_.warn()) - << "Ledger data: oversized nodes (" << totalNodeBytes << " bytes)"; - fee_.update(Resource::kFeeInvalidData, "oversized ledger nodes"); - return; - } - } - // If there is a request cookie, attempt to relay the message if (m->has_requestcookie()) { From c50edf507c3ddec833750ee27ee4348ea1ac24d3 Mon Sep 17 00:00:00 2001 From: Pratik Mankawde <3397372+pratikmankawde@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:27:45 +0100 Subject: [PATCH 23/52] fix: Reduce untrusted manifest cache cap to 100 --- include/xrpl/server/Manifest.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/include/xrpl/server/Manifest.h b/include/xrpl/server/Manifest.h index 19dfbfd54f..452047ecd7 100644 --- a/include/xrpl/server/Manifest.h +++ b/include/xrpl/server/Manifest.h @@ -346,7 +346,7 @@ private: * * Once reached, a manifest for a brand-new unlisted key is rejected. */ - static constexpr std::size_t kMaxUntrustedCount = 50000; + static constexpr std::size_t kMaxUntrustedCount = 100; /** * Running count of manifests rejected because the untrusted cap was full. From 1653f0c80ffee49426bc99eaddeaeff63cfc2f34 Mon Sep 17 00:00:00 2001 From: Bart Date: Thu, 23 Jul 2026 16:39:50 -0400 Subject: [PATCH 24/52] chore: Bump version to 3.3.0-rc3 --- src/libxrpl/protocol/BuildInfo.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libxrpl/protocol/BuildInfo.cpp b/src/libxrpl/protocol/BuildInfo.cpp index a5462d9c09..bf058a455f 100644 --- a/src/libxrpl/protocol/BuildInfo.cpp +++ b/src/libxrpl/protocol/BuildInfo.cpp @@ -23,7 +23,7 @@ namespace { //------------------------------------------------------------------------------ // clang-format off // NOLINTNEXTLINE(readability-identifier-naming) -char const* const versionString = "3.3.0-rc2" +char const* const versionString = "3.3.0-rc3" // clang-format on ; From a5cc339d7b8d097a0ae3792420225565e2525699 Mon Sep 17 00:00:00 2001 From: Bart Date: Fri, 24 Jul 2026 18:39:35 -0400 Subject: [PATCH 25/52] fix: Revert "fix: Set request size limits and differential pricing for get-object-by-hash calls" --- src/test/overlay/TMGetObjectByHash_test.cpp | 18 +- src/xrpld/overlay/detail/PeerImp.cpp | 217 +++++--------------- src/xrpld/overlay/detail/PeerImp.h | 70 ------- src/xrpld/overlay/detail/Tuning.h | 103 ---------- 4 files changed, 49 insertions(+), 359 deletions(-) diff --git a/src/test/overlay/TMGetObjectByHash_test.cpp b/src/test/overlay/TMGetObjectByHash_test.cpp index e579989181..961e1b7eb4 100644 --- a/src/test/overlay/TMGetObjectByHash_test.cpp +++ b/src/test/overlay/TMGetObjectByHash_test.cpp @@ -100,17 +100,6 @@ class TMGetObjectByHash_test : public beast::unit_test::Suite return lastSentMessage_; } - // Synchronous test access to the JobQueue-dispatched processor. - // The production path runs this on JtLedgerReq; tests need a - // synchronous entry point to inspect the reply via send(). - // PeerImp::processGetObjectByHash is `protected` so the derived - // test subclass can call it directly. - void - runProcessGetObjectByHash(std::shared_ptr const& m) - { - processGetObjectByHash(m); - } - static void resetId() { @@ -190,10 +179,6 @@ class TMGetObjectByHash_test : public beast::unit_test::Suite /** * Test that reply is limited to hardMaxReplyNodes when more objects * are requested than the limit allows. - * - * `onMessage(TMGetObjectByHash)` dispatches the generic-query path - * to the JobQueue, so tests invoke the synchronous processor - * directly via `runProcessGetObjectByHash`. */ void testReplyLimit(size_t const numObjects, int const expectedReplySize) @@ -206,7 +191,8 @@ class TMGetObjectByHash_test : public beast::unit_test::Suite auto peer = createPeer(env); auto request = createRequest(numObjects, env); - peer->runProcessGetObjectByHash(request); + // Call the onMessage handler + peer->onMessage(request); // Verify that a reply was sent auto sentMessage = peer->getLastSentMessage(); diff --git a/src/xrpld/overlay/detail/PeerImp.cpp b/src/xrpld/overlay/detail/PeerImp.cpp index 962ab0f408..d5c5141317 100644 --- a/src/xrpld/overlay/detail/PeerImp.cpp +++ b/src/xrpld/overlay/detail/PeerImp.cpp @@ -22,7 +22,6 @@ #include #include -#include #include #include #include @@ -81,7 +80,6 @@ #include #include -#include #include #include #include @@ -378,19 +376,9 @@ PeerImp::charge(Resource::Charge const& fee, std::string const& context) if ((self->usage_.charge(fee, context) == Resource::Disposition::Drop) && self->usage_.disconnect(self->pJournal_)) { - // Idempotent: only the first worker to observe Drop counts the - // metric and posts fail(). Without the guard, several queued - // workers can all see Drop before fail() lands on the strand, - // overcounting peerDisconnectsCharges_ and posting duplicate - // shutdowns. fail(std::string const&) self-posts to strand_ - // when invoked off-strand. - bool expected = false; - if (self->chargeDisconnectFired_.compare_exchange_strong( - expected, true, std::memory_order_acq_rel)) - { - self->overlay_.incPeerDisconnectCharges(); - self->fail("charge: Resources"); - } + // Sever the connection. + self->overlay_.incPeerDisconnectCharges(); + self->fail("charge: Resources"); } }); } @@ -2485,63 +2473,62 @@ PeerImp::onMessage(std::shared_ptr const& m) return; } + protocol::TMGetObjectByHash reply; + reply.set_query(false); + reply.set_type(packet.type()); + if (packet.has_ledgerhash()) { if (!stringIsUInt256Sized(packet.ledgerhash())) { - JLOG(pJournal_.debug()) << "GetObj: malformed ledgerhash from peer " << id_; + JLOG(pJournal_.debug()) << "GetObj: malformed ledger hash from peer " << id_; fee_.update(Resource::kFeeMalformedRequest, "get object ledger hash"); return; } - } - // Reject oversized requests before touching the NodeStore. - // The legitimate upper bound (InboundLedger::getNeededHashes()) - // is 8 hashes; anything beyond kHardMaxReplyNodes is non-conforming. - if (packet.objects_size() > Tuning::kHardMaxReplyNodes) - { - JLOG(pJournal_.warn()) - << "GetObj: oversized request from peer " << id_ << " (" << packet.objects_size() - << " > " << Tuning::kHardMaxReplyNodes << ")"; - fee_.update(Resource::kFeeInvalidData, "oversized get object request"); - return; + + reply.set_ledgerhash(packet.ledgerhash()); } - // Dispatch heavy synchronous NodeStore lookups off the peer's - // I/O strand and onto the bounded job queue, mirroring the pattern - // used by processLedgerRequest. - std::weak_ptr const weak = shared_from_this(); - bool const queued = app_.getJobQueue().addJob(JtLedgerReq, "RcvGetObjByHash", [weak, m]() { - auto peer = weak.lock(); - if (!peer) - return; - try - { - peer->processGetObjectByHash(m); - } - catch (std::exception const& e) - { - // Surface backend failures (NodeStore I/O, allocation) - // back through the resource model so a misbehaving peer - // is still accountable rather than silently dropped. - JLOG(peer->pJournal_.warn()) << "GetObj: handler threw: " << e.what(); - peer->charge(Resource::kFeeRequestNoReply, "get object handler exception"); - } - }); - if (!queued) + fee_.update(Resource::kFeeModerateBurdenPeer, " received a get object by hash request"); + + // This is a very minimal implementation + for (int i = 0; i < packet.objects_size(); ++i) { - // The JobQueue is no longer accepting new work (typically - // because it is shutting down / has been joined). - JLOG(pJournal_.warn()) << "GetObj: job queue refused request from peer " << id_; - return; + auto const& obj = packet.objects(i); + if (obj.has_hash() && stringIsUInt256Sized(obj.hash())) + { + uint256 const hash = uint256::fromRaw(obj.hash()); + // VFALCO TODO Move this someplace more sensible so we dont + // need to inject the NodeStore interfaces. + std::uint32_t const seq{obj.has_ledgerseq() ? obj.ledgerseq() : 0}; + auto nodeObject{app_.getNodeStore().fetchNodeObject(hash, seq)}; + if (nodeObject) + { + protocol::TMIndexedObject& newObj = *reply.add_objects(); + newObj.set_hash(hash.begin(), hash.size()); + newObj.set_data(&nodeObject->getData().front(), nodeObject->getData().size()); + + if (obj.has_nodeid()) + newObj.set_index(obj.nodeid()); + if (obj.has_ledgerseq()) + newObj.set_ledgerseq(obj.ledgerseq()); + + // Check if by adding this object, reply has reached its + // limit + if (reply.objects_size() >= Tuning::kHardMaxReplyNodes) + { + fee_.update( + Resource::kFeeModerateBurdenPeer, + "Reply limit reached. Truncating reply."); + break; + } + } + } } - // Admission-time charge: a peer that floods enqueues would - // otherwise be billed only the trivial onMessageEnd fee per - // message until the JobQueue catches up, re-creating an - // uncharged DoS window. Charge the base burden up-front (after - // a successful enqueue); the per-lookup differential is added - // in the worker. - fee_.update(Resource::kFeeModerateBurdenPeer, "received a get object by hash request"); + JLOG(pJournal_.trace()) << "GetObj: " << reply.objects_size() << " of " + << packet.objects_size(); + send(std::make_shared(reply, protocol::mtGET_OBJECTS)); } else { @@ -2597,69 +2584,6 @@ PeerImp::onMessage(std::shared_ptr const& m) } } -void -PeerImp::processGetObjectByHash(std::shared_ptr const& m) -{ - protocol::TMGetObjectByHash const& packet = *m; - - protocol::TMGetObjectByHash reply; - reply.set_query(false); - reply.set_type(packet.type()); - - if (packet.has_ledgerhash()) - { - reply.set_ledgerhash(packet.ledgerhash()); - } - - // Defense in depth: caller (onMessage) already validates cheap - // structural properties of the request before dispatching here: - // - objects_size() <= kHardMaxReplyNodes (oversize gate) - // - if has_ledgerhash() then ledgerhash is uint256-sized - // The iteration cap below mirrors the oversize gate so this method - // remains safe if invoked directly by tests or future callers, and - // a peer cannot drive unbounded NodeStore lookups by sending - // non-existent hashes. - int const requested = packet.objects_size(); - int const iterLimit = std::min(requested, Tuning::kHardMaxReplyNodes); - - for (int i = 0; i < iterLimit; ++i) - { - auto const& obj = packet.objects(i); - if (!obj.has_hash() || !stringIsUInt256Sized(obj.hash())) - continue; - - uint256 const hash = uint256::fromRaw(obj.hash()); - // VFALCO TODO Move this someplace more sensible so we don't - // need to inject the NodeStore interfaces. - std::uint32_t const seq{obj.has_ledgerseq() ? obj.ledgerseq() : 0}; - auto const nodeObject = app_.getNodeStore().fetchNodeObject(hash, seq); - if (!nodeObject) - continue; - - protocol::TMIndexedObject& newObj = *reply.add_objects(); - newObj.set_hash(hash.begin(), hash.size()); - auto const& data = nodeObject->getData(); - newObj.set_data(data.data(), data.size()); - if (obj.has_nodeid()) - newObj.set_index(obj.nodeid()); - if (obj.has_ledgerseq()) - newObj.set_ledgerseq(obj.ledgerseq()); - } - - // Apply work-proportional charge. `charge()` posts the disconnect - // step (if any) back to strand_, so it is safe to call from this - // JobQueue worker thread. - charge( - // We pass `requested` directly here, instead of actual lookups done. Which could be - // std::min(packet.objects_size(), static_cast(Tuning::kHardMaxReplyNodes)); - // Because we want to charge as per the request size, to discourage large requests. - computeGetObjectByHashFee(requested, reply.objects_size()), - "processed get object by hash request"); - - JLOG(pJournal_.trace()) << "GetObj: " << reply.objects_size() << " of " << requested; - send(std::make_shared(reply, protocol::mtGET_OBJECTS)); -} - void PeerImp::onMessage(std::shared_ptr const& m) { @@ -3484,53 +3408,6 @@ PeerImp::processLedgerRequest(std::shared_ptr const& m) send(std::make_shared(ledgerData, protocol::mtLEDGER_DATA)); } -// Differential pricing helper. Returns only the *dynamic* component -// of the per-message charge — the base `kFeeModerateBurdenPeer` is -// applied at admission time in `onMessage(TMGetObjectByHash)` so a -// high traffic client pays for the message regardless of when (or -// whether) the worker runs. -// -// Dynamic charge model: -// -// billable = max(0, requested - kFreeObjectsPerRequest) -// missed = max(0, requested - found) -// billableMisses = min(missed, billable) // misses billed first -// billableHits = billable - billableMisses -// sizeBand = (requested > kBandMediumMax) ? kCostBandLarge -// : (requested > kBandSmallMax) ? kCostBandMedium -// : kCostBandSmall -// dynamic = billableHits * kCostPerLookupHit -// + billableMisses * kCostPerLookupMiss -// + sizeBand -// -// Misses are billed first against the billable budget because a node store -// seek dominates a cache hit and because invalid hashes are ~100% miss by construction. -Resource::Charge -PeerImp::computeGetObjectByHashFee(int const requested, int const found) -{ - int const billable = std::max(0, requested - static_cast(Tuning::kFreeObjectsPerRequest)); - // Clamp `missed` so a future caller passing found > requested cannot - // produce a negative value that flips the hits/misses split. - int const missed = std::max(0, requested - found); - int const billableMisses = std::min(missed, billable); - int const billableHits = billable - billableMisses; - - int sizeBand = Tuning::kCostBandSmall; - if (requested > Tuning::kBandMediumMax) - { - sizeBand = Tuning::kCostBandLarge; - } - else if (requested > Tuning::kBandSmallMax) - { - sizeBand = Tuning::kCostBandMedium; - } - - int const dynamic = (billableHits * Tuning::kCostPerLookupHit) + - (billableMisses * Tuning::kCostPerLookupMiss) + sizeBand; - - return Resource::Charge(dynamic, "GetObject differential"); -} - int PeerImp::getScore(bool haveItem) const { diff --git a/src/xrpld/overlay/detail/PeerImp.h b/src/xrpld/overlay/detail/PeerImp.h index 0085927550..a9a246452d 100644 --- a/src/xrpld/overlay/detail/PeerImp.h +++ b/src/xrpld/overlay/detail/PeerImp.h @@ -180,12 +180,6 @@ private: protocol::TMStatusChange lastStatus_; Resource::Consumer usage_; ChargeWithContext fee_; - - // One-shot guard so concurrent JobQueue workers cannot double-count - // the per-connection peer-disconnect-by-charge metric (and cannot - // post duplicate fail() calls) when several queued requests cross - // kDropThreshold before the first fail() lands on the strand. - std::atomic chargeDisconnectFired_{false}; std::shared_ptr const slot_; boost::beast::multi_buffer readBuffer_; http_request_type request_; @@ -681,70 +675,6 @@ private: void processLedgerRequest(std::shared_ptr const& m); - -protected: - // Kept `protected` so test subclasses (see - // TMGetObjectByHash_test) can drive the - // synchronous processor and the differential-pricing helper without - // routing through the JobQueue or going through `friend` plumbing. - // Production callers reach these members only via - // `onMessage(TMGetObjectByHash)` → JobQueue → `processGetObjectByHash`. - - /** - * Process a generic-query TMGetObjectByHash message. - * - * Dispatched from `onMessage(TMGetObjectByHash)` to the JobQueue - * (`JtLedgerReq`) so synchronous NodeStore lookups do not block the - * peer's I/O strand. Caps iteration at `Tuning::kHardMaxReplyNodes` - * regardless of hit/miss outcome and applies differential pricing - * via `computeGetObjectByHashFee()` after the fetch loop completes. - * - * @param m The protocol message containing requested object hashes. - */ - void - processGetObjectByHash(std::shared_ptr const& m); - - /** - * Compute the per-message resource charge for a TMGetObjectByHash - * request based on how much work was actually performed. - * - * The charge has three components on top of the base - * `Resource::kFeeModerateBurdenPeer`: - * - per-hit lookup cost (cheap; usually served from cache) - * - per-miss lookup cost (expensive node store seeks) - * - request-size band surcharge (escalates abusive batch sizes) - * - * The first `Tuning::kFreeObjectsPerRequest` objects are free so - * that legitimate `InboundLedger::getNeededHashes()` traffic - * (at most 8 objects) is unaffected. - * - * @param requested Number of objects requested by the message. This - * value is used for request-size pricing and may - * exceed `Tuning::kHardMaxReplyNodes` when this - * helper is called directly, even though processing - * caps the iterations to `Tuning::kHardMaxReplyNodes`. - * @param found Number of objects successfully returned in the - * reply. - * @return A `Resource::Charge` whose cost reflects the work performed. - */ - static Resource::Charge - computeGetObjectByHashFee(int const requested, int const found); - - /** - * Read-only accessor for the accumulated peer-message charge. - * - * Exposed at `protected` scope so test subclasses can verify the - * oversized-request rejection path (Layer 1) without invoking the - * full JobQueue handler. Production callers should never read this back — - * the value is consumed by `charge()`/`disconnect()` internally. - * - * @return The current `Resource::Charge` accumulated on `fee_`. - */ - Resource::Charge - currentFeeCharge() const - { - return fee_.fee; - } }; //------------------------------------------------------------------------------ diff --git a/src/xrpld/overlay/detail/Tuning.h b/src/xrpld/overlay/detail/Tuning.h index 5488fab07b..a27e8b56ad 100644 --- a/src/xrpld/overlay/detail/Tuning.h +++ b/src/xrpld/overlay/detail/Tuning.h @@ -1,7 +1,5 @@ #pragma once -#include - #include #include @@ -64,105 +62,4 @@ static constexpr auto kMaxQueryDepth = 3; */ constexpr std::size_t kReadBufferBytes = 16384; -/** - * TMGetObjectByHash differential pricing. - * - * Honest peers ask for at most 8 hashes per call (the header, or up to - * 4 state + 4 tx hashes from `InboundLedger::getNeededHashes()`). The - * free tier covers them at zero cost. Beyond that, each lookup is billed: - * 'misses' cost much more than 'hits' because a miss does a node store seek - * while a hit is usually served from cache. On top of that, a size-band - * surcharge kicks in for larger requests so an attacker who crams a - * single message with thousands of hashes blows past - * `Resource::kDropThreshold` and gets disconnected. - * - * The numbers below are picked to keep three things true given - * `kDropThreshold = 25000`: - * - * - Honest traffic (<= 8 objects per request) is free. - * - A single all-miss request at `kHardMaxReplyNodes` (12288) costs - * more than the drop threshold, so an attacker gets dropped in one - * message. - * - A peer spamming 1024-object hit-only requests gets dropped in - * ~19 messages — fast enough to be useful, slow enough that an - * honest peer momentarily sending oversized requests has time to - * back off. - */ - -/** - * How many objects a request can ask for before per-lookup billing - * begins? - * Twice the honest peak (8) so a peer that occasionally retries a hash - * never trips pricing. Same value as `SHAMapInnerNode::kBranchFactor`; - * that's a coincidence, not a requirement. - */ -static constexpr auto kFreeObjectsPerRequest = 16; - -/** - * Cost of one cache-hit lookup. The unit; everything else is a - * multiple of this. - */ -static constexpr auto kCostPerLookupHit = 1; - -/** - * Cost of one node-store miss, in units of `kCostPerLookupHit`. - * - * A miss does a node store disk seek; a hit usually comes from cache. - * The 8x ratio is an order-of-magnitude guess at the latency gap on - * SSD-backed nodes, not a measured number. The math only requires this - * to be at least 2 — any smaller and a full-miss request at the hard - * cap wouldn't trip the drop threshold. 8 leaves headroom: if - * `kDropThreshold` goes up or `kHardMaxReplyNodes` comes down, the - * drop-on-attack property still holds without a code change. - */ -static constexpr auto kCostPerLookupMiss = 8; - -/** - * Size-band surcharges. Whichever band a request's size falls into, - * its surcharge is added once on top of the per-lookup cost. - * - * The job of the surcharge is to make crossing a band edge feel like - * a step, not a slope. With these values, the cost roughly doubles or triples at each cliff: - * - * n=64: costs 48 => n=65 costs 149 (~3x jump) - * n=1024: costs 1108 => n=1025 costs 2009 (~2x jump) - * - * The 10x step between medium and large mirrors the ~16x step - * between the band edges (64 -> 1024) so the cliff feels comparable - * at both scales. - */ -static constexpr auto kCostBandSmall = 0; -static constexpr auto kCostBandMedium = 100; -static constexpr auto kCostBandLarge = 1000; - -/** - * How many hashes per type an honest peer asks for at a time. - * - * Matches the `4` passed to `neededStateHashes(4)` and - * `neededTxHashes(4)` in `InboundLedger::getNeededHashes()`. Kept here - * instead of imported from the ledger module so overlay stays - * self-contained; if that `4` ever changes, update this in lockstep or - * the band thresholds below will start charging honest peers. - */ -static constexpr auto kLegitHashesPerType = 4; - -/** - * Cutoffs that decide which size band a request falls into. - * - * A SHAMap inner node has 16 children; an honest peer asks for 4 - * hashes per type. So: - * - * kBandSmallMax = 4 * 16 = 64 // one inner node's worth - * kBandMediumMax = 4 * 16^2 = 1024 // a depth-2 subtree's worth - * - * A request up to 64 objects is small (no surcharge); up to 1024 is - * medium; anything larger is large. The bounds are inclusive: a - * request of exactly 64 is small, 65 is medium. Anything past 1024 is - * well beyond what the honest sync path produces, so it's billed at - * the large rate to drive attack-shaped traffic over the drop - * threshold quickly. - */ -static constexpr auto kBandSmallMax = kLegitHashesPerType * SHAMapInnerNode::kBranchFactor; -static constexpr auto kBandMediumMax = kBandSmallMax * SHAMapInnerNode::kBranchFactor; - } // namespace xrpl::Tuning From 6668b7e8d05cea0ca395031718deed55b6593d9b Mon Sep 17 00:00:00 2001 From: Bart Date: Fri, 24 Jul 2026 18:40:02 -0400 Subject: [PATCH 26/52] chore: Bump version to 3.3.0-rc4 --- src/libxrpl/protocol/BuildInfo.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libxrpl/protocol/BuildInfo.cpp b/src/libxrpl/protocol/BuildInfo.cpp index bf058a455f..56cbc1c5ce 100644 --- a/src/libxrpl/protocol/BuildInfo.cpp +++ b/src/libxrpl/protocol/BuildInfo.cpp @@ -23,7 +23,7 @@ namespace { //------------------------------------------------------------------------------ // clang-format off // NOLINTNEXTLINE(readability-identifier-naming) -char const* const versionString = "3.3.0-rc3" +char const* const versionString = "3.3.0-rc4" // clang-format on ; From e290005db5a43bc99e7a87e43c1cc337f5be2e70 Mon Sep 17 00:00:00 2001 From: Bart Date: Tue, 28 Jul 2026 14:02:49 -0400 Subject: [PATCH 27/52] fix: Re-revert "fix: Set request size limits and differential pricing for get-object-by-hash calls" --- src/test/overlay/TMGetObjectByHash_test.cpp | 18 +- src/xrpld/overlay/detail/PeerImp.cpp | 219 +++++++++++++++----- src/xrpld/overlay/detail/PeerImp.h | 70 +++++++ src/xrpld/overlay/detail/Tuning.h | 103 +++++++++ 4 files changed, 360 insertions(+), 50 deletions(-) diff --git a/src/test/overlay/TMGetObjectByHash_test.cpp b/src/test/overlay/TMGetObjectByHash_test.cpp index 961e1b7eb4..e579989181 100644 --- a/src/test/overlay/TMGetObjectByHash_test.cpp +++ b/src/test/overlay/TMGetObjectByHash_test.cpp @@ -100,6 +100,17 @@ class TMGetObjectByHash_test : public beast::unit_test::Suite return lastSentMessage_; } + // Synchronous test access to the JobQueue-dispatched processor. + // The production path runs this on JtLedgerReq; tests need a + // synchronous entry point to inspect the reply via send(). + // PeerImp::processGetObjectByHash is `protected` so the derived + // test subclass can call it directly. + void + runProcessGetObjectByHash(std::shared_ptr const& m) + { + processGetObjectByHash(m); + } + static void resetId() { @@ -179,6 +190,10 @@ class TMGetObjectByHash_test : public beast::unit_test::Suite /** * Test that reply is limited to hardMaxReplyNodes when more objects * are requested than the limit allows. + * + * `onMessage(TMGetObjectByHash)` dispatches the generic-query path + * to the JobQueue, so tests invoke the synchronous processor + * directly via `runProcessGetObjectByHash`. */ void testReplyLimit(size_t const numObjects, int const expectedReplySize) @@ -191,8 +206,7 @@ class TMGetObjectByHash_test : public beast::unit_test::Suite auto peer = createPeer(env); auto request = createRequest(numObjects, env); - // Call the onMessage handler - peer->onMessage(request); + peer->runProcessGetObjectByHash(request); // Verify that a reply was sent auto sentMessage = peer->getLastSentMessage(); diff --git a/src/xrpld/overlay/detail/PeerImp.cpp b/src/xrpld/overlay/detail/PeerImp.cpp index d5c5141317..962ab0f408 100644 --- a/src/xrpld/overlay/detail/PeerImp.cpp +++ b/src/xrpld/overlay/detail/PeerImp.cpp @@ -22,6 +22,7 @@ #include #include +#include #include #include #include @@ -80,6 +81,7 @@ #include #include +#include #include #include #include @@ -376,9 +378,19 @@ PeerImp::charge(Resource::Charge const& fee, std::string const& context) if ((self->usage_.charge(fee, context) == Resource::Disposition::Drop) && self->usage_.disconnect(self->pJournal_)) { - // Sever the connection. - self->overlay_.incPeerDisconnectCharges(); - self->fail("charge: Resources"); + // Idempotent: only the first worker to observe Drop counts the + // metric and posts fail(). Without the guard, several queued + // workers can all see Drop before fail() lands on the strand, + // overcounting peerDisconnectsCharges_ and posting duplicate + // shutdowns. fail(std::string const&) self-posts to strand_ + // when invoked off-strand. + bool expected = false; + if (self->chargeDisconnectFired_.compare_exchange_strong( + expected, true, std::memory_order_acq_rel)) + { + self->overlay_.incPeerDisconnectCharges(); + self->fail("charge: Resources"); + } } }); } @@ -2473,62 +2485,63 @@ PeerImp::onMessage(std::shared_ptr const& m) return; } - protocol::TMGetObjectByHash reply; - reply.set_query(false); - reply.set_type(packet.type()); - if (packet.has_ledgerhash()) { if (!stringIsUInt256Sized(packet.ledgerhash())) { - JLOG(pJournal_.debug()) << "GetObj: malformed ledger hash from peer " << id_; + JLOG(pJournal_.debug()) << "GetObj: malformed ledgerhash from peer " << id_; fee_.update(Resource::kFeeMalformedRequest, "get object ledger hash"); return; } - - reply.set_ledgerhash(packet.ledgerhash()); } - - fee_.update(Resource::kFeeModerateBurdenPeer, " received a get object by hash request"); - - // This is a very minimal implementation - for (int i = 0; i < packet.objects_size(); ++i) + // Reject oversized requests before touching the NodeStore. + // The legitimate upper bound (InboundLedger::getNeededHashes()) + // is 8 hashes; anything beyond kHardMaxReplyNodes is non-conforming. + if (packet.objects_size() > Tuning::kHardMaxReplyNodes) { - auto const& obj = packet.objects(i); - if (obj.has_hash() && stringIsUInt256Sized(obj.hash())) - { - uint256 const hash = uint256::fromRaw(obj.hash()); - // VFALCO TODO Move this someplace more sensible so we dont - // need to inject the NodeStore interfaces. - std::uint32_t const seq{obj.has_ledgerseq() ? obj.ledgerseq() : 0}; - auto nodeObject{app_.getNodeStore().fetchNodeObject(hash, seq)}; - if (nodeObject) - { - protocol::TMIndexedObject& newObj = *reply.add_objects(); - newObj.set_hash(hash.begin(), hash.size()); - newObj.set_data(&nodeObject->getData().front(), nodeObject->getData().size()); - - if (obj.has_nodeid()) - newObj.set_index(obj.nodeid()); - if (obj.has_ledgerseq()) - newObj.set_ledgerseq(obj.ledgerseq()); - - // Check if by adding this object, reply has reached its - // limit - if (reply.objects_size() >= Tuning::kHardMaxReplyNodes) - { - fee_.update( - Resource::kFeeModerateBurdenPeer, - "Reply limit reached. Truncating reply."); - break; - } - } - } + JLOG(pJournal_.warn()) + << "GetObj: oversized request from peer " << id_ << " (" << packet.objects_size() + << " > " << Tuning::kHardMaxReplyNodes << ")"; + fee_.update(Resource::kFeeInvalidData, "oversized get object request"); + return; } - JLOG(pJournal_.trace()) << "GetObj: " << reply.objects_size() << " of " - << packet.objects_size(); - send(std::make_shared(reply, protocol::mtGET_OBJECTS)); + // Dispatch heavy synchronous NodeStore lookups off the peer's + // I/O strand and onto the bounded job queue, mirroring the pattern + // used by processLedgerRequest. + std::weak_ptr const weak = shared_from_this(); + bool const queued = app_.getJobQueue().addJob(JtLedgerReq, "RcvGetObjByHash", [weak, m]() { + auto peer = weak.lock(); + if (!peer) + return; + try + { + peer->processGetObjectByHash(m); + } + catch (std::exception const& e) + { + // Surface backend failures (NodeStore I/O, allocation) + // back through the resource model so a misbehaving peer + // is still accountable rather than silently dropped. + JLOG(peer->pJournal_.warn()) << "GetObj: handler threw: " << e.what(); + peer->charge(Resource::kFeeRequestNoReply, "get object handler exception"); + } + }); + if (!queued) + { + // The JobQueue is no longer accepting new work (typically + // because it is shutting down / has been joined). + JLOG(pJournal_.warn()) << "GetObj: job queue refused request from peer " << id_; + return; + } + + // Admission-time charge: a peer that floods enqueues would + // otherwise be billed only the trivial onMessageEnd fee per + // message until the JobQueue catches up, re-creating an + // uncharged DoS window. Charge the base burden up-front (after + // a successful enqueue); the per-lookup differential is added + // in the worker. + fee_.update(Resource::kFeeModerateBurdenPeer, "received a get object by hash request"); } else { @@ -2584,6 +2597,69 @@ PeerImp::onMessage(std::shared_ptr const& m) } } +void +PeerImp::processGetObjectByHash(std::shared_ptr const& m) +{ + protocol::TMGetObjectByHash const& packet = *m; + + protocol::TMGetObjectByHash reply; + reply.set_query(false); + reply.set_type(packet.type()); + + if (packet.has_ledgerhash()) + { + reply.set_ledgerhash(packet.ledgerhash()); + } + + // Defense in depth: caller (onMessage) already validates cheap + // structural properties of the request before dispatching here: + // - objects_size() <= kHardMaxReplyNodes (oversize gate) + // - if has_ledgerhash() then ledgerhash is uint256-sized + // The iteration cap below mirrors the oversize gate so this method + // remains safe if invoked directly by tests or future callers, and + // a peer cannot drive unbounded NodeStore lookups by sending + // non-existent hashes. + int const requested = packet.objects_size(); + int const iterLimit = std::min(requested, Tuning::kHardMaxReplyNodes); + + for (int i = 0; i < iterLimit; ++i) + { + auto const& obj = packet.objects(i); + if (!obj.has_hash() || !stringIsUInt256Sized(obj.hash())) + continue; + + uint256 const hash = uint256::fromRaw(obj.hash()); + // VFALCO TODO Move this someplace more sensible so we don't + // need to inject the NodeStore interfaces. + std::uint32_t const seq{obj.has_ledgerseq() ? obj.ledgerseq() : 0}; + auto const nodeObject = app_.getNodeStore().fetchNodeObject(hash, seq); + if (!nodeObject) + continue; + + protocol::TMIndexedObject& newObj = *reply.add_objects(); + newObj.set_hash(hash.begin(), hash.size()); + auto const& data = nodeObject->getData(); + newObj.set_data(data.data(), data.size()); + if (obj.has_nodeid()) + newObj.set_index(obj.nodeid()); + if (obj.has_ledgerseq()) + newObj.set_ledgerseq(obj.ledgerseq()); + } + + // Apply work-proportional charge. `charge()` posts the disconnect + // step (if any) back to strand_, so it is safe to call from this + // JobQueue worker thread. + charge( + // We pass `requested` directly here, instead of actual lookups done. Which could be + // std::min(packet.objects_size(), static_cast(Tuning::kHardMaxReplyNodes)); + // Because we want to charge as per the request size, to discourage large requests. + computeGetObjectByHashFee(requested, reply.objects_size()), + "processed get object by hash request"); + + JLOG(pJournal_.trace()) << "GetObj: " << reply.objects_size() << " of " << requested; + send(std::make_shared(reply, protocol::mtGET_OBJECTS)); +} + void PeerImp::onMessage(std::shared_ptr const& m) { @@ -3408,6 +3484,53 @@ PeerImp::processLedgerRequest(std::shared_ptr const& m) send(std::make_shared(ledgerData, protocol::mtLEDGER_DATA)); } +// Differential pricing helper. Returns only the *dynamic* component +// of the per-message charge — the base `kFeeModerateBurdenPeer` is +// applied at admission time in `onMessage(TMGetObjectByHash)` so a +// high traffic client pays for the message regardless of when (or +// whether) the worker runs. +// +// Dynamic charge model: +// +// billable = max(0, requested - kFreeObjectsPerRequest) +// missed = max(0, requested - found) +// billableMisses = min(missed, billable) // misses billed first +// billableHits = billable - billableMisses +// sizeBand = (requested > kBandMediumMax) ? kCostBandLarge +// : (requested > kBandSmallMax) ? kCostBandMedium +// : kCostBandSmall +// dynamic = billableHits * kCostPerLookupHit +// + billableMisses * kCostPerLookupMiss +// + sizeBand +// +// Misses are billed first against the billable budget because a node store +// seek dominates a cache hit and because invalid hashes are ~100% miss by construction. +Resource::Charge +PeerImp::computeGetObjectByHashFee(int const requested, int const found) +{ + int const billable = std::max(0, requested - static_cast(Tuning::kFreeObjectsPerRequest)); + // Clamp `missed` so a future caller passing found > requested cannot + // produce a negative value that flips the hits/misses split. + int const missed = std::max(0, requested - found); + int const billableMisses = std::min(missed, billable); + int const billableHits = billable - billableMisses; + + int sizeBand = Tuning::kCostBandSmall; + if (requested > Tuning::kBandMediumMax) + { + sizeBand = Tuning::kCostBandLarge; + } + else if (requested > Tuning::kBandSmallMax) + { + sizeBand = Tuning::kCostBandMedium; + } + + int const dynamic = (billableHits * Tuning::kCostPerLookupHit) + + (billableMisses * Tuning::kCostPerLookupMiss) + sizeBand; + + return Resource::Charge(dynamic, "GetObject differential"); +} + int PeerImp::getScore(bool haveItem) const { diff --git a/src/xrpld/overlay/detail/PeerImp.h b/src/xrpld/overlay/detail/PeerImp.h index a9a246452d..0085927550 100644 --- a/src/xrpld/overlay/detail/PeerImp.h +++ b/src/xrpld/overlay/detail/PeerImp.h @@ -180,6 +180,12 @@ private: protocol::TMStatusChange lastStatus_; Resource::Consumer usage_; ChargeWithContext fee_; + + // One-shot guard so concurrent JobQueue workers cannot double-count + // the per-connection peer-disconnect-by-charge metric (and cannot + // post duplicate fail() calls) when several queued requests cross + // kDropThreshold before the first fail() lands on the strand. + std::atomic chargeDisconnectFired_{false}; std::shared_ptr const slot_; boost::beast::multi_buffer readBuffer_; http_request_type request_; @@ -675,6 +681,70 @@ private: void processLedgerRequest(std::shared_ptr const& m); + +protected: + // Kept `protected` so test subclasses (see + // TMGetObjectByHash_test) can drive the + // synchronous processor and the differential-pricing helper without + // routing through the JobQueue or going through `friend` plumbing. + // Production callers reach these members only via + // `onMessage(TMGetObjectByHash)` → JobQueue → `processGetObjectByHash`. + + /** + * Process a generic-query TMGetObjectByHash message. + * + * Dispatched from `onMessage(TMGetObjectByHash)` to the JobQueue + * (`JtLedgerReq`) so synchronous NodeStore lookups do not block the + * peer's I/O strand. Caps iteration at `Tuning::kHardMaxReplyNodes` + * regardless of hit/miss outcome and applies differential pricing + * via `computeGetObjectByHashFee()` after the fetch loop completes. + * + * @param m The protocol message containing requested object hashes. + */ + void + processGetObjectByHash(std::shared_ptr const& m); + + /** + * Compute the per-message resource charge for a TMGetObjectByHash + * request based on how much work was actually performed. + * + * The charge has three components on top of the base + * `Resource::kFeeModerateBurdenPeer`: + * - per-hit lookup cost (cheap; usually served from cache) + * - per-miss lookup cost (expensive node store seeks) + * - request-size band surcharge (escalates abusive batch sizes) + * + * The first `Tuning::kFreeObjectsPerRequest` objects are free so + * that legitimate `InboundLedger::getNeededHashes()` traffic + * (at most 8 objects) is unaffected. + * + * @param requested Number of objects requested by the message. This + * value is used for request-size pricing and may + * exceed `Tuning::kHardMaxReplyNodes` when this + * helper is called directly, even though processing + * caps the iterations to `Tuning::kHardMaxReplyNodes`. + * @param found Number of objects successfully returned in the + * reply. + * @return A `Resource::Charge` whose cost reflects the work performed. + */ + static Resource::Charge + computeGetObjectByHashFee(int const requested, int const found); + + /** + * Read-only accessor for the accumulated peer-message charge. + * + * Exposed at `protected` scope so test subclasses can verify the + * oversized-request rejection path (Layer 1) without invoking the + * full JobQueue handler. Production callers should never read this back — + * the value is consumed by `charge()`/`disconnect()` internally. + * + * @return The current `Resource::Charge` accumulated on `fee_`. + */ + Resource::Charge + currentFeeCharge() const + { + return fee_.fee; + } }; //------------------------------------------------------------------------------ diff --git a/src/xrpld/overlay/detail/Tuning.h b/src/xrpld/overlay/detail/Tuning.h index a27e8b56ad..5488fab07b 100644 --- a/src/xrpld/overlay/detail/Tuning.h +++ b/src/xrpld/overlay/detail/Tuning.h @@ -1,5 +1,7 @@ #pragma once +#include + #include #include @@ -62,4 +64,105 @@ static constexpr auto kMaxQueryDepth = 3; */ constexpr std::size_t kReadBufferBytes = 16384; +/** + * TMGetObjectByHash differential pricing. + * + * Honest peers ask for at most 8 hashes per call (the header, or up to + * 4 state + 4 tx hashes from `InboundLedger::getNeededHashes()`). The + * free tier covers them at zero cost. Beyond that, each lookup is billed: + * 'misses' cost much more than 'hits' because a miss does a node store seek + * while a hit is usually served from cache. On top of that, a size-band + * surcharge kicks in for larger requests so an attacker who crams a + * single message with thousands of hashes blows past + * `Resource::kDropThreshold` and gets disconnected. + * + * The numbers below are picked to keep three things true given + * `kDropThreshold = 25000`: + * + * - Honest traffic (<= 8 objects per request) is free. + * - A single all-miss request at `kHardMaxReplyNodes` (12288) costs + * more than the drop threshold, so an attacker gets dropped in one + * message. + * - A peer spamming 1024-object hit-only requests gets dropped in + * ~19 messages — fast enough to be useful, slow enough that an + * honest peer momentarily sending oversized requests has time to + * back off. + */ + +/** + * How many objects a request can ask for before per-lookup billing + * begins? + * Twice the honest peak (8) so a peer that occasionally retries a hash + * never trips pricing. Same value as `SHAMapInnerNode::kBranchFactor`; + * that's a coincidence, not a requirement. + */ +static constexpr auto kFreeObjectsPerRequest = 16; + +/** + * Cost of one cache-hit lookup. The unit; everything else is a + * multiple of this. + */ +static constexpr auto kCostPerLookupHit = 1; + +/** + * Cost of one node-store miss, in units of `kCostPerLookupHit`. + * + * A miss does a node store disk seek; a hit usually comes from cache. + * The 8x ratio is an order-of-magnitude guess at the latency gap on + * SSD-backed nodes, not a measured number. The math only requires this + * to be at least 2 — any smaller and a full-miss request at the hard + * cap wouldn't trip the drop threshold. 8 leaves headroom: if + * `kDropThreshold` goes up or `kHardMaxReplyNodes` comes down, the + * drop-on-attack property still holds without a code change. + */ +static constexpr auto kCostPerLookupMiss = 8; + +/** + * Size-band surcharges. Whichever band a request's size falls into, + * its surcharge is added once on top of the per-lookup cost. + * + * The job of the surcharge is to make crossing a band edge feel like + * a step, not a slope. With these values, the cost roughly doubles or triples at each cliff: + * + * n=64: costs 48 => n=65 costs 149 (~3x jump) + * n=1024: costs 1108 => n=1025 costs 2009 (~2x jump) + * + * The 10x step between medium and large mirrors the ~16x step + * between the band edges (64 -> 1024) so the cliff feels comparable + * at both scales. + */ +static constexpr auto kCostBandSmall = 0; +static constexpr auto kCostBandMedium = 100; +static constexpr auto kCostBandLarge = 1000; + +/** + * How many hashes per type an honest peer asks for at a time. + * + * Matches the `4` passed to `neededStateHashes(4)` and + * `neededTxHashes(4)` in `InboundLedger::getNeededHashes()`. Kept here + * instead of imported from the ledger module so overlay stays + * self-contained; if that `4` ever changes, update this in lockstep or + * the band thresholds below will start charging honest peers. + */ +static constexpr auto kLegitHashesPerType = 4; + +/** + * Cutoffs that decide which size band a request falls into. + * + * A SHAMap inner node has 16 children; an honest peer asks for 4 + * hashes per type. So: + * + * kBandSmallMax = 4 * 16 = 64 // one inner node's worth + * kBandMediumMax = 4 * 16^2 = 1024 // a depth-2 subtree's worth + * + * A request up to 64 objects is small (no surcharge); up to 1024 is + * medium; anything larger is large. The bounds are inclusive: a + * request of exactly 64 is small, 65 is medium. Anything past 1024 is + * well beyond what the honest sync path produces, so it's billed at + * the large rate to drive attack-shaped traffic over the drop + * threshold quickly. + */ +static constexpr auto kBandSmallMax = kLegitHashesPerType * SHAMapInnerNode::kBranchFactor; +static constexpr auto kBandMediumMax = kBandSmallMax * SHAMapInnerNode::kBranchFactor; + } // namespace xrpl::Tuning From 24b6dad287dd3307a2ae689e36c9d6c0c29f0fbf Mon Sep 17 00:00:00 2001 From: Mayukha Vadari Date: Wed, 29 Jul 2026 14:24:55 -0400 Subject: [PATCH 28/52] fix: Switch SponsorshipSet to use a delta for sfFeeAmount --- include/xrpl/protocol/detail/sfields.macro | 2 + .../xrpl/protocol/detail/transactions.macro | 4 +- .../transactions/SponsorshipSet.h | 42 +-- .../tx/transactors/sponsor/SponsorshipSet.h | 13 +- .../tx/transactors/sponsor/SponsorshipSet.cpp | 335 +++++++++++------- src/test/app/MPToken_test.cpp | 4 +- src/test/app/Sponsor_test.cpp | 272 ++++++++++++-- src/test/jtx/impl/sponsor.cpp | 12 +- src/test/jtx/sponsor.h | 12 +- .../transactions/SponsorshipSetTests.cpp | 60 ++-- 10 files changed, 522 insertions(+), 234 deletions(-) diff --git a/include/xrpl/protocol/detail/sfields.macro b/include/xrpl/protocol/detail/sfields.macro index 56527628c9..29e97b2371 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -238,6 +238,7 @@ TYPED_SFIELD(sfManagementFeeOutstanding, NUMBER, 17, SField::kSmdNeedsAsset // int32 TYPED_SFIELD(sfLoanScale, INT32, 1) +TYPED_SFIELD(sfRemainingOwnerCountDelta, INT32, 2) // currency amount (common) TYPED_SFIELD(sfAmount, AMOUNT, 1) @@ -277,6 +278,7 @@ TYPED_SFIELD(sfMinAccountCreateAmount, AMOUNT, 30) TYPED_SFIELD(sfLPTokenBalance, AMOUNT, 31) TYPED_SFIELD(sfFeeAmount, AMOUNT, 32) TYPED_SFIELD(sfMaxFee, AMOUNT, 33) +TYPED_SFIELD(sfFeeAmountDelta, AMOUNT, 34) // variable length (common) TYPED_SFIELD(sfPublicKey, VL, 1) diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index 6d809f7ab5..1f9603dbae 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -1189,9 +1189,9 @@ TRANSACTION(ttSPONSORSHIP_SET, 91, SponsorshipSet, ({ {sfCounterpartySponsor, SoeOptional}, {sfSponsee, SoeOptional}, - {sfFeeAmount, SoeOptional}, + {sfFeeAmountDelta, SoeOptional}, {sfMaxFee, SoeOptional}, - {sfRemainingOwnerCount, SoeOptional}, + {sfRemainingOwnerCountDelta, SoeOptional}, })) /** This system-generated transaction type is used to update the status of the various amendments. diff --git a/include/xrpl/protocol_autogen/transactions/SponsorshipSet.h b/include/xrpl/protocol_autogen/transactions/SponsorshipSet.h index 0124da5e58..dfd12a329f 100644 --- a/include/xrpl/protocol_autogen/transactions/SponsorshipSet.h +++ b/include/xrpl/protocol_autogen/transactions/SponsorshipSet.h @@ -100,29 +100,29 @@ public: } /** - * @brief Get sfFeeAmount (SoeOptional) + * @brief Get sfFeeAmountDelta (SoeOptional) * @return The field value, or std::nullopt if not present. */ [[nodiscard]] protocol_autogen::Optional - getFeeAmount() const + getFeeAmountDelta() const { - if (hasFeeAmount()) + if (hasFeeAmountDelta()) { - return this->tx_->at(sfFeeAmount); + return this->tx_->at(sfFeeAmountDelta); } return std::nullopt; } /** - * @brief Check if sfFeeAmount is present. + * @brief Check if sfFeeAmountDelta is present. * @return True if the field is present, false otherwise. */ [[nodiscard]] bool - hasFeeAmount() const + hasFeeAmountDelta() const { - return this->tx_->isFieldPresent(sfFeeAmount); + return this->tx_->isFieldPresent(sfFeeAmountDelta); } /** @@ -152,29 +152,29 @@ public: } /** - * @brief Get sfRemainingOwnerCount (SoeOptional) + * @brief Get sfRemainingOwnerCountDelta (SoeOptional) * @return The field value, or std::nullopt if not present. */ [[nodiscard]] - protocol_autogen::Optional - getRemainingOwnerCount() const + protocol_autogen::Optional + getRemainingOwnerCountDelta() const { - if (hasRemainingOwnerCount()) + if (hasRemainingOwnerCountDelta()) { - return this->tx_->at(sfRemainingOwnerCount); + return this->tx_->at(sfRemainingOwnerCountDelta); } return std::nullopt; } /** - * @brief Check if sfRemainingOwnerCount is present. + * @brief Check if sfRemainingOwnerCountDelta is present. * @return True if the field is present, false otherwise. */ [[nodiscard]] bool - hasRemainingOwnerCount() const + hasRemainingOwnerCountDelta() const { - return this->tx_->isFieldPresent(sfRemainingOwnerCount); + return this->tx_->isFieldPresent(sfRemainingOwnerCountDelta); } }; @@ -243,13 +243,13 @@ public: } /** - * @brief Set sfFeeAmount (SoeOptional) + * @brief Set sfFeeAmountDelta (SoeOptional) * @return Reference to this builder for method chaining. */ SponsorshipSetBuilder& - setFeeAmount(std::decay_t const& value) + setFeeAmountDelta(std::decay_t const& value) { - object_[sfFeeAmount] = value; + object_[sfFeeAmountDelta] = value; return *this; } @@ -265,13 +265,13 @@ public: } /** - * @brief Set sfRemainingOwnerCount (SoeOptional) + * @brief Set sfRemainingOwnerCountDelta (SoeOptional) * @return Reference to this builder for method chaining. */ SponsorshipSetBuilder& - setRemainingOwnerCount(std::decay_t const& value) + setRemainingOwnerCountDelta(std::decay_t const& value) { - object_[sfRemainingOwnerCount] = value; + object_[sfRemainingOwnerCountDelta] = value; return *this; } diff --git a/include/xrpl/tx/transactors/sponsor/SponsorshipSet.h b/include/xrpl/tx/transactors/sponsor/SponsorshipSet.h index 3310c995ae..1100c5352a 100644 --- a/include/xrpl/tx/transactors/sponsor/SponsorshipSet.h +++ b/include/xrpl/tx/transactors/sponsor/SponsorshipSet.h @@ -2,6 +2,8 @@ #include #include +#include +#include #include #include #include @@ -16,7 +18,7 @@ namespace xrpl { class SponsorshipSet : public Transactor { public: - static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Normal; + static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Custom; explicit SponsorshipSet(ApplyContext& ctx) : Transactor(ctx) { @@ -47,6 +49,15 @@ public: XRPAmount fee, ReadView const& view, beast::Journal const& j) override; + +private: + TER + createSponsorship( + Keylet const& sponsorshipKeylet, + AccountID const& sponsorID, + AccountID const& sponseeID, + SLE::ref sponsorAccSle, + SLE::ref reserveSponsorAccSle); }; } // namespace xrpl diff --git a/src/libxrpl/tx/transactors/sponsor/SponsorshipSet.cpp b/src/libxrpl/tx/transactors/sponsor/SponsorshipSet.cpp index 24bfaad2f8..e717c626e4 100644 --- a/src/libxrpl/tx/transactors/sponsor/SponsorshipSet.cpp +++ b/src/libxrpl/tx/transactors/sponsor/SponsorshipSet.cpp @@ -3,13 +3,16 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include +#include #include #include #include @@ -17,36 +20,62 @@ #include #include +#include #include +#include #include #include namespace xrpl { +// Compute the resulting RemainingOwnerCount using signed 64-bit arithmetic to +// avoid unsigned wraparound. A missing SLE (object creation) or absent field +// counts as zero. Callers handle the out-of-range results: a negative value is +// clamped to zero (field absent) and overflow is rejected in preclaim. +static std::int64_t +totalRemainingOwnerCount( + SLE::const_ref sponsorshipSle, + std::optional const& remainingOwnerCountDelta) +{ + std::uint32_t const currentCount = + sponsorshipSle ? (*sponsorshipSle)[~sfRemainingOwnerCount].value_or(0u) : 0u; + return static_cast(currentCount) + remainingOwnerCountDelta.value_or(0); +} + static bool hasSponsorshipBudget( SLE::const_ref sponsorshipSle, - std::optional const& feeAmount, - std::optional const& remainingOwnerCount) + std::optional const& feeAmountDelta, + std::optional const& remainingOwnerCountDelta) { - // A field the transaction omits keeps whatever the existing object holds, + // sfFeeAmountDelta and sfRemainingOwnerCountDelta must be non-negative when creating a new + // Sponsorship object. + if (!sponsorshipSle) + { + if (feeAmountDelta.has_value() && *feeAmountDelta <= beast::kZero) + return false; + + if (remainingOwnerCountDelta.has_value() && *remainingOwnerCountDelta <= 0) + return false; + } + // If the transaction omits a field, it keeps whatever the existing object holds, // so fall back to the current SLE value when the tx does not set it. - bool const hasFeeAmount = feeAmount - ? *feeAmount > beast::kZero - : sponsorshipSle && (*sponsorshipSle)[~sfFeeAmount].value_or(STAmount{0}) > beast::kZero; + STAmount const currentFee = + sponsorshipSle ? (*sponsorshipSle)[~sfFeeAmount].value_or(STAmount{0}) : STAmount{0}; + STAmount const newFee = currentFee + feeAmountDelta.value_or(STAmount{0}); - bool const hasRemainingOwnerCount = remainingOwnerCount - ? *remainingOwnerCount > 0 - : sponsorshipSle && (*sponsorshipSle)[~sfRemainingOwnerCount].value_or(0) > 0; + std::int64_t const newCount = + totalRemainingOwnerCount(sponsorshipSle, remainingOwnerCountDelta); - return hasFeeAmount || hasRemainingOwnerCount; + return newFee > beast::kZero || newCount > 0; } TxConsequences SponsorshipSet::makeTxConsequences(PreflightContext const& ctx) { - auto const feeAmount = ctx.tx[~sfFeeAmount]; - return TxConsequences{ctx.tx, feeAmount.has_value() ? feeAmount->xrp() : beast::kZero}; + auto const feeAmount = ctx.tx[~sfFeeAmountDelta]; + auto const feeAmountDelta = std::max(STAmount{0}, feeAmount.value_or(STAmount{0})); + return TxConsequences{ctx.tx, feeAmountDelta.xrp()}; } std::uint32_t @@ -90,8 +119,8 @@ SponsorshipSet::preflight(PreflightContext const& ctx) return temINVALID_FLAG; // Transactions deleting `Sponsorship` cannot include modification fields. - if (ctx.tx.isFieldPresent(sfFeeAmount) || ctx.tx.isFieldPresent(sfRemainingOwnerCount) || - ctx.tx.isFieldPresent(sfMaxFee)) + if (ctx.tx.isFieldPresent(sfFeeAmountDelta) || + ctx.tx.isFieldPresent(sfRemainingOwnerCountDelta) || ctx.tx.isFieldPresent(sfMaxFee)) return temMALFORMED; } else @@ -101,27 +130,26 @@ SponsorshipSet::preflight(PreflightContext const& ctx) if (account != sponsorID) return temMALFORMED; - // FeeAmount and MaxFee must be non-negative XRP amounts when present. - auto const checkOptionalAmountField = [&](SField const& field) -> NotTEC { - if (!ctx.tx.isFieldPresent(field)) - return tesSUCCESS; + // FeeAmountDelta must be a non-zero XRP amount when present. + if (auto const feeAmt = ctx.tx[~sfFeeAmountDelta]; + feeAmt && (!isXRP(*feeAmt) || *feeAmt == beast::kZero)) + return temBAD_AMOUNT; - auto const amount = ctx.tx.getFieldAmount(field); + // MaxFee must be a non-negative XRP amount when present. + if (auto const maxFee = ctx.tx[~sfMaxFee]; + maxFee && (!isXRP(*maxFee) || *maxFee < beast::kZero)) + return temBAD_AMOUNT; - if (!isXRP(amount)) - return temBAD_AMOUNT; + // RemainingOwnerCountDelta must be a non-zero integer when present. + if (auto const remainingOwnerCountDelta = ctx.tx[~sfRemainingOwnerCountDelta]; + remainingOwnerCountDelta && *remainingOwnerCountDelta == 0) + return temINVALID; - if (amount.xrp() < beast::kZero) - return temBAD_AMOUNT; - - return tesSUCCESS; - }; - - if (auto const ret = checkOptionalAmountField(sfFeeAmount); !isTesSuccess(ret)) - return ret; - - if (auto const ret = checkOptionalAmountField(sfMaxFee); !isTesSuccess(ret)) - return ret; + // nothing specified in the tx + if (!ctx.tx.isFieldPresent(sfRemainingOwnerCountDelta) && + !ctx.tx.isFieldPresent(sfFeeAmountDelta) && !ctx.tx.isFieldPresent(sfMaxFee) && + ((ctx.tx.getFlags() & tfUniversalMask) == 0)) + return temREDUNDANT; } return tesSUCCESS; @@ -154,12 +182,21 @@ SponsorshipSet::preclaim(PreclaimContext const& ctx) if (ctx.tx.isFlag(tfDeleteObject) && !sponsorshipSle) return tecNO_ENTRY; - // Reject creating or updating a Sponsorship that would be left with no - // budget (neither a positive FeeAmount nor a positive RemainingOwnerCount). - // Such an object is unusable yet still consumes the sponsor's reserve. - if (!ctx.tx.isFlag(tfDeleteObject) && - !hasSponsorshipBudget(sponsorshipSle, ctx.tx[~sfFeeAmount], ctx.tx[~sfRemainingOwnerCount])) - return tecNO_PERMISSION; + if (!ctx.tx.isFlag(tfDeleteObject)) + { + // Reject if applying the delta would overflow uint32_t. A negative delta + // that underflows is clamped to zero (field absent) rather than erroring. + if (totalRemainingOwnerCount(sponsorshipSle, ctx.tx[~sfRemainingOwnerCountDelta]) > + static_cast(std::numeric_limits::max())) + return tecLIMIT_EXCEEDED; + + // Reject creating or updating a Sponsorship that would be left with no + // budget (neither a positive FeeAmount nor a positive RemainingOwnerCount). + // Such an object is unusable yet still consumes the sponsor's reserve. + if (!hasSponsorshipBudget( + sponsorshipSle, ctx.tx[~sfFeeAmountDelta], ctx.tx[~sfRemainingOwnerCountDelta])) + return tecNO_PERMISSION; + } return tesSUCCESS; } @@ -208,6 +245,91 @@ deleteSponsorship(ApplyView& view, SLE::ref sle, beast::Journal j) return tesSUCCESS; } +TER +SponsorshipSet::createSponsorship( + Keylet const& sponsorshipKeylet, + AccountID const& sponsorID, + AccountID const& sponseeID, + SLE::ref sponsorAccSle, + SLE::ref reserveSponsorAccSle) +{ + auto const feeAmountDelta = ctx_.tx[~sfFeeAmountDelta]; + auto const maxFee = ctx_.tx[~sfMaxFee]; + auto const remainingOwnerCountDelta = ctx_.tx[~sfRemainingOwnerCountDelta]; + + bool const hasPositiveFeeAmount = feeAmountDelta.has_value() && *feeAmountDelta > beast::kZero; + + // Create a new Sponsorship object between the sponsor and sponsee. + auto newSle = std::make_shared(sponsorshipKeylet); + STAmount sponsorBalanceAfterFee = (*sponsorAccSle)[sfBalance]; + // sfFeeAmountDelta must be positive if the sponsorship object doesn't exist. This is + // checked in preclaim. + XRPL_ASSERT( + !feeAmountDelta.has_value() || *feeAmountDelta > beast::kZero, + "xrpl::SponsorshipSet::doApply : new sponsorship has positive fee amount"); + + (*newSle)[sfOwner] = sponsorID; + (*newSle)[sfSponsee] = sponseeID; + if (feeAmountDelta && feeAmountDelta->xrp() > sponsorBalanceAfterFee.xrp()) + return tecUNFUNDED; + + if (hasPositiveFeeAmount) + sponsorBalanceAfterFee -= *feeAmountDelta; + + if (auto const ret = checkReserve( + ctx_.getApplyViewContext(), + sponsorAccSle, + sponsorBalanceAfterFee.xrp(), + reserveSponsorAccSle, + {.ownerCountDelta = 1}, + ctx_.journal, + tecUNFUNDED); + !isTesSuccess(ret)) + { + return ret; + } + + if (hasPositiveFeeAmount) + { + // New object: FeeAmount starts absent, so deduct and record the full amount + (*newSle)[sfFeeAmount] = *feeAmountDelta; + (*sponsorAccSle)[sfBalance] -= *feeAmountDelta; + } + + if (maxFee && *maxFee > beast::kZero) + (*newSle)[sfMaxFee] = *maxFee; + if (remainingOwnerCountDelta && *remainingOwnerCountDelta > 0) + (*newSle)[sfRemainingOwnerCount] = *remainingOwnerCountDelta; + + std::uint32_t flags = 0; + if (ctx_.tx.isFlag(tfSponsorshipSetRequireSignForFee)) + flags |= lsfSponsorshipRequireSignForFee; + + if (ctx_.tx.isFlag(tfSponsorshipSetRequireSignForReserve)) + flags |= lsfSponsorshipRequireSignForReserve; + + (*newSle)[sfFlags] = flags; + + auto const sponsorPage = view().dirInsert( + keylet::ownerDir(sponsorID), sponsorshipKeylet, describeOwnerDir(sponsorID)); + if (!sponsorPage) + return tecDIR_FULL; // LCOV_EXCL_LINE + (*newSle)[sfOwnerNode] = *sponsorPage; + + auto const sponseePage = view().dirInsert( + keylet::ownerDir(sponseeID), sponsorshipKeylet, describeOwnerDir(sponseeID)); + if (!sponseePage) + return tecDIR_FULL; // LCOV_EXCL_LINE + (*newSle)[sfSponseeNode] = *sponseePage; + + // NOLINTNEXTLINE(readability-suspicious-call-argument) + increaseOwnerCount(view(), sponsorAccSle, reserveSponsorAccSle, 1, ctx_.journal); + addSponsorToLedgerEntry(newSle, reserveSponsorAccSle); + + ctx_.view().insert(newSle); + return tesSUCCESS; +} + TER SponsorshipSet::doApply() { @@ -224,8 +346,8 @@ SponsorshipSet::doApply() if (!ctx_.view().exists(keylet::account(sponseeID))) return tecINTERNAL; // LCOV_EXCL_LINE - auto const sponsorKeylet = keylet::sponsorship(sponsorID, sponseeID); - auto const sponsorshipSle = ctx_.view().peek(sponsorKeylet); + auto const sponsorshipKeylet = keylet::sponsorship(sponsorID, sponseeID); + auto const sponsorshipSle = ctx_.view().peek(sponsorshipKeylet); if (ctx_.tx.isFlag(tfDeleteObject)) { @@ -235,11 +357,9 @@ SponsorshipSet::doApply() return deleteSponsorship(ctx_.view(), sponsorshipSle, ctx_.journal); } - auto const feeAmount = ctx_.tx[~sfFeeAmount]; + auto const feeAmountDelta = ctx_.tx[~sfFeeAmountDelta]; auto const maxFee = ctx_.tx[~sfMaxFee]; - auto const remainingOwnerCount = ctx_.tx[~sfRemainingOwnerCount]; - - bool const hasPositiveFeeAmount = feeAmount.has_value() && *feeAmount > beast::kZero; + auto const remainingOwnerCountDelta = ctx_.tx[~sfRemainingOwnerCountDelta]; auto reserveSponsorAccSle = getTxReserveSponsor(ctx_.getApplyViewContext()); if (!reserveSponsorAccSle) @@ -247,24 +367,33 @@ SponsorshipSet::doApply() if (!sponsorshipSle) { - // Create a new Sponsorship object between the sponsor and sponsee. - auto newSle = std::make_shared(sponsorKeylet); + return createSponsorship( + sponsorshipKeylet, sponsorID, sponseeID, sponsorAccSle, *reserveSponsorAccSle); + } - (*newSle)[sfOwner] = sponsorID; - (*newSle)[sfSponsee] = sponseeID; - if (feeAmount && (*feeAmount).xrp() > (*sponsorAccSle)[sfBalance]) + // Update the existing Sponsorship object. + if (feeAmountDelta) + { + auto actualDelta = feeAmountDelta.value(); + auto const currentFee = (*sponsorshipSle)[~sfFeeAmount].valueOr(XRPAmount{0}); + + // Clamp negative delta to avoid underflow. + if (actualDelta < beast::kZero && -actualDelta > currentFee) + actualDelta = -currentFee; + // Reject if the sponsor cannot afford the (positive) delta. + if (actualDelta > beast::kZero && actualDelta > (*sponsorAccSle)[sfBalance]) return tecUNFUNDED; - STAmount sponsorBalanceAfterFee = (*sponsorAccSle)[sfBalance]; - if (hasPositiveFeeAmount) - sponsorBalanceAfterFee -= *feeAmount; + // Move the FeeAmount delta between the sponsor balance and Sponsorship + // object. + (*sponsorAccSle)[sfBalance] -= actualDelta; if (auto const ret = checkReserve( ctx_.getApplyViewContext(), sponsorAccSle, - sponsorBalanceAfterFee.xrp(), + (*sponsorAccSle)[sfBalance]->xrp(), *reserveSponsorAccSle, - {.ownerCountDelta = 1}, + {}, ctx_.journal, tecUNFUNDED); !isTesSuccess(ret)) @@ -272,87 +401,19 @@ SponsorshipSet::doApply() return ret; } - if (hasPositiveFeeAmount) + STAmount const newFee = currentFee + actualDelta; + // checked in preclaim + XRPL_ASSERT( + newFee >= beast::kZero, "xrpl::SponsorshipSet::doApply : new fee is non-negative"); + if (newFee == beast::kZero) { - // New object: FeeAmount starts absent, so deduct and record the full amount - (*newSle)[sfFeeAmount] = *feeAmount; - (*sponsorAccSle)[sfBalance] -= *feeAmount; + sponsorshipSle->makeFieldAbsent(sfFeeAmount); } - - if (maxFee && *maxFee > beast::kZero) - (*newSle)[sfMaxFee] = *maxFee; - if (remainingOwnerCount && *remainingOwnerCount > 0) - (*newSle)[sfRemainingOwnerCount] = *remainingOwnerCount; - - std::uint32_t flags = 0; - if (ctx_.tx.isFlag(tfSponsorshipSetRequireSignForFee)) - flags |= lsfSponsorshipRequireSignForFee; - - if (ctx_.tx.isFlag(tfSponsorshipSetRequireSignForReserve)) - flags |= lsfSponsorshipRequireSignForReserve; - - (*newSle)[sfFlags] = flags; - - auto const sponsorPage = view().dirInsert( - keylet::ownerDir(sponsorID), sponsorKeylet, describeOwnerDir(sponsorID)); - if (!sponsorPage) - return tecDIR_FULL; // LCOV_EXCL_LINE - (*newSle)[sfOwnerNode] = *sponsorPage; - - auto const sponseePage = view().dirInsert( - keylet::ownerDir(sponseeID), sponsorKeylet, describeOwnerDir(sponseeID)); - if (!sponseePage) - return tecDIR_FULL; // LCOV_EXCL_LINE - (*newSle)[sfSponseeNode] = *sponseePage; - - // NOLINTNEXTLINE(readability-suspicious-call-argument) - increaseOwnerCount(view(), sponsorAccSle, *reserveSponsorAccSle, 1, ctx_.journal); - addSponsorToLedgerEntry(newSle, *reserveSponsorAccSle); - - ctx_.view().insert(newSle); - return tesSUCCESS; - } - - // Update the existing Sponsorship object. - if (feeAmount) - { - auto const currentFeeAmount = (*sponsorshipSle)[~sfFeeAmount].valueOr(XRPAmount{0}); - auto const feeAmountDelta = XRPAmount(*feeAmount - currentFeeAmount); - - if (feeAmountDelta > beast::kZero && feeAmountDelta > (*sponsorAccSle)[sfBalance]) - return tecUNFUNDED; - - // Move the FeeAmount delta between the sponsor balance and Sponsorship - // object. - if (feeAmountDelta != beast::kZero) + else { - STAmount sponsorBalanceAfterFee = (*sponsorAccSle)[sfBalance]; - sponsorBalanceAfterFee -= feeAmountDelta; - - if (auto const ret = checkReserve( - ctx_.getApplyViewContext(), - sponsorAccSle, - sponsorBalanceAfterFee.xrp(), - *reserveSponsorAccSle, - {}, - ctx_.journal, - tecUNFUNDED); - !isTesSuccess(ret)) - { - return ret; - } - - (*sponsorAccSle)[sfBalance] -= feeAmountDelta; - if (*feeAmount == beast::kZero) - { - (*sponsorshipSle).makeFieldAbsent(sfFeeAmount); - } - else - { - (*sponsorshipSle).setFieldAmount(sfFeeAmount, *feeAmount); - } - ctx_.view().update(sponsorAccSle); + (*sponsorshipSle)[sfFeeAmount] = newFee; } + ctx_.view().update(sponsorAccSle); } if (maxFee) @@ -367,15 +428,21 @@ SponsorshipSet::doApply() } } - if (remainingOwnerCount) + if (remainingOwnerCountDelta) { - if (*remainingOwnerCount == 0) + std::int64_t const newCount = + totalRemainingOwnerCount(sponsorshipSle, remainingOwnerCountDelta); + // Overflow is rejected in preclaim; underflow clamps to zero (field absent). + XRPL_ASSERT( + newCount <= static_cast(std::numeric_limits::max()), + "xrpl::SponsorshipSet::doApply : RemainingOwnerCount does not overflow"); + if (newCount <= 0) { sponsorshipSle->makeFieldAbsent(sfRemainingOwnerCount); } else { - sponsorshipSle->at(sfRemainingOwnerCount) = *remainingOwnerCount; + sponsorshipSle->at(sfRemainingOwnerCount) = static_cast(newCount); } } diff --git a/src/test/app/MPToken_test.cpp b/src/test/app/MPToken_test.cpp index f3f967af81..c9adce0305 100644 --- a/src/test/app/MPToken_test.cpp +++ b/src/test/app/MPToken_test.cpp @@ -2116,8 +2116,8 @@ class MPToken_test : public beast::unit_test::Suite jv[jss::TransactionType] = jss::SponsorshipSet; jv[jss::Account] = alice.human(); jv[sfSponsee.fieldName] = carol.human(); - jv[sfFeeAmount.fieldName] = mpt.getJson(JsonOptions::Values::None); - test(jv, sfFeeAmount.fieldName); + jv[sfFeeAmountDelta.fieldName] = mpt.getJson(JsonOptions::Values::None); + test(jv, sfFeeAmountDelta.fieldName); } } BEAST_EXPECT(txWithAmounts.empty()); diff --git a/src/test/app/Sponsor_test.cpp b/src/test/app/Sponsor_test.cpp index 1012a7d0b6..5ddc3aa6a8 100644 --- a/src/test/app/Sponsor_test.cpp +++ b/src/test/app/Sponsor_test.cpp @@ -58,6 +58,7 @@ #include #include +#include #include #include #include @@ -197,10 +198,12 @@ public: sponsor::SponseeAcc(alice), Ter(temMALFORMED)); - // Invalid feeAmount - for (auto const& amt : {XRP(-1), usd(1)}) + // Invalid FeeAmountDelta + for (auto const& amt : {XRP(0), usd(1)}) { - env(sponsor::set_fee(sponsor, 0, amt), sponsor::SponseeAcc(alice), Ter(temBAD_AMOUNT)); + env(sponsor::set_fee(sponsor, 0, amt, XRP(1)), + sponsor::SponseeAcc(alice), + Ter(temBAD_AMOUNT)); } // Invalid MaxFee for (auto const& amt : {XRP(-1), usd(1)}) @@ -209,6 +212,10 @@ public: sponsor::SponseeAcc(alice), Ter(temBAD_AMOUNT)); } + // Invalid RemainingOwnerCountDelta + env(sponsor::set(sponsor, 0, 0, XRP(2), XRP(1)), + sponsor::SponseeAcc(alice), + Ter(temINVALID)); // Invalid Delete operation env(sponsor::set_reserve(sponsor, tfDeleteObject, 1), @@ -229,12 +236,15 @@ public: sponsor::CounterpartySponsor(alice), Ter(temMALFORMED)); + // Redundant tx + env(sponsor::set(sponsor, 0), sponsor::SponseeAcc(alice), Ter(temREDUNDANT)); + // // preclaim // // Invalid Sponsee - env(sponsor::set(sponsor, 0), sponsor::SponseeAcc(noFunded), Ter(tecNO_DST)); + env(sponsor::set(sponsor, 0, 1), sponsor::SponseeAcc(noFunded), Ter(tecNO_DST)); env.close(); // Invalid Sponsor @@ -290,7 +300,7 @@ public: // Decreasing feeAmount should succeed (refund, negative delta) adjustAccountXRPBalance(env, sponsor, XRP(500)); - env(sponsor::set_fee(sponsor, 0, XRP(800)), + env(sponsor::set_fee(sponsor, 0, XRP(-200)), sponsor::SponseeAcc(alice), Fee(XRP(1)), Ter(tesSUCCESS)); @@ -299,7 +309,7 @@ public: // Increasing feeAmount within delta budget should succeed adjustAccountXRPBalance(env, sponsor, XRP(500)); - env(sponsor::set_fee(sponsor, 0, XRP(850)), + env(sponsor::set_fee(sponsor, 0, XRP(50)), sponsor::SponseeAcc(alice), Fee(XRP(1)), Ter(tesSUCCESS)); @@ -308,18 +318,15 @@ public: // Increasing feeAmount where delta exceeds balance should fail adjustAccountXRPBalance(env, sponsor, XRP(310)); - env(sponsor::set_fee(sponsor, 0, XRP(1200)), + env(sponsor::set_fee(sponsor, 0, XRP(350)), sponsor::SponseeAcc(alice), Fee(XRP(1)), Ter(tecUNFUNDED)); env.close(); // Increasing feeAmount to reach insufficient reserve - auto const currentFeeAmount = env.le(keylet::sponsorship(sponsor.id(), alice.id())) - ->getFieldAmount(sfFeeAmount) - .xrp(); adjustAccountXRPBalance(env, sponsor, XRP(310)); - env(sponsor::set_fee(sponsor, 0, currentFeeAmount + XRP(309)), + env(sponsor::set_fee(sponsor, 0, XRP(309)), sponsor::SponseeAcc(alice), Fee(XRP(1)), Ter(tecUNFUNDED)); @@ -543,7 +550,7 @@ public: BEAST_EXPECT(env.balance(sponsor) == XRP(10000) - sle->at(sfFeeAmount) - XRP(1)); // update sponsorship (decrement) - env(sponsor::set(sponsor, 0, 50, XRP(50), XRP(0.5)), + env(sponsor::set(sponsor, 0, -50, XRP(-50), XRP(0.5)), sponsor::SponseeAcc(alice), Fee(XRP(1)), Ter(tesSUCCESS)); @@ -557,7 +564,7 @@ public: BEAST_EXPECT(env.balance(sponsor) == XRP(10000) - sle->at(sfFeeAmount) - XRP(2)); // update sponsorship (increment) - env(sponsor::set(sponsor, 0, 200, XRP(200), XRP(2)), + env(sponsor::set(sponsor, 0, 150, XRP(150), XRP(2)), sponsor::SponseeAcc(alice), Fee(XRP(1)), Ter(tesSUCCESS)); @@ -591,26 +598,32 @@ public: env.close(); BEAST_EXPECT(!env.le(keylet::sponsorship(sponsor, alice))); - // Cannot create sponsorship with no fee or reserve budget. MaxFee - // and flags do not make a sponsorship object useful by themselves. - env(sponsor::set(sponsor, 0), sponsor::SponseeAcc(alice), Ter(tecNO_PERMISSION)); - env.close(); - BEAST_EXPECT(!env.le(keylet::sponsorship(sponsor, alice))); - env(sponsor::set_max_fee(sponsor, 0, XRP(1)), sponsor::SponseeAcc(alice), Ter(tecNO_PERMISSION)); env.close(); BEAST_EXPECT(!env.le(keylet::sponsorship(sponsor, alice))); - env(sponsor::set(sponsor, 0, 0, XRP(0), XRP(0)), + env(sponsor::set(sponsor, 0, std::nullopt, std::nullopt, XRP(0)), sponsor::SponseeAcc(alice), Ter(tecNO_PERMISSION)); env.close(); BEAST_EXPECT(!env.le(keylet::sponsorship(sponsor, alice))); - // update sponsorship with non-zero value - env(sponsor::set(sponsor, 0, 100, XRP(100), XRP(1)), + // create sponsorship with negative values + env(sponsor::set_reserve(sponsor, 0, -100), + sponsor::SponseeAcc(alice), + Ter(tecNO_PERMISSION)); + env.close(); + BEAST_EXPECT(!env.le(keylet::sponsorship(sponsor, alice))); + env(sponsor::set_fee(sponsor, 0, XRP(-100)), + sponsor::SponseeAcc(alice), + Ter(tecNO_PERMISSION)); + env.close(); + BEAST_EXPECT(!env.le(keylet::sponsorship(sponsor, alice))); + + // create sponsorship with non-zero value + env(sponsor::set(sponsor, 0, 100, XRP(101), XRP(1)), sponsor::SponseeAcc(alice), Fee(XRP(1))); env.close(); @@ -618,7 +631,7 @@ public: sle = env.le(keylet::sponsorship(sponsor, alice)); BEAST_EXPECT(sle); BEAST_EXPECT(sle->at(sfRemainingOwnerCount) == 100); - BEAST_EXPECT(sle->at(sfFeeAmount) == XRP(100)); + BEAST_EXPECT(sle->at(sfFeeAmount) == XRP(101)); BEAST_EXPECT(sle->at(sfMaxFee) == XRP(1)); // update sponsorship flags @@ -648,7 +661,7 @@ public: lsfSponsorshipRequireSignForReserve); // Cannot update sponsorship so both fee and reserve budgets are absent. - env(sponsor::set(sponsor, 0, 0, XRP(0), XRP(0)), + env(sponsor::set(sponsor, 0, -100, XRP(-101), std::nullopt), sponsor::SponseeAcc(alice), Fee(XRP(1)), Ter(tecNO_PERMISSION)); @@ -657,17 +670,17 @@ public: sle = env.le(keylet::sponsorship(sponsor, alice)); BEAST_EXPECT(sle); BEAST_EXPECT(sle->at(sfRemainingOwnerCount) == 100); - BEAST_EXPECT(sle->at(sfFeeAmount) == XRP(100)); + BEAST_EXPECT(sle->at(sfFeeAmount) == XRP(101)); BEAST_EXPECT(sle->at(sfMaxFee) == XRP(1)); } { // Removing one budget field while the other remains keeps the // Sponsorship valid. Starting state (from above): - // RemainingOwnerCount = 100, FeeAmount = XRP(100). + // RemainingOwnerCount = 100, FeeAmount = XRP(101). // Remove only FeeAmount (set to 0); RemainingOwnerCount remains. - env(sponsor::set_fee(sponsor, 0, XRP(0)), + env(sponsor::set_fee(sponsor, 0, XRP(-101)), sponsor::SponseeAcc(alice), Fee(XRP(1)), Ter(tesSUCCESS)); @@ -686,12 +699,51 @@ public: Ter(tesSUCCESS)); env.close(); - env(sponsor::set_reserve(sponsor, 0, 0), + // A negative FeeAmountDelta larger than the current FeeAmount is + // clamped, so only the current FeeAmount is refunded and the field + // is removed. RemainingOwnerCount keeps the Sponsorship valid. + auto const balanceBefore = env.balance(sponsor); + env(sponsor::set_fee(sponsor, 0, XRP(-500)), sponsor::SponseeAcc(alice), Fee(XRP(1)), Ter(tesSUCCESS)); env.close(); + sle = env.le(keylet::sponsorship(sponsor, alice)); + BEAST_EXPECT(sle); + BEAST_EXPECT(!sle->isFieldPresent(sfFeeAmount)); + BEAST_EXPECT(sle->at(sfRemainingOwnerCount) == 100); + BEAST_EXPECT(env.balance(sponsor) == balanceBefore + XRP(100) - XRP(1)); + + // Restore FeeAmount for the checks below. + env(sponsor::set_fee(sponsor, 0, XRP(100)), + sponsor::SponseeAcc(alice), + Fee(XRP(1)), + Ter(tesSUCCESS)); + env.close(); + + env(sponsor::set_reserve(sponsor, 0, -100), + sponsor::SponseeAcc(alice), + Fee(XRP(1)), + Ter(tesSUCCESS)); + env.close(); + + sle = env.le(keylet::sponsorship(sponsor, alice)); + BEAST_EXPECT(sle); + BEAST_EXPECT(!sle->isFieldPresent(sfRemainingOwnerCount)); + BEAST_EXPECT(sle->at(sfFeeAmount) == XRP(100)); + + // Decreasing FeeAmount below zero must fail with tecNO_PERMISSION + // when there is no RemainingOwnerCount (the budget would become + // entirely empty). Current state: FeeAmount = XRP(100), no + // RemainingOwnerCount. + env(sponsor::set_fee(sponsor, 0, XRP(-101)), + sponsor::SponseeAcc(alice), + Fee(XRP(1)), + Ter(tecNO_PERMISSION)); + env.close(); + + // Confirm that the sponsorship is unchanged. sle = env.le(keylet::sponsorship(sponsor, alice)); BEAST_EXPECT(sle); BEAST_EXPECT(!sle->isFieldPresent(sfRemainingOwnerCount)); @@ -748,6 +800,160 @@ public: } } + void + testRemainingOwnerCountOverflow() + { + testcase("RemainingOwnerCount overflow and underflow clamping"); + using namespace test::jtx; + Env env{*this, testableAmendments()}; + Account const alice("alice"); + Account const sponsor("sponsor"); + env.fund(XRP(10000), alice, sponsor); + env.close(); + + constexpr std::int32_t kInt32Max = std::numeric_limits::max(); + + // --- Positive overflow: delta causes count to exceed UINT32_MAX --- + { + // Create with count = INT32_MAX. + env(sponsor::set_reserve(sponsor, 0, kInt32Max), + sponsor::SponseeAcc(alice), + Ter(tesSUCCESS)); + env.close(); + BEAST_EXPECT( + env.le(keylet::sponsorship(sponsor, alice))->at(sfRemainingOwnerCount) == + static_cast(kInt32Max)); + + // Add INT32_MAX again: count = 2 * INT32_MAX = 4294967294 (<= UINT32_MAX, still ok). + env(sponsor::set_reserve(sponsor, 0, kInt32Max), + sponsor::SponseeAcc(alice), + Ter(tesSUCCESS)); + env.close(); + BEAST_EXPECT( + env.le(keylet::sponsorship(sponsor, alice))->at(sfRemainingOwnerCount) == + 2u * static_cast(kInt32Max)); + + // Adding 2 more pushes count to 4294967296, exceeding UINT32_MAX: reject. + env(sponsor::set_reserve(sponsor, 0, 2), + sponsor::SponseeAcc(alice), + Ter(tecLIMIT_EXCEEDED)); + env.close(); + + // SLE is unchanged. + BEAST_EXPECT( + env.le(keylet::sponsorship(sponsor, alice))->at(sfRemainingOwnerCount) == + 2u * static_cast(kInt32Max)); + + env(sponsor::del(sponsor), sponsor::SponseeAcc(alice), Ter(tesSUCCESS)); + env.close(); + } + + // --- Negative underflow: clamps to 0; fee budget survives --- + { + // Create with count=10 and a fee budget. + env(sponsor::set(sponsor, 0, 10, XRP(100)), + sponsor::SponseeAcc(alice), + Ter(tesSUCCESS)); + env.close(); + BEAST_EXPECT( + env.le(keylet::sponsorship(sponsor, alice))->at(sfRemainingOwnerCount) == 10u); + + // Delta of -20 produces count = -10; clamps to 0 (field absent). + env(sponsor::set_reserve(sponsor, 0, -20), sponsor::SponseeAcc(alice), Ter(tesSUCCESS)); + env.close(); + + auto sle = env.le(keylet::sponsorship(sponsor, alice)); + BEAST_EXPECT(sle); + BEAST_EXPECT(!sle->isFieldPresent(sfRemainingOwnerCount)); + BEAST_EXPECT(sle->at(sfFeeAmount) == XRP(100)); + + env(sponsor::del(sponsor), sponsor::SponseeAcc(alice), Ter(tesSUCCESS)); + env.close(); + } + + // --- Negative underflow: clamped count=0 with no fee budget → no budget --- + { + // Create with count=10, no fee. + env(sponsor::set_reserve(sponsor, 0, 10), sponsor::SponseeAcc(alice), Ter(tesSUCCESS)); + env.close(); + BEAST_EXPECT( + env.le(keylet::sponsorship(sponsor, alice))->at(sfRemainingOwnerCount) == 10u); + + // Delta of -20 would clamp count to 0 with no fee → empty budget → tecNO_PERMISSION. + env(sponsor::set_reserve(sponsor, 0, -20), + sponsor::SponseeAcc(alice), + Ter(tecNO_PERMISSION)); + env.close(); + + // SLE is unchanged. + BEAST_EXPECT( + env.le(keylet::sponsorship(sponsor, alice))->at(sfRemainingOwnerCount) == 10u); + + env(sponsor::del(sponsor), sponsor::SponseeAcc(alice), Ter(tesSUCCESS)); + env.close(); + } + } + + void + testConsequences() + { + testcase("Consequences"); + using namespace test::jtx; + Env env{*this, testableAmendments()}; + auto const baseFee = env.current()->fees().base; + + Account const alice("alice"); + Account const sponsor("sponsor"); + env.memoize(alice); + env.memoize(sponsor); + + { + // A positive FeeAmountDelta is the maximum XRP the tx can spend. + auto const jt = env.jt( + sponsor::set_fee(sponsor, 0, XRP(100)), + sponsor::SponseeAcc(alice), + Seq(1), + Fee(baseFee)); + auto const pf = + preflight(env.app(), env.current()->rules(), *jt.stx, TapNone, env.journal); + BEAST_EXPECT(isTesSuccess(pf.ter)); + BEAST_EXPECT(!pf.consequences.isBlocker()); + BEAST_EXPECT(pf.consequences.fee() == drops(baseFee)); + BEAST_EXPECT(pf.consequences.potentialSpend() == XRP(100)); + } + + { + // A negative FeeAmountDelta withdraws from the sponsorship, so the + // transaction cannot spend anything. + auto const jt = env.jt( + sponsor::set_fee(sponsor, 0, XRP(-100)), + sponsor::SponseeAcc(alice), + Seq(1), + Fee(baseFee)); + auto const pf = + preflight(env.app(), env.current()->rules(), *jt.stx, TapNone, env.journal); + BEAST_EXPECT(isTesSuccess(pf.ter)); + BEAST_EXPECT(!pf.consequences.isBlocker()); + BEAST_EXPECT(pf.consequences.fee() == drops(baseFee)); + BEAST_EXPECT(pf.consequences.potentialSpend() == XRP(0)); + } + + { + // No FeeAmountDelta at all. + auto const jt = env.jt( + sponsor::set_reserve(sponsor, 0, 10), + sponsor::SponseeAcc(alice), + Seq(1), + Fee(baseFee)); + auto const pf = + preflight(env.app(), env.current()->rules(), *jt.stx, TapNone, env.journal); + BEAST_EXPECT(isTesSuccess(pf.ter)); + BEAST_EXPECT(!pf.consequences.isBlocker()); + BEAST_EXPECT(pf.consequences.fee() == drops(baseFee)); + BEAST_EXPECT(pf.consequences.potentialSpend() == XRP(0)); + } + } + void testPreFundAndCosign() { @@ -810,7 +1016,7 @@ public: Ter(terINSUF_FEE_B)); env.close(); - env(sponsor::set_reserve(sponsor, 0, 0), sponsor::SponseeAcc(alice), Ter(tesSUCCESS)); + env(sponsor::set_reserve(sponsor, 0, -1), sponsor::SponseeAcc(alice), Ter(tesSUCCESS)); env.close(); // reserve insufficient @@ -2090,7 +2296,7 @@ public: XRP(10)); // clear flag - env(sponsor::set_fee(sponsor, tfSponsorshipClearRequireSignForFee, XRP(10)), + env(sponsor::set(sponsor, tfSponsorshipClearRequireSignForFee), sponsor::SponseeAcc(alice)); env.close(); @@ -2322,7 +2528,7 @@ public: XRP(10)); // clear flag - env(sponsor::set_fee(sponsor, tfSponsorshipClearRequireSignForFee, XRP(10)), + env(sponsor::set(sponsor, tfSponsorshipClearRequireSignForFee), sponsor::SponseeAcc(alice)); env.close(); @@ -4939,7 +5145,7 @@ public: env.close(); // Create pre-funded sponsorship - env(sponsor::set(sponsor, 0, 0, XRP(1)), sponsor::SponseeAcc(alice), Fee(XRP(1))); + env(sponsor::set_fee(sponsor, 0, XRP(1)), sponsor::SponseeAcc(alice), Fee(XRP(1))); env.close(); auto const seq = env.seq(alice); @@ -5443,6 +5649,8 @@ protected: testInvalidSponsorField(); testSimpleSponsorshipSet(); + testRemainingOwnerCountOverflow(); + testConsequences(); testPreFundAndCosign(); testSponsoredFreeTierReserve(); diff --git a/src/test/jtx/impl/sponsor.cpp b/src/test/jtx/impl/sponsor.cpp index cdf68800f5..453ccebcb9 100644 --- a/src/test/jtx/impl/sponsor.cpp +++ b/src/test/jtx/impl/sponsor.cpp @@ -21,18 +21,18 @@ namespace xrpl::test::jtx::sponsor { json::Value set(jtx::Account const& account, uint32_t flags, - std::optional const reserveCount, - std::optional const feeAmount, + std::optional const reserveCountDelta, + std::optional const feeAmountDelta, std::optional const maxFee) { json::Value jv; jv[jss::TransactionType] = jss::SponsorshipSet; jv[jss::Account] = account.human(); jv[sfFlags.jsonName] = flags; - if (reserveCount) - jv[sfRemainingOwnerCount.jsonName] = *reserveCount; - if (feeAmount) - jv[sfFeeAmount.jsonName] = feeAmount->getJson(JsonOptions::Values::None); + if (reserveCountDelta) + jv[sfRemainingOwnerCountDelta.jsonName] = *reserveCountDelta; + if (feeAmountDelta) + jv[sfFeeAmountDelta.jsonName] = feeAmountDelta->getJson(JsonOptions::Values::None); if (maxFee) jv[sfMaxFee.jsonName] = maxFee->getJson(JsonOptions::Values::None); return jv; diff --git a/src/test/jtx/sponsor.h b/src/test/jtx/sponsor.h index 43d55d7246..f87a13c462 100644 --- a/src/test/jtx/sponsor.h +++ b/src/test/jtx/sponsor.h @@ -18,24 +18,24 @@ namespace xrpl::test::jtx::sponsor { json::Value set(jtx::Account const& account, std::uint32_t flags, - std::optional const reserveCount = std::nullopt, - std::optional const feeAmount = std::nullopt, + std::optional const reserveCountDelta = std::nullopt, + std::optional const feeAmountDelta = std::nullopt, std::optional const maxFee = std::nullopt); inline json::Value set_fee( jtx::Account const& account, std::uint32_t flags, - STAmount feeAmount, + STAmount feeAmountDelta, std::optional maxFee = std::nullopt) { - return set(account, flags, std::nullopt, std::move(feeAmount), std::move(maxFee)); + return set(account, flags, std::nullopt, std::move(feeAmountDelta), std::move(maxFee)); } inline json::Value -set_reserve(jtx::Account const& account, std::uint32_t flags, std::uint32_t reserveCount) +set_reserve(jtx::Account const& account, std::uint32_t flags, std::int32_t reserveCountDelta) { - return set(account, flags, reserveCount); + return set(account, flags, reserveCountDelta); } inline json::Value diff --git a/src/tests/libxrpl/protocol_autogen/transactions/SponsorshipSetTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/SponsorshipSetTests.cpp index dce8cfca3f..c5bc41c6e6 100644 --- a/src/tests/libxrpl/protocol_autogen/transactions/SponsorshipSetTests.cpp +++ b/src/tests/libxrpl/protocol_autogen/transactions/SponsorshipSetTests.cpp @@ -31,9 +31,9 @@ TEST(TransactionsSponsorshipSetTests, BuilderSettersRoundTrip) // Transaction-specific field values auto const counterpartySponsorValue = canonical_ACCOUNT(); auto const sponseeValue = canonical_ACCOUNT(); - auto const feeAmountValue = canonical_AMOUNT(); + auto const feeAmountDeltaValue = canonical_AMOUNT(); auto const maxFeeValue = canonical_AMOUNT(); - auto const remainingOwnerCountValue = canonical_UINT32(); + auto const remainingOwnerCountDeltaValue = canonical_INT32(); SponsorshipSetBuilder builder{ accountValue, @@ -44,9 +44,9 @@ TEST(TransactionsSponsorshipSetTests, BuilderSettersRoundTrip) // Set optional fields builder.setCounterpartySponsor(counterpartySponsorValue); builder.setSponsee(sponseeValue); - builder.setFeeAmount(feeAmountValue); + builder.setFeeAmountDelta(feeAmountDeltaValue); builder.setMaxFee(maxFeeValue); - builder.setRemainingOwnerCount(remainingOwnerCountValue); + builder.setRemainingOwnerCountDelta(remainingOwnerCountDeltaValue); auto tx = builder.build(publicKey, secretKey); @@ -81,11 +81,11 @@ TEST(TransactionsSponsorshipSetTests, BuilderSettersRoundTrip) } { - auto const& expected = feeAmountValue; - auto const actualOpt = tx.getFeeAmount(); - ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfFeeAmount should be present"; - expectEqualField(expected, *actualOpt, "sfFeeAmount"); - EXPECT_TRUE(tx.hasFeeAmount()); + auto const& expected = feeAmountDeltaValue; + auto const actualOpt = tx.getFeeAmountDelta(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfFeeAmountDelta should be present"; + expectEqualField(expected, *actualOpt, "sfFeeAmountDelta"); + EXPECT_TRUE(tx.hasFeeAmountDelta()); } { @@ -97,11 +97,11 @@ TEST(TransactionsSponsorshipSetTests, BuilderSettersRoundTrip) } { - auto const& expected = remainingOwnerCountValue; - auto const actualOpt = tx.getRemainingOwnerCount(); - ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfRemainingOwnerCount should be present"; - expectEqualField(expected, *actualOpt, "sfRemainingOwnerCount"); - EXPECT_TRUE(tx.hasRemainingOwnerCount()); + auto const& expected = remainingOwnerCountDeltaValue; + auto const actualOpt = tx.getRemainingOwnerCountDelta(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfRemainingOwnerCountDelta should be present"; + expectEqualField(expected, *actualOpt, "sfRemainingOwnerCountDelta"); + EXPECT_TRUE(tx.hasRemainingOwnerCountDelta()); } } @@ -122,9 +122,9 @@ TEST(TransactionsSponsorshipSetTests, BuilderFromStTxRoundTrip) // Transaction-specific field values auto const counterpartySponsorValue = canonical_ACCOUNT(); auto const sponseeValue = canonical_ACCOUNT(); - auto const feeAmountValue = canonical_AMOUNT(); + auto const feeAmountDeltaValue = canonical_AMOUNT(); auto const maxFeeValue = canonical_AMOUNT(); - auto const remainingOwnerCountValue = canonical_UINT32(); + auto const remainingOwnerCountDeltaValue = canonical_INT32(); // Build an initial transaction SponsorshipSetBuilder initialBuilder{ @@ -135,9 +135,9 @@ TEST(TransactionsSponsorshipSetTests, BuilderFromStTxRoundTrip) initialBuilder.setCounterpartySponsor(counterpartySponsorValue); initialBuilder.setSponsee(sponseeValue); - initialBuilder.setFeeAmount(feeAmountValue); + initialBuilder.setFeeAmountDelta(feeAmountDeltaValue); initialBuilder.setMaxFee(maxFeeValue); - initialBuilder.setRemainingOwnerCount(remainingOwnerCountValue); + initialBuilder.setRemainingOwnerCountDelta(remainingOwnerCountDeltaValue); auto initialTx = initialBuilder.build(publicKey, secretKey); @@ -171,10 +171,10 @@ TEST(TransactionsSponsorshipSetTests, BuilderFromStTxRoundTrip) } { - auto const& expected = feeAmountValue; - auto const actualOpt = rebuiltTx.getFeeAmount(); - ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfFeeAmount should be present"; - expectEqualField(expected, *actualOpt, "sfFeeAmount"); + auto const& expected = feeAmountDeltaValue; + auto const actualOpt = rebuiltTx.getFeeAmountDelta(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfFeeAmountDelta should be present"; + expectEqualField(expected, *actualOpt, "sfFeeAmountDelta"); } { @@ -185,10 +185,10 @@ TEST(TransactionsSponsorshipSetTests, BuilderFromStTxRoundTrip) } { - auto const& expected = remainingOwnerCountValue; - auto const actualOpt = rebuiltTx.getRemainingOwnerCount(); - ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfRemainingOwnerCount should be present"; - expectEqualField(expected, *actualOpt, "sfRemainingOwnerCount"); + auto const& expected = remainingOwnerCountDeltaValue; + auto const actualOpt = rebuiltTx.getRemainingOwnerCountDelta(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfRemainingOwnerCountDelta should be present"; + expectEqualField(expected, *actualOpt, "sfRemainingOwnerCountDelta"); } } @@ -250,12 +250,12 @@ TEST(TransactionsSponsorshipSetTests, OptionalFieldsReturnNullopt) EXPECT_FALSE(tx.getCounterpartySponsor().has_value()); EXPECT_FALSE(tx.hasSponsee()); EXPECT_FALSE(tx.getSponsee().has_value()); - EXPECT_FALSE(tx.hasFeeAmount()); - EXPECT_FALSE(tx.getFeeAmount().has_value()); + EXPECT_FALSE(tx.hasFeeAmountDelta()); + EXPECT_FALSE(tx.getFeeAmountDelta().has_value()); EXPECT_FALSE(tx.hasMaxFee()); EXPECT_FALSE(tx.getMaxFee().has_value()); - EXPECT_FALSE(tx.hasRemainingOwnerCount()); - EXPECT_FALSE(tx.getRemainingOwnerCount().has_value()); + EXPECT_FALSE(tx.hasRemainingOwnerCountDelta()); + EXPECT_FALSE(tx.getRemainingOwnerCountDelta().has_value()); } } From ccb9db0bc71553a2bac85fc8fa1c37d6de553cb3 Mon Sep 17 00:00:00 2001 From: Bart Date: Wed, 29 Jul 2026 14:25:13 -0400 Subject: [PATCH 29/52] chore: Bump version to 3.3.0-rc5 --- src/libxrpl/protocol/BuildInfo.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libxrpl/protocol/BuildInfo.cpp b/src/libxrpl/protocol/BuildInfo.cpp index 56cbc1c5ce..6bb2c40cff 100644 --- a/src/libxrpl/protocol/BuildInfo.cpp +++ b/src/libxrpl/protocol/BuildInfo.cpp @@ -23,7 +23,7 @@ namespace { //------------------------------------------------------------------------------ // clang-format off // NOLINTNEXTLINE(readability-identifier-naming) -char const* const versionString = "3.3.0-rc4" +char const* const versionString = "3.3.0-rc5" // clang-format on ; From 3ad6ce236eeeb72fd1208c8e225eedcca9b798c6 Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Thu, 30 Jul 2026 16:29:37 +0100 Subject: [PATCH 30/52] feat: Package validator-keys inside rippled --- .cspell.config.yaml | 3 ++ .github/scripts/strategy-matrix/generate.py | 10 +++- .github/scripts/strategy-matrix/linux.json | 6 ++- .../workflows/reusable-build-test-config.yml | 19 ++++++- .github/workflows/reusable-package.yml | 26 +++++---- CMakeLists.txt | 4 +- cmake/PatchNixBinary.cmake | 47 ++++++++++++---- cmake/XrplPackaging.cmake | 15 +++++- cmake/XrplValidatorKeys.cmake | 33 +++++++++--- package/README.md | 53 +++++++++++++------ package/build_pkg.sh | 48 ++++++++++++++--- package/debian/control | 8 +-- package/debian/copyright | 19 +++++++ package/debian/rules | 1 + package/debian/xrpld.docs | 1 + package/rpm/xrpld.spec | 7 +++ 16 files changed, 238 insertions(+), 62 deletions(-) diff --git a/.cspell.config.yaml b/.cspell.config.yaml index 13da132b90..78ba979948 100644 --- a/.cspell.config.yaml +++ b/.cspell.config.yaml @@ -130,6 +130,7 @@ words: - godexsoft - gpgcheck - gpgkey + - Hinnant - hotwallet - hwaddress - hwrap @@ -163,6 +164,7 @@ words: - llection - LOCALGOOD - logwstream + - Lombrozo - lseq - lsmf - ltype @@ -200,6 +202,7 @@ words: - nftokens - nftpage - nikb + - Nikolaos - nixfmt - nixos - nixpkgs diff --git a/.github/scripts/strategy-matrix/generate.py b/.github/scripts/strategy-matrix/generate.py index c783f32fb7..b0a7b4b321 100755 --- a/.github/scripts/strategy-matrix/generate.py +++ b/.github/scripts/strategy-matrix/generate.py @@ -136,7 +136,8 @@ class MatrixEntry: class PackagingEntry: """One entry in the generated packaging strategy matrix.""" - artifact_name: str + xrpld_artifact_name: str + validator_keys_artifact_name: str image: str distro: str # e.g. "debian" or "rhel"; drives package-format-specific steps @@ -210,14 +211,19 @@ def expand_linux_packaging(linux: LinuxFile) -> list[PackagingEntry]: the nix-based build images, because deb/rpm tooling (debhelper, rpm-build) is taken from the distro's archive rather than from nixpkgs. Each config entry carries its own 'image'. + + The artifact names must match what the build job uploads: one artifact per + binary, each named after the build config. """ entries = [] for distro, configs in linux.package_configs.items(): for cfg in configs: for compiler, build_type in itertools.product(cfg.compiler, cfg.build_type): + config_name = f"{distro}-{compiler}-{build_type.lower()}-amd64" entries.append( PackagingEntry( - artifact_name=f"xrpld-{distro}-{compiler}-{build_type.lower()}-amd64", + xrpld_artifact_name=f"xrpld-{config_name}", + validator_keys_artifact_name=f"validator-keys-{config_name}", image=cfg.image, distro=distro, ) diff --git a/.github/scripts/strategy-matrix/linux.json b/.github/scripts/strategy-matrix/linux.json index 03ac1c6334..62aead518a 100644 --- a/.github/scripts/strategy-matrix/linux.json +++ b/.github/scripts/strategy-matrix/linux.json @@ -68,7 +68,8 @@ "compiler": ["gcc"], "build_type": ["Release"], "arch": ["amd64"], - "minimal": false + "minimal": false, + "extra_cmake_args": "-Dvalidator_keys=ON" } ], @@ -77,7 +78,8 @@ "compiler": ["gcc"], "build_type": ["Release"], "arch": ["amd64"], - "minimal": false + "minimal": false, + "extra_cmake_args": "-Dvalidator_keys=ON" } ] }, diff --git a/.github/workflows/reusable-build-test-config.yml b/.github/workflows/reusable-build-test-config.yml index a1daeed5fe..60f11bed4b 100644 --- a/.github/workflows/reusable-build-test-config.yml +++ b/.github/workflows/reusable-build-test-config.yml @@ -100,9 +100,10 @@ jobs: # header files are copied into separate directories by CMake, which will # otherwise result in cache misses. CCACHE_SLOPPINESS: include_file_ctime,include_file_mtime - # Determine if coverage and voidstar should be enabled. + # Determine if coverage, voidstar and validator-keys should be enabled. COVERAGE_ENABLED: ${{ contains(inputs.cmake_args, '-Dcoverage=ON') }} VOIDSTAR_ENABLED: ${{ contains(inputs.cmake_args, '-Dvoidstar=ON') }} + VALIDATOR_KEYS_ENABLED: ${{ contains(inputs.cmake_args, '-Dvalidator_keys=ON') }} SANITIZERS_ENABLED: ${{ inputs.sanitizers != '' }} steps: - name: Cleanup workspace (macOS and Windows) @@ -247,6 +248,22 @@ jobs: retention-days: 3 if-no-files-found: error + - name: Run the validator-keys tests + if: ${{ env.VALIDATOR_KEYS_ENABLED == 'true' }} + working-directory: ${{ env.BUILD_DIR }} + run: ./validator-keys --unittest + + - name: Upload the validator-keys binary + if: ${{ github.event.repository.visibility == 'public' && env.VALIDATOR_KEYS_ENABLED == 'true' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: validator-keys-${{ inputs.config_name }} + path: | + ${{ env.BUILD_DIR }}/validator-keys + ${{ env.BUILD_DIR }}/validator-keys-LICENSE + retention-days: 3 + if-no-files-found: error + - name: Upload the test binary (Linux) if: ${{ github.event.repository.visibility == 'public' && runner.os == 'Linux' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.github/workflows/reusable-package.yml b/.github/workflows/reusable-package.yml index 6feecbfb75..0a0c96e7dd 100644 --- a/.github/workflows/reusable-package.yml +++ b/.github/workflows/reusable-package.yml @@ -1,7 +1,7 @@ -# Build Linux packages (DEB and RPM) from pre-built binary artifacts. -# Discovers which configurations to package from linux.json (configs in -# "package_configs") and fans out one job per distro. Only linux/amd64 is -# supported; the runner is hardcoded in the job below. +# Build Linux packages (DEB and RPM) from pre-built binary artifacts (xrpld and +# validator-keys). Discovers which configurations to package from linux.json +# (configs in "package_configs") and fans out one job per distro. Only +# linux/amd64 is supported; the runner is hardcoded in the job below. name: Package on: @@ -45,7 +45,7 @@ jobs: strategy: fail-fast: false matrix: ${{ fromJson(needs.generate-matrix.outputs.matrix) }} - name: "${{ matrix.artifact_name }}" + name: "${{ matrix.xrpld_artifact_name }}" permissions: contents: read runs-on: ["self-hosted", "Linux", "X64", "heavy"] @@ -56,14 +56,20 @@ jobs: - name: Checkout repository uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Download pre-built binary + - name: Download pre-built xrpld binary uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: ${{ matrix.artifact_name }} + name: ${{ matrix.xrpld_artifact_name }} path: ${{ env.BUILD_DIR }} - - name: Make binary executable - run: chmod +x "${BUILD_DIR}/xrpld" + - name: Download pre-built validator-keys binary + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ matrix.validator_keys_artifact_name }} + path: ${{ env.BUILD_DIR }} + + - name: Make binaries executable + run: chmod +x "${BUILD_DIR}/xrpld" "${BUILD_DIR}/validator-keys" - name: Build package env: @@ -73,7 +79,7 @@ jobs: - name: Upload package artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: ${{ matrix.artifact_name }}-pkg + name: ${{ matrix.xrpld_artifact_name }}-pkg path: | ${{ env.BUILD_DIR }}/debbuild/*.deb ${{ env.BUILD_DIR }}/debbuild/*.ddeb diff --git a/CMakeLists.txt b/CMakeLists.txt index 1e8befcc8f..ac2bed1aa8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -138,8 +138,10 @@ endif() include(XrplCore) include(XrplProtocolAutogen) include(XrplInstall) -include(XrplPackaging) include(XrplValidatorKeys) +# Must come after XrplValidatorKeys: the 'package' target depends on the +# validator-keys target existing. +include(XrplPackaging) if(tests) include(CTest) diff --git a/cmake/PatchNixBinary.cmake b/cmake/PatchNixBinary.cmake index 79ca0b150c..18d992305f 100644 --- a/cmake/PatchNixBinary.cmake +++ b/cmake/PatchNixBinary.cmake @@ -3,9 +3,9 @@ The Nix-based CI image links binaries against an ELF interpreter (loader) that lives in the Nix store, so the resulting binaries don't run elsewhere - (including once installed from the .deb package). `patch_nix_binary` adds a - POST_BUILD step that resets the interpreter to the system default loader and - drops the rpath. + (including once installed from the .deb package). `patch_nix_binary` resets + the interpreter to the system default loader and drops the rpath, once the + binary has been linked. This is only active inside the Nix-based image, detected by the presence of /tmp/loader-path.sh (shipped by that image, resolves the default loader). It @@ -41,13 +41,38 @@ function(patch_nix_binary target) if(NOT PATCH_NIX_BINARIES) return() endif() - add_custom_command( - TARGET ${target} - POST_BUILD - COMMAND - "${PATCHELF_COMMAND}" --set-interpreter "${DEFAULT_LOADER_PATH}" - --remove-rpath "$" - COMMENT "Patching ${target}: set default loader, remove rpath" - VERBATIM + + set(patch_command + "${PATCHELF_COMMAND}" + --set-interpreter + "${DEFAULT_LOADER_PATH}" + --remove-rpath + "$" ) + set(comment "Patching ${target}: set default loader, remove rpath") + + # POST_BUILD is the cheap way to do this: it runs only when the binary is + # relinked. It is also only available in the directory that defined the + # target, so for a target from elsewhere (e.g. a FetchContent subproject) + # fall back to a custom target that runs after the binary is linked. That + # one runs on every build, which is harmless because patchelf is idempotent. + get_target_property(target_source_dir ${target} SOURCE_DIR) + if("${target_source_dir}" STREQUAL "${CMAKE_CURRENT_SOURCE_DIR}") + add_custom_command( + TARGET ${target} + POST_BUILD + COMMAND ${patch_command} + COMMENT "${comment}" + VERBATIM + ) + else() + add_custom_target( + ${target}-patch-nix + ALL + COMMAND ${patch_command} + COMMENT "${comment}" + VERBATIM + ) + add_dependencies(${target}-patch-nix ${target}) + endif() endfunction() diff --git a/cmake/XrplPackaging.cmake b/cmake/XrplPackaging.cmake index 8e3861925d..bee7b15791 100644 --- a/cmake/XrplPackaging.cmake +++ b/cmake/XrplPackaging.cmake @@ -25,6 +25,19 @@ if(NOT (RPMBUILD_EXECUTABLE OR DPKG_BUILDPACKAGE_EXECUTABLE)) return() endif() +if(NOT TARGET xrpld) + message(STATUS "xrpld=ON is required; 'package' target not available") + return() +endif() + +if(NOT TARGET validator-keys) + message( + STATUS + "validator_keys=ON is required; 'package' target not available" + ) + return() +endif() + set(package_env SRC_DIR=${CMAKE_SOURCE_DIR} BUILD_DIR=${CMAKE_BINARY_DIR} @@ -37,7 +50,7 @@ add_custom_target( ${CMAKE_COMMAND} -E env ${package_env} ${CMAKE_SOURCE_DIR}/package/build_pkg.sh WORKING_DIRECTORY ${CMAKE_BINARY_DIR} - DEPENDS xrpld + DEPENDS xrpld validator-keys COMMENT "Building Linux package (deb/rpm inferred from host tooling)" VERBATIM ) diff --git a/cmake/XrplValidatorKeys.cmake b/cmake/XrplValidatorKeys.cmake index 0e511b6a88..0acaed1a56 100644 --- a/cmake/XrplValidatorKeys.cmake +++ b/cmake/XrplValidatorKeys.cmake @@ -5,22 +5,39 @@ option( ) if(validator_keys) - git_branch(current_branch) - # default to tracking VK master branch unless we are on release - if(NOT (current_branch STREQUAL "release")) - set(current_branch "master") - endif() - message(STATUS "Tracking ValidatorKeys branch: ${current_branch}") + # Own the install destination below rather than relying on another module + # having pulled this in first. + include(GNUInstallDirs) + + # Pinned to an exact commit, not a branch: the tool ships inside our + # packages, so the same xrpld version must always package the same + # validator-keys. Bump this deliberately. + set(validator_keys_commit "4c0fb75eec9601c711645998c904507e87e910ae") + message(STATUS "Using ValidatorKeys commit: ${validator_keys_commit}") FetchContent_Declare( validator_keys GIT_REPOSITORY https://github.com/ripple/validator-keys-tool.git - GIT_TAG "${current_branch}" + GIT_TAG "${validator_keys_commit}" ) FetchContent_MakeAvailable(validator_keys) + # The tool's own CMakeLists excludes the target from 'all' when it is built + # as a subproject. Undo that, so validator_keys=ON really does build it. set_target_properties( validator-keys - PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}" + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}" + EXCLUDE_FROM_ALL OFF + EXCLUDE_FROM_DEFAULT_BUILD OFF + ) + # We ship this binary, so like xrpld it must not keep the Nix store's ELF + # loader, or it cannot run on the target distro at all. + patch_nix_binary(validator-keys) + + configure_file( + "${validator_keys_SOURCE_DIR}/LICENSE" + "${CMAKE_BINARY_DIR}/validator-keys-LICENSE" + COPYONLY ) install(TARGETS validator-keys RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}) endif() diff --git a/package/README.md b/package/README.md index 4b78106c4c..887509b60b 100644 --- a/package/README.md +++ b/package/README.md @@ -1,6 +1,8 @@ # Linux Packaging -This directory contains all files needed to build RPM and Debian packages for `xrpld`. +This directory contains all files needed to build RPM and Debian packages for +`xrpld`. The packages also ship the `validator-keys` tool, so packaging requires +a build configured with `-Dvalidator_keys=ON`. ## Directory layout @@ -46,17 +48,28 @@ To print the full packaging matrix (artifact names and images) for the current Caller workflows (`on-pr.yml`, `on-tag.yml`, `on-trigger.yml`) call `reusable-package.yml`. That workflow generates its own packaging matrix from `package_configs` in `linux.json` (via `generate.py --packaging`) and fans out -one job per distro. Each job downloads the pre-built `xrpld` binary artifact and -runs in that distro's container, so the package format follows from the -container's package manager. The packaging script derives the package version -from the downloaded binary's `xrpld --version` output; no CMake configure or -build step is needed inside the packaging job. +one job per distro. Each job downloads the pre-built `xrpld` and `validator-keys` +binary artifacts and runs in that distro's container, so the package format +follows from the container's package manager. The packaging script derives the +package version from the downloaded binary's `xrpld --version` output; no CMake +configure or build step is needed inside the packaging job. + +The binaries come from the `debian` and `rhel` build configurations in +`linux.json`'s `configs` section, which pass `-Dvalidator_keys=ON` so that the +build job produces `validator-keys` next to `xrpld` and uploads it as the +`validator-keys-` artifact. The packaging entry for a distro names +both artifacts (`xrpld_artifact_name` and `validator_keys_artifact_name`), so a +packaged configuration must keep `-Dvalidator_keys=ON`. + +`validator-keys` is fetched from an exact commit pinned in +[`cmake/XrplValidatorKeys.cmake`](../cmake/XrplValidatorKeys.cmake), so a given +`xrpld` version always packages the same tool; bump that commit deliberately. ### Locally (mirrors CI) -With an `xrpld` binary already built at `build/xrpld`, run the packaging step -inside the same container CI uses. The image tag is derived from `linux.json` -so you don't need to hardcode a SHA. +With `xrpld` and `validator-keys` binaries already built at `build/xrpld` and +`build/validator-keys`, run the packaging step inside the same container CI uses. +The image tag is derived from `linux.json` so you don't need to hardcode a SHA. ```bash # From the repo root. Each distro's container image is the `image` field of its @@ -87,6 +100,7 @@ needed, but the host toolchain replaces the pinned CI image: ```bash cmake \ -Dxrpld=ON \ + -Dvalidator_keys=ON \ -Dpkg_release=1 \ -Dtests=OFF \ .. @@ -95,9 +109,11 @@ cmake --build . --target package # deb on Debian/Ubuntu, rpm on RHEL ``` The `cmake/XrplPackaging.cmake` module defines the `package` target only if at -least one of `rpmbuild` / `dpkg-buildpackage` is present; `build_pkg.sh` then -infers the package format from the host's package manager. The packaging script -installs to FHS-standard paths (`/usr/bin`, `/etc/xrpld`, etc.) regardless of +least one of `rpmbuild` / `dpkg-buildpackage` is present and both the `xrpld` and +`validator-keys` targets exist (`-Dxrpld=ON -Dvalidator_keys=ON`); the target +builds both binaries before packaging. `build_pkg.sh` then infers the package +format from the host's package manager. The packaging script installs to +FHS-standard paths (`/usr/bin`, `/etc/xrpld`, etc.) regardless of `CMAKE_INSTALL_PREFIX`. The package version is not a CMake input on this path: `build_pkg.sh` derives it @@ -156,13 +172,17 @@ CMake/CI integration. The CI workflow and the CMake `package` target both invoke and lets the script use defaults for the rest. It resolves `SRC_DIR` and `BUILD_DIR` to absolute paths, then calls -`stage_common()` to copy the binary, config files, and shared support files -into the staging area, and invokes the platform build tool. +`stage_common()` to copy the `xrpld` and `validator-keys` binaries, config files, +and shared support files into the staging area, and invokes the platform build +tool. Both binaries must be present in `BUILD_DIR` and must run in the packaging +environment; a missing or non-runnable one fails early. That runtime check is +what catches a binary still linked against the Nix store's ELF loader (see +`patch_nix_binary` in `cmake/PatchNixBinary.cmake`). ### RPM 1. Creates the standard `rpmbuild/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS}` tree inside the build directory. -2. Copies `xrpld.spec` and all shared source files (binary, configs, service files) into `SOURCES/`. +2. Copies `xrpld.spec` and all shared source files (binaries, configs, service files) into `SOURCES/`. 3. Runs `rpmbuild -bb`, passing the normalized package metadata version as the `pkg_version` RPM macro and `PKG_RELEASE` as the `pkg_release` RPM macro. The spec uses manual `install` commands to place files, disables `dwz`, and @@ -182,7 +202,8 @@ service restart. ### DEB 1. Creates a staging source tree at `debbuild/source/` inside the build directory. -2. Stages the binary, configs, `README.md`, and `LICENSE.md`. +2. Stages the binaries, configs, `README.md`, `LICENSE.md`, and + `validator-keys-LICENSE`. 3. Copies `package/debian/` control files into `debbuild/source/debian/`. 4. Copies shared service/sysusers/tmpfiles into `debian/` where `dh_installsystemd`, `dh_installsysusers`, and `dh_installtmpfiles` pick them up automatically. 5. Generates a minimal `debian/changelog` using `${pkg_version}-${PKG_RELEASE}`, diff --git a/package/build_pkg.sh b/package/build_pkg.sh index 3684fc096a..d853bf95b7 100755 --- a/package/build_pkg.sh +++ b/package/build_pkg.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash set -euo pipefail -# Build an RPM or Debian package from a pre-built xrpld binary. +# Build an RPM or Debian package from the pre-built xrpld and validator-keys +# binaries. # # Flags override env vars; env vars override defaults. @@ -11,7 +12,9 @@ Usage: build_pkg.sh [options] Options (each can also be set via the env var shown): --src-dir DIR repo root [SRC_DIR; default: ${PWD}] - --build-dir DIR directory holding xrpld [BUILD_DIR; default: ${PWD}/build] + --build-dir DIR directory holding the + xrpld and validator-keys + binaries [BUILD_DIR; default: ${PWD}/build] --pkg-release N package release iteration [PKG_RELEASE; default: 1] --source-date-epoch SECS reproducibility timestamp [SOURCE_DATE_EPOCH; latest git ctime; fallback: current time] -h, --help show this help and exit @@ -69,15 +72,44 @@ SRC_DIR="$(cd "${SRC_DIR:-${PWD}}" && pwd)" BUILD_DIR="${BUILD_DIR:-${PWD}/build}" if [[ ! -d "${BUILD_DIR}" ]]; then echo "build_pkg.sh: build directory not found: ${BUILD_DIR}" >&2 - echo "Build xrpld before packaging, or set BUILD_DIR to the directory containing xrpld." >&2 + echo "Build the binaries before packaging, or set BUILD_DIR to the directory containing them." >&2 exit 1 fi BUILD_DIR="$(cd "${BUILD_DIR}" && pwd)" xrpld_binary="${BUILD_DIR}/xrpld" -if [[ ! -x "${xrpld_binary}" ]]; then - echo "build_pkg.sh: expected executable xrpld binary at ${xrpld_binary}." >&2 - echo "Build xrpld before packaging, or set BUILD_DIR to the directory containing xrpld." >&2 +validator_keys_binary="${BUILD_DIR}/validator-keys" + +# Report both binaries at once: they share a single BUILD_DIR, so telling the +# reader to point it at one of them in isolation is advice they cannot follow. +missing=() +[[ -x "${xrpld_binary}" ]] || missing+=(xrpld) +[[ -x "${validator_keys_binary}" ]] || missing+=(validator-keys) + +if [[ ${#missing[@]} -gt 0 ]]; then + echo "build_pkg.sh: missing or not executable in ${BUILD_DIR}: ${missing[*]}" >&2 + echo "Both binaries come from a single CMake build directory configured with" >&2 + echo "-Dxrpld=ON -Dvalidator_keys=ON. Build them, then point BUILD_DIR at that" >&2 + echo "directory." >&2 + exit 1 +fi + +# Shipping validator-keys means shipping its notice, so treat it as required +# rather than letting a package go out without the attribution. +validator_keys_license="${BUILD_DIR}/validator-keys-LICENSE" +if [[ ! -f "${validator_keys_license}" ]]; then + echo "build_pkg.sh: missing ${validator_keys_license}." >&2 + echo "cmake/XrplValidatorKeys.cmake copies it out of the fetched" >&2 + echo "validator-keys-tool source, so reconfigure with -Dvalidator_keys=ON." >&2 + exit 1 +fi + +# The binary must also *run* here. Packaging happens in a vanilla distro +# container, so this is what catches a binary still pointing at the Nix store's +# ELF loader (see patch_nix_binary in cmake/PatchNixBinary.cmake); xrpld is +# covered implicitly by the version query below. +if ! "${validator_keys_binary}" --version >/dev/null; then + echo "build_pkg.sh: ${validator_keys_binary} exists but does not run here." >&2 exit 1 fi @@ -150,7 +182,9 @@ stage_common() { local dest="$1" mkdir -p "${dest}" - cp "${BUILD_DIR}/xrpld" "${dest}/xrpld" + cp "${xrpld_binary}" "${dest}/xrpld" + cp "${validator_keys_binary}" "${dest}/validator-keys" + cp "${validator_keys_license}" "${dest}/validator-keys-LICENSE" cp "${SRC_DIR}/cfg/xrpld-example.cfg" "${dest}/xrpld.cfg" cp "${SRC_DIR}/cfg/validators-example.txt" "${dest}/validators.txt" cp "${SRC_DIR}/LICENSE.md" "${dest}/LICENSE.md" diff --git a/package/debian/control b/package/debian/control index 45d2acbbea..62e5d79ef1 100644 --- a/package/debian/control +++ b/package/debian/control @@ -18,6 +18,8 @@ Depends: ${shlibs:Depends}, ${misc:Depends} Description: XRP Ledger daemon - Reference implementation of the XRP Ledger protocol. - Participates in the peer-to-peer network, processes transactions, - and maintains a local ledger copy. + xrpld is the reference implementation of the XRP Ledger protocol. It + participates in the peer-to-peer XRP Ledger network, processes + transactions, and maintains the ledger database. + This package also includes the validator-keys tool for validator key + management. diff --git a/package/debian/copyright b/package/debian/copyright index ddaa719e3a..2cf673854a 100644 --- a/package/debian/copyright +++ b/package/debian/copyright @@ -4,6 +4,25 @@ Source: https://github.com/XRPLF/rippled Files: * Copyright: 2011-present, the XRP Ledger developers +License: ISC + +Files: validator-keys +Copyright: 2016, Ripple Labs Inc. + 2011, Arthur Britto, David Schwartz, Jed McCaleb, Vinnie Falco, Bob Way, + Eric Lombrozo, Nikolaos D. Bougalis, Howard Hinnant + 2013, Raw Material Software Ltd. + 2003-2011, Christopher M. Kohlhoff + 2009-2010, Satoshi Nakamoto + 2011, The Bitcoin developers + 2003-2005, Tom Wu +License: ISC +Comment: Built from https://github.com/ripple/validator-keys-tool at the commit + pinned in cmake/XrplValidatorKeys.cmake. Besides ISC-licensed code it + incorporates work under the Boost Software License 1.0 (ASIO), the MIT/X11 + license (Bitcoin) and Tom Wu's license, whose terms require its notice to be + retained intact. The complete upstream notice is therefore shipped verbatim as + /usr/share/doc/xrpld/validator-keys-LICENSE. + License: ISC Permission to use, copy, modify, and distribute this software for any purpose with or without fee is hereby granted, provided that the above diff --git a/package/debian/rules b/package/debian/rules index 16574bca3f..8f880b8192 100644 --- a/package/debian/rules +++ b/package/debian/rules @@ -18,6 +18,7 @@ override_dh_installsysusers: override_dh_install: install -D -m 0755 xrpld debian/xrpld/usr/bin/xrpld + install -D -m 0755 validator-keys debian/xrpld/usr/bin/validator-keys install -D -m 0644 xrpld.cfg debian/xrpld/etc/xrpld/xrpld.cfg install -D -m 0644 validators.txt debian/xrpld/etc/xrpld/validators.txt diff --git a/package/debian/xrpld.docs b/package/debian/xrpld.docs index b43bf86b50..77681ddc6e 100644 --- a/package/debian/xrpld.docs +++ b/package/debian/xrpld.docs @@ -1 +1,2 @@ README.md +validator-keys-LICENSE diff --git a/package/rpm/xrpld.spec b/package/rpm/xrpld.spec index 61c2d61ec6..0e3ee2a968 100644 --- a/package/rpm/xrpld.spec +++ b/package/rpm/xrpld.spec @@ -32,6 +32,8 @@ BuildRequires: systemd-rpm-macros xrpld is the reference implementation of the XRP Ledger protocol. It participates in the peer-to-peer XRP Ledger network, processes transactions, and maintains the ledger database. +This package also includes the validator-keys tool for validator key +management. %prep : @@ -41,6 +43,7 @@ transactions, and maintains the ledger database. %install install -Dm0755 %{_sourcedir}/xrpld %{buildroot}%{_bindir}/%{name} +install -Dm0755 %{_sourcedir}/validator-keys %{buildroot}%{_bindir}/validator-keys install -Dm0644 %{_sourcedir}/xrpld.cfg %{buildroot}%{_sysconfdir}/%{name}/xrpld.cfg install -Dm0644 %{_sourcedir}/validators.txt %{buildroot}%{_sysconfdir}/%{name}/validators.txt @@ -59,6 +62,8 @@ install -Dm0644 %{_sourcedir}/xrpld.logrotate %{buildroot}%{_sysconfdir}/lo # Docs install -Dm0644 %{_sourcedir}/LICENSE.md %{buildroot}%{_docdir}/%{name}/LICENSE.md install -Dm0644 %{_sourcedir}/README.md %{buildroot}%{_docdir}/%{name}/README.md +# Upstream notice for the bundled validator-keys tool. +install -Dm0644 %{_sourcedir}/validator-keys-LICENSE %{buildroot}%{_docdir}/%{name}/validator-keys-LICENSE # Legacy compatibility for pre-FHS package layouts. # TODO: remove after rippled fully deprecated. @@ -80,11 +85,13 @@ systemd-tmpfiles --create %{_tmpfilesdir}/xrpld.conf || : %files %license %{_docdir}/%{name}/LICENSE.md +%license %{_docdir}/%{name}/validator-keys-LICENSE %doc %{_docdir}/%{name}/README.md %dir %{_sysconfdir}/%{name} %{_bindir}/%{name} +%{_bindir}/validator-keys %config(noreplace) %{_sysconfdir}/%{name}/xrpld.cfg %config(noreplace) %{_sysconfdir}/%{name}/validators.txt From 4eece4003d7dd19fb717977d659aec0001057d82 Mon Sep 17 00:00:00 2001 From: Bart Date: Thu, 30 Jul 2026 11:34:46 -0400 Subject: [PATCH 31/52] chore: Bump version to 3.3.0-rc6 --- src/libxrpl/protocol/BuildInfo.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libxrpl/protocol/BuildInfo.cpp b/src/libxrpl/protocol/BuildInfo.cpp index 6bb2c40cff..87956a12fd 100644 --- a/src/libxrpl/protocol/BuildInfo.cpp +++ b/src/libxrpl/protocol/BuildInfo.cpp @@ -23,7 +23,7 @@ namespace { //------------------------------------------------------------------------------ // clang-format off // NOLINTNEXTLINE(readability-identifier-naming) -char const* const versionString = "3.3.0-rc5" +char const* const versionString = "3.3.0-rc6" // clang-format on ; From 587505ef186c3dc1937570a5911caab851c467e2 Mon Sep 17 00:00:00 2001 From: Pratik Mankawde <3397372+pratikmankawde@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:07:57 +0100 Subject: [PATCH 32/52] fix: Bound untrusted manifest cache --- include/xrpl/server/Manifest.h | 91 +++++++++++++++-- src/libxrpl/server/Manifest.cpp | 105 ++++++++++++++++---- src/libxrpl/server/Wallet.cpp | 21 +++- src/test/app/Manifest_test.cpp | 72 ++++++++++---- src/test/app/ValidatorList_test.cpp | 59 +++++++---- src/xrpld/app/misc/detail/ValidatorList.cpp | 10 +- src/xrpld/overlay/detail/OverlayImpl.cpp | 30 ++++-- 7 files changed, 310 insertions(+), 78 deletions(-) diff --git a/include/xrpl/server/Manifest.h b/include/xrpl/server/Manifest.h index eed1c14dae..ddd4f503fe 100644 --- a/include/xrpl/server/Manifest.h +++ b/include/xrpl/server/Manifest.h @@ -35,12 +35,15 @@ namespace xrpl { dynamically generates the signatureless form when it needs to verify the signature. - An instance of ManifestCache stores, for each trusted validator, (a) its + An instance of ManifestCache stores, for each known validator, (a) its master public key, and (b) the most senior of all valid manifests it has seen for that validator, if any. On startup, the [validator_token] config entry (which contains the manifest for this validator) is decoded and added to the manifest cache. Other manifests are added as "gossip" - received from xrpld peers. + received from xrpld peers, including ones for validators this node does not + list. Manifests for unlisted validators are capped (kMaxUntrustedCount) + so peer gossip cannot grow the cache without bound; listed validators are + not capped. Entries are never evicted, so a stored revocation is permanent. When an ephemeral key is compromised, a new signing key pair is created, along with a new manifest vouching for it (with a higher sequence number), @@ -206,7 +209,10 @@ enum class ManifestDisposition { BadEphemeralKey, /// Timely, but invalid signature - Invalid + Invalid, + + /// Unlisted and limit reached + UntrustedCapacity }; inline std::string @@ -224,11 +230,25 @@ to_string(ManifestDisposition m) return "badEphemeralKey"; case ManifestDisposition::Invalid: return "invalid"; + case ManifestDisposition::UntrustedCapacity: + return "untrustedCapacity"; default: return "unknown"; } } +/** + * Whether a manifest counts against the 'untrusted' cache cap. + * + * Passed to `ManifestCache::applyManifest` with no default, so every caller + * must choose. `Capped` is the safe, flood-resistant value; only listed or + * configured keys should use `Uncapped`. + */ +enum class ManifestRateLimitCap : std::uint8_t { + Capped, ///< Subject to the untrusted cap (unlisted peer gossip) + Uncapped ///< Bypasses the cap (listed/trusted or config manifests) +}; + class DatabaseCon; /** Remembers manifests with the highest sequence number. */ @@ -246,6 +266,38 @@ private: std::atomic seq_{0}; + /** + * Master keys of cached manifests for validators this node does not list. + * + * One entry per capped key in `map_`; its size enforces the cap below. + * A key is added when first cached under `Capped` and removed when it + * becomes listed (see `promoteToTrusted`) or an `Uncapped` update arrives, + * never re-added on de-listing. Uncapped keys are not tracked here. + */ + hash_set untrustedKeys_; + + /** + * Maximum number of untrusted master keys kept in the cache. + * + * Once reached, a manifest for a brand-new unlisted key is rejected. + */ + static constexpr std::size_t kMaxUntrustedCount = 50000; + + /** + * Running count of manifests rejected because the untrusted cap was full. + * + * Drives throttled logging (see `kUntrustedRejectCount`). Atomic because + * `applyManifest` may run concurrently. + */ + std::atomic untrustedRejectCount_{0}; + + /** + * Number of cap rejections between summary warnings. + * + * @see untrustedRejectCount_ + */ + static constexpr std::uint64_t kUntrustedRejectCount = 10000; + public: explicit ManifestCache(beast::Journal j = beast::Journal(beast::Journal::getNullSink())) : j_(j) { @@ -321,17 +373,44 @@ public: /** Add manifest to cache. + A brand-new unlisted key is rejected once the untrusted cap is full; + updates to a cached key and `Uncapped` manifests bypass the cap. The + caller decides `cap` before calling so the cache lock is not held while + consulting the validator list, which would risk a lock-ordering deadlock. + @param m Manifest to add - @return `ManifestDisposition::accepted` if successful, or - `stale` or `invalid` otherwise + @param cap `Uncapped` skips the untrusted cap; use it for keys that are + listed, configured, or loaded from the DB. Note `Uncapped` does + not assert the key is currently trusted (a DB entry may predate a + de-listing). Callers must state this explicitly so a manifest is + never left uncapped by omission. + + @return `Accepted` if stored, `Stale` if superseded, `Invalid`/ + `BadEphemeralKey` if malformed, or `UntrustedCapacity` if the + untrusted cap is full. @par Thread Safety May be called concurrently */ ManifestDisposition - applyManifest(Manifest m); + applyManifest(Manifest m, ManifestRateLimitCap cap); + + /** + * Stop counting a master key against the untrusted cap. + * + * Called when a cached untrusted key becomes listed, freeing its slot. + * Idempotent and a no-op for keys that were never counted. + * + * @param pk Master public key that is now listed/trusted + * + * @par Thread Safety + * + * May be called concurrently + */ + void + promoteToTrusted(PublicKey const& pk); /** Populate manifest cache with manifests in database and config. diff --git a/src/libxrpl/server/Manifest.cpp b/src/libxrpl/server/Manifest.cpp index b26c67e531..b18fdadbe2 100644 --- a/src/libxrpl/server/Manifest.cpp +++ b/src/libxrpl/server/Manifest.cpp @@ -377,16 +377,20 @@ ManifestCache::revoked(PublicKey const& pk) const } ManifestDisposition -ManifestCache::applyManifest(Manifest m) +ManifestCache::applyManifest(Manifest m, ManifestRateLimitCap const cap) { + bool const uncapped = cap == ManifestRateLimitCap::Uncapped; + + // The signature is checked only on the first `prewriteCheck` run (under the + // read lock). It is expensive, so `checkSignature` is cleared the first + // time it is read; the second run (under the write lock) skips it. + bool checkSignature = true; + // Check the manifest against the conditions that do not require a - // `unique_lock` (write lock) on the `mutex_`. Since the signature can be - // relatively expensive, the `checkSignature` parameter determines if the - // signature should be checked. Since `prewriteCheck` is run twice (see - // comment below), `checkSignature` only needs to be set to true on the - // first run. - auto prewriteCheck = [this, &m](auto const& iter, bool checkSignature, auto const& lock) - -> std::optional { + // `unique_lock` (write lock) on the `mutex_`. + auto prewriteCheck = [this, &m, &checkSignature]( + auto const& iter, + auto const& lock) -> std::optional { XRPL_ASSERT(lock.owns_lock(), "xrpl::ManifestCache::applyManifest::prewriteCheck : locked"); (void)lock; // not used. parameter is present to ensure the mutex is // locked when the lambda is called. @@ -401,11 +405,15 @@ ManifestCache::applyManifest(Manifest m) return ManifestDisposition::Stale; } - if (checkSignature && !m.verify()) + if (checkSignature) { - if (auto stream = j_.warn()) - logMftAct(stream, "Invalid", m.masterKey, m.sequence); - return ManifestDisposition::Invalid; + checkSignature = false; + if (!m.verify()) + { + if (auto stream = j_.warn()) + logMftAct(stream, "Invalid", m.masterKey, m.sequence); + return ManifestDisposition::Invalid; + } } // If the master key associated with a manifest is or might be @@ -465,14 +473,51 @@ ManifestCache::applyManifest(Manifest m) return std::nullopt; }; + // Reject a brand-new manifest for an unlisted key once the untrusted cap + // is full. Updates to a cached key and uncapped manifests always pass. + // Called under both the read and write lock, since the cap can be reached + // between the two. The lock param enforces that. + auto atUntrustedCap = [this, &m, uncapped](auto const& iter, auto const& lock) { + XRPL_ASSERT( + lock.owns_lock(), "xrpl::ManifestCache::applyManifest::atUntrustedCap : locked"); + (void)lock; // not used. parameter is present to ensure the mutex is + // locked when the lambda is called. + if (iter == map_.end() && !uncapped && untrustedKeys_.size() >= kMaxUntrustedCount) + { + // Log each rejection at debug, but warn only once per interval so a + // flood does not fill the log. + if (auto stream = j_.debug()) + logMftAct(stream, "UntrustedCapacity", m.masterKey, m.sequence); + if (auto const n = untrustedRejectCount_.fetch_add(1) + 1; + n % kUntrustedRejectCount == 0) + { + JLOG(j_.warn()) << "Untrusted manifest cap reached; " << n + << " manifests rejected so far"; + } + return true; + } + return false; + }; + { std::shared_lock const sl{mutex_}; - if (auto d = prewriteCheck(map_.find(m.masterKey), /*checkSig*/ true, sl)) + auto const iter = map_.find(m.masterKey); + + if (atUntrustedCap(iter, sl)) + return ManifestDisposition::UntrustedCapacity; + + if (auto d = prewriteCheck(iter, sl); d.has_value()) return *d; } std::unique_lock const sl{mutex_}; auto const iter = map_.find(m.masterKey); + + // Re-check the cap under the write lock: the cache may have grown while the + // read lock above was released. + if (atUntrustedCap(iter, sl)) + return ManifestDisposition::UntrustedCapacity; + // Since we released the previously held read lock, it's possible that the // collections have been written to. This means we need to run // `prewriteCheck` again. This re-does work, but `prewriteCheck` is @@ -482,7 +527,7 @@ ManifestCache::applyManifest(Manifest m) // doesn't need to happen again (signature checks are somewhat expensive). // Note: It's a mistake to use an upgradable lock. This is a recipe for // deadlock. - if (auto d = prewriteCheck(iter, /*checkSig*/ false, sl)) + if (auto d = prewriteCheck(iter, sl); d.has_value()) return *d; bool const revoked = m.revoked(); @@ -501,6 +546,12 @@ ManifestCache::applyManifest(Manifest m) } auto masterKey = m.masterKey; + + // Count this key against the untrusted cap. Uncapped keys (listed, + // configured, or DB-loaded) are not tracked. + if (!uncapped) + untrustedKeys_.insert(masterKey); + map_.emplace(std::move(masterKey), std::move(m)); // Something has changed. Keep track of it. @@ -514,6 +565,11 @@ ManifestCache::applyManifest(Manifest m) if (auto stream = j_.info()) logMftAct(stream, "AcceptedUpdate", m.masterKey, m.sequence, iter->second.sequence); + // If this key was counted against the cap but now arrives uncapped, free + // its slot without waiting for promoteToTrusted. + if (uncapped) + untrustedKeys_.erase(m.masterKey); + signingToMasterKeys_.erase( *iter->second.signingKey); // NOLINT(bugprone-unchecked-optional-access) prewriteCheck // ensures old manifest is not revoked @@ -521,8 +577,8 @@ ManifestCache::applyManifest(Manifest m) if (!revoked) { signingToMasterKeys_.emplace( - *m.signingKey, m.masterKey); // NOLINT(bugprone-unchecked-optional-access) non-revoked - // manifest always has signingKey + *m.signingKey, m.masterKey); // NOLINT(bugprone-unchecked-optional-access) + // non-revoked manifest always has signingKey } iter->second = std::move(m); @@ -533,6 +589,16 @@ ManifestCache::applyManifest(Manifest m) return ManifestDisposition::Accepted; } +void +ManifestCache::promoteToTrusted(PublicKey const& pk) +{ + // Frees the key's untrusted slot; a no-op (and idempotent) if the key was + // never counted. Not re-added on de-listing, so list/de-list cannot grow + // the count. + std::unique_lock const sl{mutex_}; + untrustedKeys_.erase(pk); +} + void ManifestCache::load(DatabaseCon& dbCon, std::string const& dbTable) { @@ -563,7 +629,8 @@ ManifestCache::load( JLOG(j_.warn()) << "Configured manifest revokes public key"; } - if (applyManifest(std::move(*mo)) == ManifestDisposition::Invalid) + if (applyManifest(std::move(*mo), ManifestRateLimitCap::Uncapped) == + ManifestDisposition::Invalid) { JLOG(j_.error()) << "Manifest in config was rejected"; return false; @@ -585,7 +652,9 @@ ManifestCache::load( auto mo = deserializeManifest(base64Decode(revocationStr)); - if (!mo || !mo->revoked() || applyManifest(std::move(*mo)) == ManifestDisposition::Invalid) + if (!mo || !mo->revoked() || + applyManifest(std::move(*mo), ManifestRateLimitCap::Uncapped) == + ManifestDisposition::Invalid) { JLOG(j_.error()) << "Invalid validator key revocation in config"; return false; diff --git a/src/libxrpl/server/Wallet.cpp b/src/libxrpl/server/Wallet.cpp index f3ae9dc925..f3ef3cf832 100644 --- a/src/libxrpl/server/Wallet.cpp +++ b/src/libxrpl/server/Wallet.cpp @@ -27,6 +27,7 @@ #include #include +#include #include #include #include @@ -75,7 +76,9 @@ getManifests( continue; } - cache.applyManifest(std::move(*mo)); + // Only trusted manifests are persisted (see saveManifests), so + // anything loaded from the DB bypasses the untrusted cap. + cache.applyManifest(std::move(*mo), ManifestRateLimitCap::Uncapped); } else { @@ -105,19 +108,27 @@ saveManifests( { soci::transaction tr(session); session << "DELETE FROM " << dbTable; + // Count skipped untrusted manifests and log one summary afterwards, since + // the cache can hold many and per-entry logging would flood at shutdown. + std::size_t skipped = 0; for (auto const& v : map) { - // Save all revocation manifests, - // but only save trusted non-revocation manifests. - if (!v.second.revoked() && !isTrusted(v.second.masterKey)) + // Persist only trusted keys. Untrusted gossip is left out so a flood + // cannot survive a restart on disk. + if (!isTrusted(v.second.masterKey)) { - JLOG(j.info()) << "Untrusted manifest in cache not saved to db"; + ++skipped; continue; } saveManifest(session, dbTable, v.second.serialized); } tr.commit(); + + if (skipped != 0) + { + JLOG(j.info()) << skipped << " untrusted manifest(s) in cache not saved to db"; + } } void diff --git a/src/test/app/Manifest_test.cpp b/src/test/app/Manifest_test.cpp index d559ecd7b5..c1c55c4a5d 100644 --- a/src/test/app/Manifest_test.cpp +++ b/src/test/app/Manifest_test.cpp @@ -399,7 +399,8 @@ public: BEAST_EXPECT( ManifestDisposition::Accepted == cache.applyManifest( - makeManifest(sk, KeyType::Ed25519, kp0.second, KeyType::Secp256k1, 0))); + makeManifest(sk, KeyType::Ed25519, kp0.second, KeyType::Secp256k1, 0), + ManifestRateLimitCap::Capped)); BEAST_EXPECT(cache.getSigningKey(pk) == kp0.first); BEAST_EXPECT(cache.getMasterKey(kp0.first) == pk); @@ -411,7 +412,8 @@ public: BEAST_EXPECT( ManifestDisposition::Accepted == cache.applyManifest( - makeManifest(sk, KeyType::Ed25519, kp1.second, KeyType::Secp256k1, 1))); + makeManifest(sk, KeyType::Ed25519, kp1.second, KeyType::Secp256k1, 1), + ManifestRateLimitCap::Capped)); BEAST_EXPECT(cache.getSigningKey(pk) == kp1.first); BEAST_EXPECT(cache.getMasterKey(kp1.first) == pk); BEAST_EXPECT(cache.getMasterKey(kp0.first) == kp0.first); @@ -421,7 +423,8 @@ public: BEAST_EXPECT( ManifestDisposition::BadEphemeralKey == cache.applyManifest( - makeManifest(sk, KeyType::Ed25519, kp1.second, KeyType::Secp256k1, 2))); + makeManifest(sk, KeyType::Ed25519, kp1.second, KeyType::Secp256k1, 2), + ManifestRateLimitCap::Capped)); BEAST_EXPECT(cache.getSigningKey(pk) == kp1.first); BEAST_EXPECT(cache.getMasterKey(kp1.first) == pk); BEAST_EXPECT(cache.getMasterKey(kp0.first) == kp0.first); @@ -431,7 +434,8 @@ public: // key from a revoked master public key BEAST_EXPECT( ManifestDisposition::Accepted == - cache.applyManifest(makeRevocation(sk, KeyType::Ed25519))); + cache.applyManifest( + makeRevocation(sk, KeyType::Ed25519), ManifestRateLimitCap::Capped)); BEAST_EXPECT(cache.revoked(pk)); BEAST_EXPECT(cache.getSigningKey(pk) == pk); BEAST_EXPECT(cache.getMasterKey(kp0.first) == kp0.first); @@ -902,39 +906,69 @@ public: // applyManifest should accept new manifests with // higher sequence numbers auto const seq0 = cache.sequence(); - BEAST_EXPECT(cache.applyManifest(clone(sA0)) == ManifestDisposition::Accepted); + BEAST_EXPECT( + cache.applyManifest(clone(sA0), ManifestRateLimitCap::Capped) == + ManifestDisposition::Accepted); BEAST_EXPECT(cache.sequence() > seq0); auto const seq1 = cache.sequence(); - BEAST_EXPECT(cache.applyManifest(clone(sA0)) == ManifestDisposition::Stale); + BEAST_EXPECT( + cache.applyManifest(clone(sA0), ManifestRateLimitCap::Capped) == + ManifestDisposition::Stale); BEAST_EXPECT(cache.sequence() == seq1); - BEAST_EXPECT(cache.applyManifest(clone(sA1)) == ManifestDisposition::Accepted); - BEAST_EXPECT(cache.applyManifest(clone(sA1)) == ManifestDisposition::Stale); - BEAST_EXPECT(cache.applyManifest(clone(sA0)) == ManifestDisposition::Stale); + BEAST_EXPECT( + cache.applyManifest(clone(sA1), ManifestRateLimitCap::Capped) == + ManifestDisposition::Accepted); + BEAST_EXPECT( + cache.applyManifest(clone(sA1), ManifestRateLimitCap::Capped) == + ManifestDisposition::Stale); + BEAST_EXPECT( + cache.applyManifest(clone(sA0), ManifestRateLimitCap::Capped) == + ManifestDisposition::Stale); - BEAST_EXPECT(cache.applyManifest(clone(sA2)) == ManifestDisposition::BadEphemeralKey); + BEAST_EXPECT( + cache.applyManifest(clone(sA2), ManifestRateLimitCap::Capped) == + ManifestDisposition::BadEphemeralKey); // applyManifest should accept manifests with max sequence numbers // that revoke the master public key BEAST_EXPECT(!cache.revoked(pkA)); BEAST_EXPECT(sAMax.revoked()); - BEAST_EXPECT(cache.applyManifest(clone(sAMax)) == ManifestDisposition::Accepted); - BEAST_EXPECT(cache.applyManifest(clone(sAMax)) == ManifestDisposition::Stale); - BEAST_EXPECT(cache.applyManifest(clone(sA1)) == ManifestDisposition::Stale); - BEAST_EXPECT(cache.applyManifest(clone(sA0)) == ManifestDisposition::Stale); + BEAST_EXPECT( + cache.applyManifest(clone(sAMax), ManifestRateLimitCap::Capped) == + ManifestDisposition::Accepted); + BEAST_EXPECT( + cache.applyManifest(clone(sAMax), ManifestRateLimitCap::Capped) == + ManifestDisposition::Stale); + BEAST_EXPECT( + cache.applyManifest(clone(sA1), ManifestRateLimitCap::Capped) == + ManifestDisposition::Stale); + BEAST_EXPECT( + cache.applyManifest(clone(sA0), ManifestRateLimitCap::Capped) == + ManifestDisposition::Stale); BEAST_EXPECT(cache.revoked(pkA)); // applyManifest should reject manifests with invalid signatures - BEAST_EXPECT(cache.applyManifest(clone(sB0)) == ManifestDisposition::Accepted); - BEAST_EXPECT(cache.applyManifest(clone(sB0)) == ManifestDisposition::Stale); + BEAST_EXPECT( + cache.applyManifest(clone(sB0), ManifestRateLimitCap::Capped) == + ManifestDisposition::Accepted); + BEAST_EXPECT( + cache.applyManifest(clone(sB0), ManifestRateLimitCap::Capped) == + ManifestDisposition::Stale); BEAST_EXPECT(!deserializeManifest(fake)); - BEAST_EXPECT(cache.applyManifest(clone(sB1)) == ManifestDisposition::Invalid); - BEAST_EXPECT(cache.applyManifest(clone(sB2)) == ManifestDisposition::Accepted); + BEAST_EXPECT( + cache.applyManifest(clone(sB1), ManifestRateLimitCap::Capped) == + ManifestDisposition::Invalid); + BEAST_EXPECT( + cache.applyManifest(clone(sB2), ManifestRateLimitCap::Capped) == + ManifestDisposition::Accepted); auto const sC0 = makeManifest( kpB2.second, KeyType::Ed25519, randomSecretKey(), KeyType::Ed25519, 47); - BEAST_EXPECT(cache.applyManifest(clone(sC0)) == ManifestDisposition::BadMasterKey); + BEAST_EXPECT( + cache.applyManifest(clone(sC0), ManifestRateLimitCap::Capped) == + ManifestDisposition::BadMasterKey); } testLoadStore(cache); diff --git a/src/test/app/ValidatorList_test.cpp b/src/test/app/ValidatorList_test.cpp index 20a3557db5..3fede86637 100644 --- a/src/test/app/ValidatorList_test.cpp +++ b/src/test/app/ValidatorList_test.cpp @@ -277,8 +277,10 @@ private: trustedKeys->load(localSigningPublicOuter, emptyCfgKeys, emptyCfgPublishers)); BEAST_EXPECT(trustedKeys->listed(localSigningPublicOuter)); - // NOLINTNEXTLINE(bugprone-unchecked-optional-access) - manifests.applyManifest(*deserializeManifest(cfgManifest)); + // NOLINTBEGIN(bugprone-unchecked-optional-access) + manifests.applyManifest( + *deserializeManifest(cfgManifest), ManifestRateLimitCap::Capped); + // NOLINTEND(bugprone-unchecked-optional-access) BEAST_EXPECT( trustedKeys->load(localSigningPublicOuter, emptyCfgKeys, emptyCfgPublishers)); @@ -368,8 +370,10 @@ private: app.config().legacy("database_path"), env.journal); - // NOLINTNEXTLINE(bugprone-unchecked-optional-access) - manifests.applyManifest(*deserializeManifest(cfgManifest)); + // NOLINTBEGIN(bugprone-unchecked-optional-access) + manifests.applyManifest( + *deserializeManifest(cfgManifest), ManifestRateLimitCap::Capped); + // NOLINTEND(bugprone-unchecked-optional-access) BEAST_EXPECT(trustedKeys->load(localSigningPublicOuter, cfgKeys, emptyCfgPublishers)); @@ -454,13 +458,16 @@ private: auto const pubRevokedSigning = randomKeyPair(KeyType::Secp256k1); // make this manifest revoked (seq num = max) // -- thus should not be loaded - // NOLINTNEXTLINE(bugprone-unchecked-optional-access) - pubManifests.applyManifest(*deserializeManifest(makeManifestString( - pubRevokedPublic, - pubRevokedSecret, - pubRevokedSigning.first, - pubRevokedSigning.second, - std::numeric_limits::max()))); + // NOLINTBEGIN(bugprone-unchecked-optional-access) + pubManifests.applyManifest( + *deserializeManifest(makeManifestString( + pubRevokedPublic, + pubRevokedSecret, + pubRevokedSigning.first, + pubRevokedSigning.second, + std::numeric_limits::max())), + ManifestRateLimitCap::Capped); + // NOLINTEND(bugprone-unchecked-optional-access) // these two are not revoked (and not in the manifest cache at all.) auto legitKey1 = randomMasterKey(); @@ -493,13 +500,16 @@ private: auto const pubRevokedSigning = randomKeyPair(KeyType::Secp256k1); // make this manifest revoked (seq num = max) // -- thus should not be loaded - // NOLINTNEXTLINE(bugprone-unchecked-optional-access) - pubManifests.applyManifest(*deserializeManifest(makeManifestString( - pubRevokedPublic, - pubRevokedSecret, - pubRevokedSigning.first, - pubRevokedSigning.second, - std::numeric_limits::max()))); + // NOLINTBEGIN(bugprone-unchecked-optional-access) + pubManifests.applyManifest( + *deserializeManifest(makeManifestString( + pubRevokedPublic, + pubRevokedSecret, + pubRevokedSigning.first, + pubRevokedSigning.second, + std::numeric_limits::max())), + ManifestRateLimitCap::Capped); + // NOLINTEND(bugprone-unchecked-optional-access) // this one is not revoked (and not in the manifest cache at all.) auto legitKey = randomMasterKey(); @@ -1164,7 +1174,8 @@ private: BEAST_EXPECT( // NOLINTNEXTLINE(bugprone-unchecked-optional-access) - manifestsOuter.applyManifest(std::move(*m1)) == ManifestDisposition::Accepted); + manifestsOuter.applyManifest(std::move(*m1), ManifestRateLimitCap::Capped) == + ManifestDisposition::Accepted); BEAST_EXPECT(trustedKeysOuter->listed(masterPublic)); BEAST_EXPECT(trustedKeysOuter->trusted(masterPublic)); BEAST_EXPECT(trustedKeysOuter->listed(signingPublic1)); @@ -1178,7 +1189,8 @@ private: masterPublic, masterPrivate, signingPublic2, signingKeys2.second, 2)); BEAST_EXPECT( // NOLINTNEXTLINE(bugprone-unchecked-optional-access) - manifestsOuter.applyManifest(std::move(*m2)) == ManifestDisposition::Accepted); + manifestsOuter.applyManifest(std::move(*m2), ManifestRateLimitCap::Capped) == + ManifestDisposition::Accepted); BEAST_EXPECT(trustedKeysOuter->listed(masterPublic)); BEAST_EXPECT(trustedKeysOuter->trusted(masterPublic)); BEAST_EXPECT(trustedKeysOuter->listed(signingPublic2)); @@ -1195,7 +1207,8 @@ private: // NOLINTBEGIN(bugprone-unchecked-optional-access) BEAST_EXPECT(max->revoked()); BEAST_EXPECT( - manifestsOuter.applyManifest(std::move(*max)) == ManifestDisposition::Accepted); + manifestsOuter.applyManifest(std::move(*max), ManifestRateLimitCap::Capped) == + ManifestDisposition::Accepted); // NOLINTEND(bugprone-unchecked-optional-access) BEAST_EXPECT(manifestsOuter.getSigningKey(masterPublic) == masterPublic); @@ -2612,7 +2625,9 @@ private: auto threshold = listThreshold > 0 ? std::optional(listThreshold) : std::nullopt; if (self) { - valManifests.applyManifest(*deserializeManifest(base64Decode(self->manifest))); + valManifests.applyManifest( + *deserializeManifest(base64Decode(self->manifest)), + ManifestRateLimitCap::Capped); BEAST_EXPECT( result->load(self->signingPublic, emptyCfgKeys, cfgPublishers, threshold)); } diff --git a/src/xrpld/app/misc/detail/ValidatorList.cpp b/src/xrpld/app/misc/detail/ValidatorList.cpp index 57b65814e1..ce07e0ec77 100644 --- a/src/xrpld/app/misc/detail/ValidatorList.cpp +++ b/src/xrpld/app/misc/detail/ValidatorList.cpp @@ -1065,6 +1065,8 @@ ValidatorList::updatePublisherList( { // Increment list count for added keys ++keyListings_[*iNew]; + // Key is now listed: free its untrusted slot if it had one. + validatorManifests_.promoteToTrusted(*iNew); ++iNew; } else if (iNew == publisherList.end() || (iOld != oldList.end() && *iOld < *iNew)) @@ -1103,7 +1105,8 @@ ValidatorList::updatePublisherList( continue; } - if (auto const r = validatorManifests_.applyManifest(std::move(*m)); + if (auto const r = + validatorManifests_.applyManifest(std::move(*m), ManifestRateLimitCap::Uncapped); r == ManifestDisposition::Invalid) { JLOG(j_.warn()) << "List for " << strHex(pubKey) @@ -1348,7 +1351,10 @@ ValidatorList::verify( PublicKey masterPubKey = manifest.masterKey; auto const revoked = manifest.revoked(); - auto const result = publisherManifests_.applyManifest(std::move(manifest)); + // Publisher keys are configured/trusted (checked above), so bypass the + // untrusted cap. + auto const result = + publisherManifests_.applyManifest(std::move(manifest), ManifestRateLimitCap::Uncapped); if (revoked && result == ManifestDisposition::Accepted) { diff --git a/src/xrpld/overlay/detail/OverlayImpl.cpp b/src/xrpld/overlay/detail/OverlayImpl.cpp index b31f54058a..452d0f0339 100644 --- a/src/xrpld/overlay/detail/OverlayImpl.cpp +++ b/src/xrpld/overlay/detail/OverlayImpl.cpp @@ -673,13 +673,22 @@ OverlayImpl::onManifests( if (auto mo = deserializeManifest(s)) { auto const serialized = mo->serialized; + // Resolve trust before applyManifest takes the manifest-cache + // lock: listed() takes the validator-list lock, so ordering it + // first avoids holding the two locks in opposite orders. + bool const isTrusted = app_.getValidators().listed(mo->masterKey); + // Updates to a known key are relayed even when untrusted. Use + // getSequence, not getManifest, to avoid copying the cached payload + // on this hot path. + bool const isKnown = + app_.getValidatorManifests().getSequence(mo->masterKey).has_value(); - auto const result = app_.getValidatorManifests().applyManifest(std::move(*mo)); + auto const result = app_.getValidatorManifests().applyManifest( + std::move(*mo), + isTrusted ? ManifestRateLimitCap::Uncapped : ManifestRateLimitCap::Capped); if (result == ManifestDisposition::Accepted) { - relay.add_list()->set_stobject(s); - // N.B.: this is important; the applyManifest call above moves // the loaded Manifest out of the optional so we need to // reload it here. @@ -691,10 +700,19 @@ OverlayImpl::onManifests( // NOLINTBEGIN(bugprone-unchecked-optional-access) assert above app_.getOPs().pubManifest(*mo); - if (app_.getValidators().listed(mo->masterKey)) + // Relay only trusted manifests or updates to known keys, so + // untrusted gossip for a brand-new key cannot be amplified. + // Persist to the wallet DB only for trusted keys, so untrusted + // gossip never survives a restart. + if (isTrusted || isKnown) { - auto db = app_.getWalletDB().checkoutDb(); - addValidatorManifest(*db, serialized); + relay.add_list()->set_stobject(s); + + if (isTrusted) + { + auto db = app_.getWalletDB().checkoutDb(); + addValidatorManifest(*db, serialized); + } } // NOLINTEND(bugprone-unchecked-optional-access) } From 32a9cc4038e62ee87c7a2eb5e03f1f028507a8a7 Mon Sep 17 00:00:00 2001 From: Pratik Mankawde <3397372+pratikmankawde@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:27:45 +0100 Subject: [PATCH 33/52] fix: Reduce untrusted manifest cache cap to 100 --- include/xrpl/server/Manifest.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/include/xrpl/server/Manifest.h b/include/xrpl/server/Manifest.h index ddd4f503fe..70fc8f6f8f 100644 --- a/include/xrpl/server/Manifest.h +++ b/include/xrpl/server/Manifest.h @@ -281,7 +281,7 @@ private: * * Once reached, a manifest for a brand-new unlisted key is rejected. */ - static constexpr std::size_t kMaxUntrustedCount = 50000; + static constexpr std::size_t kMaxUntrustedCount = 100; /** * Running count of manifests rejected because the untrusted cap was full. From 0cce5a06d994cb1c45419e2b7016843ba5817748 Mon Sep 17 00:00:00 2001 From: Bart Date: Thu, 16 Jul 2026 16:21:56 -0400 Subject: [PATCH 34/52] fix: Reject oversized validator manifest before decoding --- include/xrpl/basics/base64.h | 29 ++++++++++++ include/xrpl/server/Manifest.h | 52 ++++++++++++++++++--- src/libxrpl/basics/base64.cpp | 14 ------ src/libxrpl/server/Manifest.cpp | 5 ++ src/xrpld/app/misc/detail/ValidatorList.cpp | 9 ++++ 5 files changed, 88 insertions(+), 21 deletions(-) diff --git a/include/xrpl/basics/base64.h b/include/xrpl/basics/base64.h index ed30e40a36..6b4cd26604 100644 --- a/include/xrpl/basics/base64.h +++ b/include/xrpl/basics/base64.h @@ -39,6 +39,35 @@ namespace xrpl { +namespace base64 { + +/** + * Returns the maximum number of characters needed to base64-encode @p nBytes bytes. + * + * @param nBytes Number of input bytes. + * @return Size of the encoded string, including padding. + */ +constexpr std::size_t +encodedSize(std::size_t const nBytes) +{ + return 4 * ((nBytes + 2) / 3); +} + +/** + * Returns the maximum number of bytes a base64 string of @p nChars characters + * decodes to. + * + * @param nChars Number of base64 characters. + * @return Upper bound on the number of decoded bytes. + */ +constexpr std::size_t +decodedSize(std::size_t const nChars) +{ + return ((nChars / 4) * 3) + 2; +} + +} // namespace base64 + std::string base64Encode(std::uint8_t const* data, std::size_t len); diff --git a/include/xrpl/server/Manifest.h b/include/xrpl/server/Manifest.h index 70fc8f6f8f..fe5f1db985 100644 --- a/include/xrpl/server/Manifest.h +++ b/include/xrpl/server/Manifest.h @@ -1,10 +1,16 @@ #pragma once #include +#include +#include #include #include #include +#include +#include +#include +#include #include #include #include @@ -135,15 +141,47 @@ struct Manifest std::string to_string(Manifest const& m); -/** Constructs Manifest from serialized string +/** + *Largest a valid manifest can be, in decoded bytes. + * + * A manifest has a fixed set of fields. Each is serialized as a field header + * (1-2 bytes), an optional length prefix (1 byte for these sizes), and the + * field body. Taking every field at its largest gives the maximum below, so + * anything larger cannot be a valid manifest. + * + * Field header + length + body = bytes + * sfVersion (U16) 2 0 2 4 + * sfSequence (U32) 1 0 4 5 + * sfPublicKey (33) 1 1 33 35 + * sfSigningPubKey (33) 1 1 33 35 + * sfSignature (72) 1 1 72 74 + * sfMasterSignature (72) 2 1 72 75 + * sfDomain (128) 1 1 128 130 + * ----- + * 358 + */ +constexpr std::size_t kMaxManifestBytes = 358; - @param s Serialized manifest string +/** + * Largest a valid manifest can be, in base64 characters. + * + * base64 encodes 3 bytes as 4 characters, so this is the encoded form of + * @ref kMaxManifestBytes. Callers that receive a base64 manifest should + * reject anything longer than this before decoding, to avoid allocating + * memory for an oversized input. + */ +constexpr std::size_t kMaxManifestBase64 = base64::encodedSize(kMaxManifestBytes); - @return `std::nullopt` if string is invalid - - @note This does not verify manifest signatures. - `Manifest::verify` should be called after constructing manifest. -*/ +/** + * Constructs Manifest from serialized string + * + * @param s Serialized manifest string + * + * @return `std::nullopt` if string is invalid + * + * @note This does not verify manifest signatures. + * `Manifest::verify` should be called after constructing manifest. + */ /** @{ */ std::optional deserializeManifest(Slice s, beast::Journal journal); diff --git a/src/libxrpl/basics/base64.cpp b/src/libxrpl/basics/base64.cpp index 541ddd0839..7772752f40 100644 --- a/src/libxrpl/basics/base64.cpp +++ b/src/libxrpl/basics/base64.cpp @@ -76,20 +76,6 @@ getInverse() return &kTab[0]; } -/// Returns max chars needed to encode a base64 string -constexpr std::size_t -encodedSize(std::size_t n) -{ - return 4 * ((n + 2) / 3); -} - -/// Returns max bytes needed to decode a base64 string -constexpr std::size_t -decodedSize(std::size_t n) -{ - return ((n / 4) * 3) + 2; -} - /** Encode a series of octets as a padded, base64 string. The resulting string will not be null terminated. diff --git a/src/libxrpl/server/Manifest.cpp b/src/libxrpl/server/Manifest.cpp index b18fdadbe2..b34955dc28 100644 --- a/src/libxrpl/server/Manifest.cpp +++ b/src/libxrpl/server/Manifest.cpp @@ -62,6 +62,11 @@ deserializeManifest(Slice s, beast::Journal journal) if (s.empty()) return std::nullopt; + // A valid manifest has a fixed maximum size, so reject anything larger + // before parsing it. + if (s.size() > kMaxManifestBytes) + return std::nullopt; + static SOTemplate const kManifestFormat{ // A manifest must include: // - the master public key diff --git a/src/xrpld/app/misc/detail/ValidatorList.cpp b/src/xrpld/app/misc/detail/ValidatorList.cpp index ce07e0ec77..5c95eb553c 100644 --- a/src/xrpld/app/misc/detail/ValidatorList.cpp +++ b/src/xrpld/app/misc/detail/ValidatorList.cpp @@ -1130,6 +1130,15 @@ ValidatorList::applyList( json::Value list; auto const& manifest = localManifest ? *localManifest : globalManifest; + // Reject an oversized manifest before decoding it, so we do not allocate + // memory for an input that cannot be a valid manifest. deserializeManifest + // also enforces the decoded-byte limit, but checking here avoids the + // base64 decode entirely. + if (manifest.size() > kMaxManifestBase64) + { + JLOG(j_.warn()) << "UNL manifest exceeds maximum size"; + return PublisherListStats{ListDisposition::Invalid}; + } auto m = deserializeManifest(base64Decode(manifest)); if (!m) { From 4bd1d1ca2f01952b9ef533bc4bd8abc2046bfce8 Mon Sep 17 00:00:00 2001 From: Valentin Balaschenko <13349202+vlntb@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:18:28 +0100 Subject: [PATCH 35/52] fix: Cap untrusted manifests per message and drop oversized ones Bound the number of manifests carried in a single TMManifests message (kMaxManifestsPerMessage). Trusted manifests are always included and processed; untrusted gossip is capped per message on both send and receive, and the sender is charged only when untrusted entries are actually skipped. Oversized TMManifests messages are dropped without penalty at the protocol layer so an unpatched peer is not disconnected. Complements the cache bound from #276/#323. --- include/xrpl/basics/base64.h | 8 +- include/xrpl/server/Manifest.h | 45 ++++++---- src/libxrpl/server/Manifest.cpp | 8 +- src/libxrpl/server/Wallet.cpp | 2 +- src/test/app/Manifest_test.cpp | 38 ++++----- src/test/app/ValidatorList_test.cpp | 16 ++-- src/xrpld/app/misc/detail/ValidatorList.cpp | 8 +- src/xrpld/overlay/Message.h | 8 ++ src/xrpld/overlay/detail/OverlayImpl.cpp | 95 +++++++++++++++++++-- src/xrpld/overlay/detail/PeerImp.cpp | 4 + src/xrpld/overlay/detail/ProtocolMessage.h | 10 +++ 11 files changed, 178 insertions(+), 64 deletions(-) diff --git a/include/xrpl/basics/base64.h b/include/xrpl/basics/base64.h index 6b4cd26604..34b2cbc5a0 100644 --- a/include/xrpl/basics/base64.h +++ b/include/xrpl/basics/base64.h @@ -54,16 +54,16 @@ encodedSize(std::size_t const nBytes) } /** - * Returns the maximum number of bytes a base64 string of @p nChars characters + * Returns the maximum number of bytes a base64 string of @p numChars characters * decodes to. * - * @param nChars Number of base64 characters. + * @param numChars Number of base64 characters. * @return Upper bound on the number of decoded bytes. */ constexpr std::size_t -decodedSize(std::size_t const nChars) +decodedSize(std::size_t const numChars) { - return ((nChars / 4) * 3) + 2; + return ((numChars / 4) * 3) + 2; } } // namespace base64 diff --git a/include/xrpl/server/Manifest.h b/include/xrpl/server/Manifest.h index fe5f1db985..eec07ac3ee 100644 --- a/include/xrpl/server/Manifest.h +++ b/include/xrpl/server/Manifest.h @@ -47,8 +47,8 @@ namespace xrpl { entry (which contains the manifest for this validator) is decoded and added to the manifest cache. Other manifests are added as "gossip" received from xrpld peers, including ones for validators this node does not - list. Manifests for unlisted validators are capped (kMaxUntrustedCount) - so peer gossip cannot grow the cache without bound; listed validators are + trust. Manifests for untrusted validators are capped (kMaxUntrustedCount) + so peer gossip cannot grow the cache without bound; trusted validators are not capped. Entries are never evicted, so a stored revocation is permanent. When an ephemeral key is compromised, a new signing key pair is created, @@ -149,7 +149,7 @@ to_string(Manifest const& m); * field body. Taking every field at its largest gives the maximum below, so * anything larger cannot be a valid manifest. * - * Field header + length + body = bytes + * Field header + length + body = bytes * sfVersion (U16) 2 0 2 4 * sfSequence (U32) 1 0 4 5 * sfPublicKey (33) 1 1 33 35 @@ -172,16 +172,31 @@ constexpr std::size_t kMaxManifestBytes = 358; */ constexpr std::size_t kMaxManifestBase64 = base64::encodedSize(kMaxManifestBytes); -/** - * Constructs Manifest from serialized string - * - * @param s Serialized manifest string - * - * @return `std::nullopt` if string is invalid - * - * @note This does not verify manifest signatures. - * `Manifest::verify` should be called after constructing manifest. - */ +/** Maximum number of manifests carried in a single TMManifests message. + + Outbound, the TMManifests message sent to a peer includes every trusted + manifest and fills the rest of this budget with untrusted gossip, so it + never exceeds this size. Inbound, trusted manifests are always processed + and untrusted ones are processed up to this many, so a peer sending its + whole cache cannot force unbounded work. + + The trusted set is tiny relative to this bound, so trusted manifests are + not dropped in practice. This is a transitional per-message cap; the cache + already bounds untrusted manifests (see kMaxUntrustedCount), so it is no + longer needed once the network has upgraded past nodes that send their + whole cache in one message. +*/ +constexpr std::size_t kMaxManifestsPerMessage = 200; + +/** Constructs Manifest from serialized string + + @param s Serialized manifest string + + @return `std::nullopt` if string is invalid + + @note This does not verify manifest signatures. + `Manifest::verify` should be called after constructing manifest. +*/ /** @{ */ std::optional deserializeManifest(Slice s, beast::Journal journal); @@ -282,7 +297,7 @@ to_string(ManifestDisposition m) * must choose. `Capped` is the safe, flood-resistant value; only listed or * configured keys should use `Uncapped`. */ -enum class ManifestRateLimitCap : std::uint8_t { +enum class ManifestRateLimitCapPolicy : std::uint8_t { Capped, ///< Subject to the untrusted cap (unlisted peer gossip) Uncapped ///< Bypasses the cap (listed/trusted or config manifests) }; @@ -433,7 +448,7 @@ public: May be called concurrently */ ManifestDisposition - applyManifest(Manifest m, ManifestRateLimitCap cap); + applyManifest(Manifest m, ManifestRateLimitCapPolicy cap); /** * Stop counting a master key against the untrusted cap. diff --git a/src/libxrpl/server/Manifest.cpp b/src/libxrpl/server/Manifest.cpp index b34955dc28..3da3f9e9cd 100644 --- a/src/libxrpl/server/Manifest.cpp +++ b/src/libxrpl/server/Manifest.cpp @@ -382,9 +382,9 @@ ManifestCache::revoked(PublicKey const& pk) const } ManifestDisposition -ManifestCache::applyManifest(Manifest m, ManifestRateLimitCap const cap) +ManifestCache::applyManifest(Manifest m, ManifestRateLimitCapPolicy const cap) { - bool const uncapped = cap == ManifestRateLimitCap::Uncapped; + bool const uncapped = cap == ManifestRateLimitCapPolicy::Uncapped; // The signature is checked only on the first `prewriteCheck` run (under the // read lock). It is expensive, so `checkSignature` is cleared the first @@ -634,7 +634,7 @@ ManifestCache::load( JLOG(j_.warn()) << "Configured manifest revokes public key"; } - if (applyManifest(std::move(*mo), ManifestRateLimitCap::Uncapped) == + if (applyManifest(std::move(*mo), ManifestRateLimitCapPolicy::Uncapped) == ManifestDisposition::Invalid) { JLOG(j_.error()) << "Manifest in config was rejected"; @@ -658,7 +658,7 @@ ManifestCache::load( auto mo = deserializeManifest(base64Decode(revocationStr)); if (!mo || !mo->revoked() || - applyManifest(std::move(*mo), ManifestRateLimitCap::Uncapped) == + applyManifest(std::move(*mo), ManifestRateLimitCapPolicy::Uncapped) == ManifestDisposition::Invalid) { JLOG(j_.error()) << "Invalid validator key revocation in config"; diff --git a/src/libxrpl/server/Wallet.cpp b/src/libxrpl/server/Wallet.cpp index f3ef3cf832..ac5f0ace76 100644 --- a/src/libxrpl/server/Wallet.cpp +++ b/src/libxrpl/server/Wallet.cpp @@ -78,7 +78,7 @@ getManifests( // Only trusted manifests are persisted (see saveManifests), so // anything loaded from the DB bypasses the untrusted cap. - cache.applyManifest(std::move(*mo), ManifestRateLimitCap::Uncapped); + cache.applyManifest(std::move(*mo), ManifestRateLimitCapPolicy::Uncapped); } else { diff --git a/src/test/app/Manifest_test.cpp b/src/test/app/Manifest_test.cpp index c1c55c4a5d..9218b073d4 100644 --- a/src/test/app/Manifest_test.cpp +++ b/src/test/app/Manifest_test.cpp @@ -400,7 +400,7 @@ public: ManifestDisposition::Accepted == cache.applyManifest( makeManifest(sk, KeyType::Ed25519, kp0.second, KeyType::Secp256k1, 0), - ManifestRateLimitCap::Capped)); + ManifestRateLimitCapPolicy::Capped)); BEAST_EXPECT(cache.getSigningKey(pk) == kp0.first); BEAST_EXPECT(cache.getMasterKey(kp0.first) == pk); @@ -413,7 +413,7 @@ public: ManifestDisposition::Accepted == cache.applyManifest( makeManifest(sk, KeyType::Ed25519, kp1.second, KeyType::Secp256k1, 1), - ManifestRateLimitCap::Capped)); + ManifestRateLimitCapPolicy::Capped)); BEAST_EXPECT(cache.getSigningKey(pk) == kp1.first); BEAST_EXPECT(cache.getMasterKey(kp1.first) == pk); BEAST_EXPECT(cache.getMasterKey(kp0.first) == kp0.first); @@ -424,7 +424,7 @@ public: ManifestDisposition::BadEphemeralKey == cache.applyManifest( makeManifest(sk, KeyType::Ed25519, kp1.second, KeyType::Secp256k1, 2), - ManifestRateLimitCap::Capped)); + ManifestRateLimitCapPolicy::Capped)); BEAST_EXPECT(cache.getSigningKey(pk) == kp1.first); BEAST_EXPECT(cache.getMasterKey(kp1.first) == pk); BEAST_EXPECT(cache.getMasterKey(kp0.first) == kp0.first); @@ -435,7 +435,7 @@ public: BEAST_EXPECT( ManifestDisposition::Accepted == cache.applyManifest( - makeRevocation(sk, KeyType::Ed25519), ManifestRateLimitCap::Capped)); + makeRevocation(sk, KeyType::Ed25519), ManifestRateLimitCapPolicy::Capped)); BEAST_EXPECT(cache.revoked(pk)); BEAST_EXPECT(cache.getSigningKey(pk) == pk); BEAST_EXPECT(cache.getMasterKey(kp0.first) == kp0.first); @@ -907,28 +907,28 @@ public: // higher sequence numbers auto const seq0 = cache.sequence(); BEAST_EXPECT( - cache.applyManifest(clone(sA0), ManifestRateLimitCap::Capped) == + cache.applyManifest(clone(sA0), ManifestRateLimitCapPolicy::Capped) == ManifestDisposition::Accepted); BEAST_EXPECT(cache.sequence() > seq0); auto const seq1 = cache.sequence(); BEAST_EXPECT( - cache.applyManifest(clone(sA0), ManifestRateLimitCap::Capped) == + cache.applyManifest(clone(sA0), ManifestRateLimitCapPolicy::Capped) == ManifestDisposition::Stale); BEAST_EXPECT(cache.sequence() == seq1); BEAST_EXPECT( - cache.applyManifest(clone(sA1), ManifestRateLimitCap::Capped) == + cache.applyManifest(clone(sA1), ManifestRateLimitCapPolicy::Capped) == ManifestDisposition::Accepted); BEAST_EXPECT( - cache.applyManifest(clone(sA1), ManifestRateLimitCap::Capped) == + cache.applyManifest(clone(sA1), ManifestRateLimitCapPolicy::Capped) == ManifestDisposition::Stale); BEAST_EXPECT( - cache.applyManifest(clone(sA0), ManifestRateLimitCap::Capped) == + cache.applyManifest(clone(sA0), ManifestRateLimitCapPolicy::Capped) == ManifestDisposition::Stale); BEAST_EXPECT( - cache.applyManifest(clone(sA2), ManifestRateLimitCap::Capped) == + cache.applyManifest(clone(sA2), ManifestRateLimitCapPolicy::Capped) == ManifestDisposition::BadEphemeralKey); // applyManifest should accept manifests with max sequence numbers @@ -936,38 +936,38 @@ public: BEAST_EXPECT(!cache.revoked(pkA)); BEAST_EXPECT(sAMax.revoked()); BEAST_EXPECT( - cache.applyManifest(clone(sAMax), ManifestRateLimitCap::Capped) == + cache.applyManifest(clone(sAMax), ManifestRateLimitCapPolicy::Capped) == ManifestDisposition::Accepted); BEAST_EXPECT( - cache.applyManifest(clone(sAMax), ManifestRateLimitCap::Capped) == + cache.applyManifest(clone(sAMax), ManifestRateLimitCapPolicy::Capped) == ManifestDisposition::Stale); BEAST_EXPECT( - cache.applyManifest(clone(sA1), ManifestRateLimitCap::Capped) == + cache.applyManifest(clone(sA1), ManifestRateLimitCapPolicy::Capped) == ManifestDisposition::Stale); BEAST_EXPECT( - cache.applyManifest(clone(sA0), ManifestRateLimitCap::Capped) == + cache.applyManifest(clone(sA0), ManifestRateLimitCapPolicy::Capped) == ManifestDisposition::Stale); BEAST_EXPECT(cache.revoked(pkA)); // applyManifest should reject manifests with invalid signatures BEAST_EXPECT( - cache.applyManifest(clone(sB0), ManifestRateLimitCap::Capped) == + cache.applyManifest(clone(sB0), ManifestRateLimitCapPolicy::Capped) == ManifestDisposition::Accepted); BEAST_EXPECT( - cache.applyManifest(clone(sB0), ManifestRateLimitCap::Capped) == + cache.applyManifest(clone(sB0), ManifestRateLimitCapPolicy::Capped) == ManifestDisposition::Stale); BEAST_EXPECT(!deserializeManifest(fake)); BEAST_EXPECT( - cache.applyManifest(clone(sB1), ManifestRateLimitCap::Capped) == + cache.applyManifest(clone(sB1), ManifestRateLimitCapPolicy::Capped) == ManifestDisposition::Invalid); BEAST_EXPECT( - cache.applyManifest(clone(sB2), ManifestRateLimitCap::Capped) == + cache.applyManifest(clone(sB2), ManifestRateLimitCapPolicy::Capped) == ManifestDisposition::Accepted); auto const sC0 = makeManifest( kpB2.second, KeyType::Ed25519, randomSecretKey(), KeyType::Ed25519, 47); BEAST_EXPECT( - cache.applyManifest(clone(sC0), ManifestRateLimitCap::Capped) == + cache.applyManifest(clone(sC0), ManifestRateLimitCapPolicy::Capped) == ManifestDisposition::BadMasterKey); } diff --git a/src/test/app/ValidatorList_test.cpp b/src/test/app/ValidatorList_test.cpp index 3fede86637..d39789f183 100644 --- a/src/test/app/ValidatorList_test.cpp +++ b/src/test/app/ValidatorList_test.cpp @@ -279,7 +279,7 @@ private: // NOLINTBEGIN(bugprone-unchecked-optional-access) manifests.applyManifest( - *deserializeManifest(cfgManifest), ManifestRateLimitCap::Capped); + *deserializeManifest(cfgManifest), ManifestRateLimitCapPolicy::Capped); // NOLINTEND(bugprone-unchecked-optional-access) BEAST_EXPECT( trustedKeys->load(localSigningPublicOuter, emptyCfgKeys, emptyCfgPublishers)); @@ -372,7 +372,7 @@ private: // NOLINTBEGIN(bugprone-unchecked-optional-access) manifests.applyManifest( - *deserializeManifest(cfgManifest), ManifestRateLimitCap::Capped); + *deserializeManifest(cfgManifest), ManifestRateLimitCapPolicy::Capped); // NOLINTEND(bugprone-unchecked-optional-access) BEAST_EXPECT(trustedKeys->load(localSigningPublicOuter, cfgKeys, emptyCfgPublishers)); @@ -466,7 +466,7 @@ private: pubRevokedSigning.first, pubRevokedSigning.second, std::numeric_limits::max())), - ManifestRateLimitCap::Capped); + ManifestRateLimitCapPolicy::Capped); // NOLINTEND(bugprone-unchecked-optional-access) // these two are not revoked (and not in the manifest cache at all.) @@ -508,7 +508,7 @@ private: pubRevokedSigning.first, pubRevokedSigning.second, std::numeric_limits::max())), - ManifestRateLimitCap::Capped); + ManifestRateLimitCapPolicy::Capped); // NOLINTEND(bugprone-unchecked-optional-access) // this one is not revoked (and not in the manifest cache at all.) @@ -1174,7 +1174,7 @@ private: BEAST_EXPECT( // NOLINTNEXTLINE(bugprone-unchecked-optional-access) - manifestsOuter.applyManifest(std::move(*m1), ManifestRateLimitCap::Capped) == + manifestsOuter.applyManifest(std::move(*m1), ManifestRateLimitCapPolicy::Capped) == ManifestDisposition::Accepted); BEAST_EXPECT(trustedKeysOuter->listed(masterPublic)); BEAST_EXPECT(trustedKeysOuter->trusted(masterPublic)); @@ -1189,7 +1189,7 @@ private: masterPublic, masterPrivate, signingPublic2, signingKeys2.second, 2)); BEAST_EXPECT( // NOLINTNEXTLINE(bugprone-unchecked-optional-access) - manifestsOuter.applyManifest(std::move(*m2), ManifestRateLimitCap::Capped) == + manifestsOuter.applyManifest(std::move(*m2), ManifestRateLimitCapPolicy::Capped) == ManifestDisposition::Accepted); BEAST_EXPECT(trustedKeysOuter->listed(masterPublic)); BEAST_EXPECT(trustedKeysOuter->trusted(masterPublic)); @@ -1207,7 +1207,7 @@ private: // NOLINTBEGIN(bugprone-unchecked-optional-access) BEAST_EXPECT(max->revoked()); BEAST_EXPECT( - manifestsOuter.applyManifest(std::move(*max), ManifestRateLimitCap::Capped) == + manifestsOuter.applyManifest(std::move(*max), ManifestRateLimitCapPolicy::Capped) == ManifestDisposition::Accepted); // NOLINTEND(bugprone-unchecked-optional-access) @@ -2627,7 +2627,7 @@ private: { valManifests.applyManifest( *deserializeManifest(base64Decode(self->manifest)), - ManifestRateLimitCap::Capped); + ManifestRateLimitCapPolicy::Capped); BEAST_EXPECT( result->load(self->signingPublic, emptyCfgKeys, cfgPublishers, threshold)); } diff --git a/src/xrpld/app/misc/detail/ValidatorList.cpp b/src/xrpld/app/misc/detail/ValidatorList.cpp index 5c95eb553c..0e29149e9c 100644 --- a/src/xrpld/app/misc/detail/ValidatorList.cpp +++ b/src/xrpld/app/misc/detail/ValidatorList.cpp @@ -1105,8 +1105,8 @@ ValidatorList::updatePublisherList( continue; } - if (auto const r = - validatorManifests_.applyManifest(std::move(*m), ManifestRateLimitCap::Uncapped); + if (auto const r = validatorManifests_.applyManifest( + std::move(*m), ManifestRateLimitCapPolicy::Uncapped); r == ManifestDisposition::Invalid) { JLOG(j_.warn()) << "List for " << strHex(pubKey) @@ -1362,8 +1362,8 @@ ValidatorList::verify( // Publisher keys are configured/trusted (checked above), so bypass the // untrusted cap. - auto const result = - publisherManifests_.applyManifest(std::move(manifest), ManifestRateLimitCap::Uncapped); + auto const result = publisherManifests_.applyManifest( + std::move(manifest), ManifestRateLimitCapPolicy::Uncapped); if (revoked && result == ManifestDisposition::Accepted) { diff --git a/src/xrpld/overlay/Message.h b/src/xrpld/overlay/Message.h index cd21ca40c6..30c30a5f2c 100644 --- a/src/xrpld/overlay/Message.h +++ b/src/xrpld/overlay/Message.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include @@ -13,6 +14,13 @@ namespace xrpl { constexpr std::size_t kMaximumMessageSize = megabytes(64); +// Upper bound on the wire size of a TMManifests message: kMaxManifestsPerMessage entries +// of at most kMaxManifestBytes each, plus a small allowance for protobuf +// framing per entry. +constexpr std::size_t kManifestFramingBytes = 8; +constexpr std::size_t kMaximumManifestsMessageSize = + kMaxManifestsPerMessage * (kMaxManifestBytes + kManifestFramingBytes); + // VFALCO NOTE If we forward declare Message and write out shared_ptr // instead of using the in-class type alias, we can remove the // entire ripple.pb.h from the main headers. diff --git a/src/xrpld/overlay/detail/OverlayImpl.cpp b/src/xrpld/overlay/detail/OverlayImpl.cpp index 452d0f0339..a82f7a286d 100644 --- a/src/xrpld/overlay/detail/OverlayImpl.cpp +++ b/src/xrpld/overlay/detail/OverlayImpl.cpp @@ -43,6 +43,7 @@ #include #include #include +#include #include #include #include @@ -661,12 +662,17 @@ OverlayImpl::onManifests( std::shared_ptr const& m, std::shared_ptr const& from) { - auto const n = m->list_size(); auto const& journal = from->pJournal(); + // Process every trusted manifest, but stop processing untrusted ones once + // kMaxManifestsPerMessage of them have been handled, so the work stays bounded. + auto const total = static_cast(m->list_size()); + std::size_t untrusted = 0; + bool skippedUntrusted = false; + protocol::TMManifests relay; - for (std::size_t i = 0; i < n; ++i) + for (std::size_t i = 0; i < total; ++i) { auto& s = m->list().Get(i).stobject(); @@ -677,6 +683,19 @@ OverlayImpl::onManifests( // lock: listed() takes the validator-list lock, so ordering it // first avoids holding the two locks in opposite orders. bool const isTrusted = app_.getValidators().listed(mo->masterKey); + + // Bound untrusted work: process at most kMaxManifestsPerMessage + // untrusted manifests, but never skip a trusted one. Trusted + // manifests are not counted against the cap. + if (!isTrusted) + { + if (untrusted >= kMaxManifestsPerMessage) + { + skippedUntrusted = true; + continue; + } + ++untrusted; + } // Updates to a known key are relayed even when untrusted. Use // getSequence, not getManifest, to avoid copying the cached payload // on this hot path. @@ -685,7 +704,8 @@ OverlayImpl::onManifests( auto const result = app_.getValidatorManifests().applyManifest( std::move(*mo), - isTrusted ? ManifestRateLimitCap::Uncapped : ManifestRateLimitCap::Capped); + isTrusted ? ManifestRateLimitCapPolicy::Uncapped + : ManifestRateLimitCapPolicy::Capped); if (result == ManifestDisposition::Accepted) { @@ -724,6 +744,18 @@ OverlayImpl::onManifests( } } + if (skippedUntrusted) + { + // The sender exceeded the untrusted per-message cap. Charge it (once, + // here) so a flood of untrusted manifests is penalized, while an honest + // message of trusted manifests never is. + from->charge(Resource::kFeeMalformedRequest, "too many untrusted manifests"); + + JLOG(journal.warn()) << "Manifests: message had " << total + << " entries; processed all trusted plus the first " + << kMaxManifestsPerMessage << " untrusted"; + } + if (!relay.list().empty()) { forEach([m2 = std::make_shared(relay, protocol::mtMANIFESTS)]( @@ -1225,15 +1257,60 @@ OverlayImpl::getManifestsMessage() if (auto seq = app_.getValidatorManifests().sequence(); seq != manifestListSeq_) { - protocol::TMManifests tm; - + // Phase 1: snapshot the cache under its own lock. Do not call + // Validators::listed() here — that takes the validator-list lock, and + // forEachManifest holds the manifest-cache lock, so consulting trust + // inside the callback would invert the lock order used elsewhere + // (see onManifests) and risk deadlock. Capture the manifest hash now, + // while we have the Manifest object, for the suppression key. + struct CachedManifest + { + PublicKey masterKey; + std::string serialized; + uint256 hash; + }; + std::vector cached; app_.getValidatorManifests().forEachManifest( - [&tm](std::size_t s) { tm.mutable_list()->Reserve(s); }, - [&tm, &hr = app_.getHashRouter()](Manifest const& manifest) { - tm.add_list()->set_stobject(manifest.serialized.data(), manifest.serialized.size()); - hr.addSuppression(manifest.hash()); + [&cached](std::size_t s) { cached.reserve(s); }, + [&cached](Manifest const& manifest) { + cached.push_back({manifest.masterKey, manifest.serialized, manifest.hash()}); }); + // Phase 2: no cache lock held, so trust checks are safe. Include every + // trusted manifest, then fill any remaining headroom up to + // kMaxManifestsPerMessage with untrusted gossip, so the whole message + // stays within the per-message cap the receiver enforces (trusted + // count is tiny in practice, so this effectively never drops trusted). + std::vector selected; + std::vector untrusted; + for (auto const& e : cached) + { + if (app_.getValidators().listed(e.masterKey)) + { + selected.push_back(&e); + } + else + { + untrusted.push_back(&e); + } + } + + // Cap untrusted only; trusted manifests are all included above. + auto const take = std::min(kMaxManifestsPerMessage, untrusted.size()); + selected.insert(selected.end(), untrusted.begin(), untrusted.begin() + take); + + // Shuffle the order. Cryptographic randomness is not needed here. + std::shuffle(selected.begin(), selected.end(), defaultPrng()); + + protocol::TMManifests tm; + auto& hr = app_.getHashRouter(); + tm.mutable_list()->Reserve(static_cast(selected.size())); + for (auto const* e : selected) + { + tm.add_list()->set_stobject(e->serialized.data(), e->serialized.size()); + hr.addSuppression(e->hash); + } + manifestMessage_.reset(); if (tm.list_size() != 0) diff --git a/src/xrpld/overlay/detail/PeerImp.cpp b/src/xrpld/overlay/detail/PeerImp.cpp index 822ef05304..7de96aa459 100644 --- a/src/xrpld/overlay/detail/PeerImp.cpp +++ b/src/xrpld/overlay/detail/PeerImp.cpp @@ -61,6 +61,7 @@ #include #include #include +#include #include #include #include @@ -1131,6 +1132,9 @@ PeerImp::onMessage(std::shared_ptr const& m) if (s > 100) fee_.update(Resource::kFeeModerateBurdenPeer, "oversize"); + // OverlayImpl::onManifests bounds the untrusted work and charges the fee + // if the untrusted count exceeds the per-message cap; trusted manifests + // are always processed and not counted against it. app_.getJobQueue().addJob(JtManifest, "RcvManifests", [this, that = shared_from_this(), m]() { overlay_.onManifests(m, that); }); diff --git a/src/xrpld/overlay/detail/ProtocolMessage.h b/src/xrpld/overlay/detail/ProtocolMessage.h index b1a30bad10..b181ea6307 100644 --- a/src/xrpld/overlay/detail/ProtocolMessage.h +++ b/src/xrpld/overlay/detail/ProtocolMessage.h @@ -351,6 +351,16 @@ invokeProtocolMessage(Buffers const& buffers, Handler& handler, std::size_t& hin return result; } + // Drop an oversized TMManifests without penalty: consume the bytes and + // return no error, so the connection is preserved. + if (header->messageType == protocol::mtMANIFESTS && + (header->payloadWireSize > kMaximumManifestsMessageSize || + header->uncompressedSize > kMaximumManifestsMessageSize)) + { + result.first = header->totalWireSize; + return result; + } + bool success = false; switch (header->messageType) From a88ba66fcea9b635a0e31df3b03ede0bc65c8a07 Mon Sep 17 00:00:00 2001 From: Bart Date: Fri, 31 Jul 2026 16:55:12 -0400 Subject: [PATCH 36/52] chore: Bump version to 3.2.1-rc1 --- src/libxrpl/protocol/BuildInfo.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libxrpl/protocol/BuildInfo.cpp b/src/libxrpl/protocol/BuildInfo.cpp index c488bb20de..323a5e628b 100644 --- a/src/libxrpl/protocol/BuildInfo.cpp +++ b/src/libxrpl/protocol/BuildInfo.cpp @@ -23,7 +23,7 @@ namespace { //------------------------------------------------------------------------------ // clang-format off // NOLINTNEXTLINE(readability-identifier-naming) -char const* const versionString = "3.2.0" +char const* const versionString = "3.2.1-rc1" // clang-format on ; From d4c1359921f34a4e96c5c8483119e59f0e30e4df Mon Sep 17 00:00:00 2001 From: Ed Hennis Date: Fri, 31 Jul 2026 19:46:17 -0400 Subject: [PATCH 37/52] chore: Bump version to 3.2.1 --- src/libxrpl/protocol/BuildInfo.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libxrpl/protocol/BuildInfo.cpp b/src/libxrpl/protocol/BuildInfo.cpp index 323a5e628b..48bcf2f598 100644 --- a/src/libxrpl/protocol/BuildInfo.cpp +++ b/src/libxrpl/protocol/BuildInfo.cpp @@ -23,7 +23,7 @@ namespace { //------------------------------------------------------------------------------ // clang-format off // NOLINTNEXTLINE(readability-identifier-naming) -char const* const versionString = "3.2.1-rc1" +char const* const versionString = "3.2.1" // clang-format on ; From 8461ded0d8a1692be899d76266f2c8a6d160aa48 Mon Sep 17 00:00:00 2001 From: Pratik Mankawde <3397372+pratikmankawde@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:00:11 +0100 Subject: [PATCH 38/52] fix: Cap untrusted manifests per message and drop oversized ones --- include/xrpl/basics/base64.h | 8 +- include/xrpl/server/Manifest.h | 31 +++++-- src/libxrpl/server/Manifest.cpp | 8 +- src/libxrpl/server/Wallet.cpp | 2 +- src/test/app/Manifest_test.cpp | 38 ++++---- src/test/app/ValidatorList_test.cpp | 16 ++-- src/xrpld/app/misc/detail/ValidatorList.cpp | 8 +- src/xrpld/overlay/Message.h | 8 ++ src/xrpld/overlay/detail/OverlayImpl.cpp | 98 +++++++++++++++++++-- src/xrpld/overlay/detail/PeerImp.cpp | 4 + src/xrpld/overlay/detail/ProtocolMessage.h | 10 +++ 11 files changed, 175 insertions(+), 56 deletions(-) diff --git a/include/xrpl/basics/base64.h b/include/xrpl/basics/base64.h index 4c743531a6..30fdc1f118 100644 --- a/include/xrpl/basics/base64.h +++ b/include/xrpl/basics/base64.h @@ -56,16 +56,16 @@ encodedSize(std::size_t const nBytes) } /** - * Returns the maximum number of bytes a base64 string of @p nChars characters + * Returns the maximum number of bytes a base64 string of @p numChars characters * decodes to. * - * @param nChars Number of base64 characters. + * @param numChars Number of base64 characters. * @return Upper bound on the number of decoded bytes. */ constexpr std::size_t -decodedSize(std::size_t const nChars) +decodedSize(std::size_t const numChars) { - return ((nChars / 4) * 3) + 2; + return ((numChars / 4) * 3) + 2; } } // namespace base64 diff --git a/include/xrpl/server/Manifest.h b/include/xrpl/server/Manifest.h index 452047ecd7..2de5bf4752 100644 --- a/include/xrpl/server/Manifest.h +++ b/include/xrpl/server/Manifest.h @@ -51,8 +51,8 @@ namespace xrpl { entry (which contains the manifest for this validator) is decoded and added to the manifest cache. Other manifests are added as "gossip" received from xrpld peers, including ones for validators this node does not - list. Manifests for unlisted validators are capped (kMaxUntrustedCount) - so peer gossip cannot grow the cache without bound; listed validators are + trust. Manifests for untrusted validators are capped (kMaxUntrustedCount) + so peer gossip cannot grow the cache without bound; trusted validators are not capped. Entries are never evicted, so a stored revocation is permanent. When an ephemeral key is compromised, a new signing key pair is created, @@ -170,14 +170,14 @@ std::string to_string(Manifest const& m); /** - *Largest a valid manifest can be, in decoded bytes. + * Largest a valid manifest can be, in decoded bytes. * * A manifest has a fixed set of fields. Each is serialized as a field header * (1-2 bytes), an optional length prefix (1 byte for these sizes), and the * field body. Taking every field at its largest gives the maximum below, so * anything larger cannot be a valid manifest. * - * Field header + length + body = bytes + * Field header + length + body = bytes * sfVersion (U16) 2 0 2 4 * sfSequence (U32) 1 0 4 5 * sfPublicKey (33) 1 1 33 35 @@ -200,6 +200,23 @@ constexpr std::size_t kMaxManifestBytes = 358; */ constexpr std::size_t kMaxManifestBase64 = base64::encodedSize(kMaxManifestBytes); +/** + * Maximum number of manifests carried in a single TMManifests message. + * + * Outbound, the TMManifests message sent to a peer includes every trusted + * manifest and fills the rest of this budget with untrusted gossip, so it + * never exceeds this size. Inbound, trusted manifests are always processed + * and untrusted ones are processed up to this many, so a peer sending its + * whole cache cannot force unbounded work. + * + * The trusted set is tiny relative to this bound, so trusted manifests are + * not dropped in practice. This is a transitional per-message cap; the cache + * already bounds untrusted manifests (see kMaxUntrustedCount), so it is no + * longer needed once the network has upgraded past nodes that send their + * whole cache in one message. + */ +constexpr std::size_t kMaxManifestsPerMessage = 200; + /** * Constructs Manifest from serialized string * @@ -208,7 +225,7 @@ constexpr std::size_t kMaxManifestBase64 = base64::encodedSize(kMaxManifestBytes * @return `std::nullopt` if string is invalid * * @note This does not verify manifest signatures. - * `Manifest::verify` should be called after constructing manifest. + * `Manifest::verify` should be called after constructing manifest. */ /** @{ */ std::optional @@ -303,7 +320,7 @@ to_string(ManifestDisposition m) * must choose. `Capped` is the safe, flood-resistant value; only listed or * configured keys should use `Uncapped`. */ -enum class ManifestRateLimitCap : std::uint8_t { +enum class ManifestRateLimitCapPolicy : std::uint8_t { Capped, ///< Subject to the untrusted cap (unlisted peer gossip) Uncapped ///< Bypasses the cap (listed/trusted or config manifests) }; @@ -469,7 +486,7 @@ public: * May be called concurrently */ ManifestDisposition - applyManifest(Manifest m, ManifestRateLimitCap cap); + applyManifest(Manifest m, ManifestRateLimitCapPolicy cap); /** * Stop counting a master key against the untrusted cap. diff --git a/src/libxrpl/server/Manifest.cpp b/src/libxrpl/server/Manifest.cpp index b34955dc28..3da3f9e9cd 100644 --- a/src/libxrpl/server/Manifest.cpp +++ b/src/libxrpl/server/Manifest.cpp @@ -382,9 +382,9 @@ ManifestCache::revoked(PublicKey const& pk) const } ManifestDisposition -ManifestCache::applyManifest(Manifest m, ManifestRateLimitCap const cap) +ManifestCache::applyManifest(Manifest m, ManifestRateLimitCapPolicy const cap) { - bool const uncapped = cap == ManifestRateLimitCap::Uncapped; + bool const uncapped = cap == ManifestRateLimitCapPolicy::Uncapped; // The signature is checked only on the first `prewriteCheck` run (under the // read lock). It is expensive, so `checkSignature` is cleared the first @@ -634,7 +634,7 @@ ManifestCache::load( JLOG(j_.warn()) << "Configured manifest revokes public key"; } - if (applyManifest(std::move(*mo), ManifestRateLimitCap::Uncapped) == + if (applyManifest(std::move(*mo), ManifestRateLimitCapPolicy::Uncapped) == ManifestDisposition::Invalid) { JLOG(j_.error()) << "Manifest in config was rejected"; @@ -658,7 +658,7 @@ ManifestCache::load( auto mo = deserializeManifest(base64Decode(revocationStr)); if (!mo || !mo->revoked() || - applyManifest(std::move(*mo), ManifestRateLimitCap::Uncapped) == + applyManifest(std::move(*mo), ManifestRateLimitCapPolicy::Uncapped) == ManifestDisposition::Invalid) { JLOG(j_.error()) << "Invalid validator key revocation in config"; diff --git a/src/libxrpl/server/Wallet.cpp b/src/libxrpl/server/Wallet.cpp index e6af5b6411..42ac80ef3f 100644 --- a/src/libxrpl/server/Wallet.cpp +++ b/src/libxrpl/server/Wallet.cpp @@ -80,7 +80,7 @@ getManifests( // Only trusted manifests are persisted (see saveManifests), so // anything loaded from the DB bypasses the untrusted cap. - cache.applyManifest(std::move(*mo), ManifestRateLimitCap::Uncapped); + cache.applyManifest(std::move(*mo), ManifestRateLimitCapPolicy::Uncapped); } else { diff --git a/src/test/app/Manifest_test.cpp b/src/test/app/Manifest_test.cpp index ae30414b92..ef2043a22c 100644 --- a/src/test/app/Manifest_test.cpp +++ b/src/test/app/Manifest_test.cpp @@ -400,7 +400,7 @@ public: ManifestDisposition::Accepted == cache.applyManifest( makeManifest(sk, KeyType::Ed25519, kp0.second, KeyType::Secp256k1, 0), - ManifestRateLimitCap::Capped)); + ManifestRateLimitCapPolicy::Capped)); BEAST_EXPECT(cache.getSigningKey(pk) == kp0.first); BEAST_EXPECT(cache.getMasterKey(kp0.first) == pk); @@ -413,7 +413,7 @@ public: ManifestDisposition::Accepted == cache.applyManifest( makeManifest(sk, KeyType::Ed25519, kp1.second, KeyType::Secp256k1, 1), - ManifestRateLimitCap::Capped)); + ManifestRateLimitCapPolicy::Capped)); BEAST_EXPECT(cache.getSigningKey(pk) == kp1.first); BEAST_EXPECT(cache.getMasterKey(kp1.first) == pk); BEAST_EXPECT(cache.getMasterKey(kp0.first) == kp0.first); @@ -424,7 +424,7 @@ public: ManifestDisposition::BadEphemeralKey == cache.applyManifest( makeManifest(sk, KeyType::Ed25519, kp1.second, KeyType::Secp256k1, 2), - ManifestRateLimitCap::Capped)); + ManifestRateLimitCapPolicy::Capped)); BEAST_EXPECT(cache.getSigningKey(pk) == kp1.first); BEAST_EXPECT(cache.getMasterKey(kp1.first) == pk); BEAST_EXPECT(cache.getMasterKey(kp0.first) == kp0.first); @@ -435,7 +435,7 @@ public: BEAST_EXPECT( ManifestDisposition::Accepted == cache.applyManifest( - makeRevocation(sk, KeyType::Ed25519), ManifestRateLimitCap::Capped)); + makeRevocation(sk, KeyType::Ed25519), ManifestRateLimitCapPolicy::Capped)); BEAST_EXPECT(cache.revoked(pk)); BEAST_EXPECT(cache.getSigningKey(pk) == pk); BEAST_EXPECT(cache.getMasterKey(kp0.first) == kp0.first); @@ -907,28 +907,28 @@ public: // higher sequence numbers auto const seq0 = cache.sequence(); BEAST_EXPECT( - cache.applyManifest(clone(sA0), ManifestRateLimitCap::Capped) == + cache.applyManifest(clone(sA0), ManifestRateLimitCapPolicy::Capped) == ManifestDisposition::Accepted); BEAST_EXPECT(cache.sequence() > seq0); auto const seq1 = cache.sequence(); BEAST_EXPECT( - cache.applyManifest(clone(sA0), ManifestRateLimitCap::Capped) == + cache.applyManifest(clone(sA0), ManifestRateLimitCapPolicy::Capped) == ManifestDisposition::Stale); BEAST_EXPECT(cache.sequence() == seq1); BEAST_EXPECT( - cache.applyManifest(clone(sA1), ManifestRateLimitCap::Capped) == + cache.applyManifest(clone(sA1), ManifestRateLimitCapPolicy::Capped) == ManifestDisposition::Accepted); BEAST_EXPECT( - cache.applyManifest(clone(sA1), ManifestRateLimitCap::Capped) == + cache.applyManifest(clone(sA1), ManifestRateLimitCapPolicy::Capped) == ManifestDisposition::Stale); BEAST_EXPECT( - cache.applyManifest(clone(sA0), ManifestRateLimitCap::Capped) == + cache.applyManifest(clone(sA0), ManifestRateLimitCapPolicy::Capped) == ManifestDisposition::Stale); BEAST_EXPECT( - cache.applyManifest(clone(sA2), ManifestRateLimitCap::Capped) == + cache.applyManifest(clone(sA2), ManifestRateLimitCapPolicy::Capped) == ManifestDisposition::BadEphemeralKey); // applyManifest should accept manifests with max sequence numbers @@ -936,38 +936,38 @@ public: BEAST_EXPECT(!cache.revoked(pkA)); BEAST_EXPECT(sAMax.revoked()); BEAST_EXPECT( - cache.applyManifest(clone(sAMax), ManifestRateLimitCap::Capped) == + cache.applyManifest(clone(sAMax), ManifestRateLimitCapPolicy::Capped) == ManifestDisposition::Accepted); BEAST_EXPECT( - cache.applyManifest(clone(sAMax), ManifestRateLimitCap::Capped) == + cache.applyManifest(clone(sAMax), ManifestRateLimitCapPolicy::Capped) == ManifestDisposition::Stale); BEAST_EXPECT( - cache.applyManifest(clone(sA1), ManifestRateLimitCap::Capped) == + cache.applyManifest(clone(sA1), ManifestRateLimitCapPolicy::Capped) == ManifestDisposition::Stale); BEAST_EXPECT( - cache.applyManifest(clone(sA0), ManifestRateLimitCap::Capped) == + cache.applyManifest(clone(sA0), ManifestRateLimitCapPolicy::Capped) == ManifestDisposition::Stale); BEAST_EXPECT(cache.revoked(pkA)); // applyManifest should reject manifests with invalid signatures BEAST_EXPECT( - cache.applyManifest(clone(sB0), ManifestRateLimitCap::Capped) == + cache.applyManifest(clone(sB0), ManifestRateLimitCapPolicy::Capped) == ManifestDisposition::Accepted); BEAST_EXPECT( - cache.applyManifest(clone(sB0), ManifestRateLimitCap::Capped) == + cache.applyManifest(clone(sB0), ManifestRateLimitCapPolicy::Capped) == ManifestDisposition::Stale); BEAST_EXPECT(!deserializeManifest(fake)); BEAST_EXPECT( - cache.applyManifest(clone(sB1), ManifestRateLimitCap::Capped) == + cache.applyManifest(clone(sB1), ManifestRateLimitCapPolicy::Capped) == ManifestDisposition::Invalid); BEAST_EXPECT( - cache.applyManifest(clone(sB2), ManifestRateLimitCap::Capped) == + cache.applyManifest(clone(sB2), ManifestRateLimitCapPolicy::Capped) == ManifestDisposition::Accepted); auto const sC0 = makeManifest( kpB2.second, KeyType::Ed25519, randomSecretKey(), KeyType::Ed25519, 47); BEAST_EXPECT( - cache.applyManifest(clone(sC0), ManifestRateLimitCap::Capped) == + cache.applyManifest(clone(sC0), ManifestRateLimitCapPolicy::Capped) == ManifestDisposition::BadMasterKey); } diff --git a/src/test/app/ValidatorList_test.cpp b/src/test/app/ValidatorList_test.cpp index 0340b24680..323c77c780 100644 --- a/src/test/app/ValidatorList_test.cpp +++ b/src/test/app/ValidatorList_test.cpp @@ -280,7 +280,7 @@ private: // NOLINTBEGIN(bugprone-unchecked-optional-access) manifests.applyManifest( - *deserializeManifest(cfgManifest), ManifestRateLimitCap::Capped); + *deserializeManifest(cfgManifest), ManifestRateLimitCapPolicy::Capped); // NOLINTEND(bugprone-unchecked-optional-access) BEAST_EXPECT( trustedKeys->load(localSigningPublicOuter, emptyCfgKeys, emptyCfgPublishers)); @@ -373,7 +373,7 @@ private: // NOLINTBEGIN(bugprone-unchecked-optional-access) manifests.applyManifest( - *deserializeManifest(cfgManifest), ManifestRateLimitCap::Capped); + *deserializeManifest(cfgManifest), ManifestRateLimitCapPolicy::Capped); // NOLINTEND(bugprone-unchecked-optional-access) BEAST_EXPECT(trustedKeys->load(localSigningPublicOuter, cfgKeys, emptyCfgPublishers)); @@ -467,7 +467,7 @@ private: pubRevokedSigning.first, pubRevokedSigning.second, std::numeric_limits::max())), - ManifestRateLimitCap::Capped); + ManifestRateLimitCapPolicy::Capped); // NOLINTEND(bugprone-unchecked-optional-access) // these two are not revoked (and not in the manifest cache at all.) @@ -509,7 +509,7 @@ private: pubRevokedSigning.first, pubRevokedSigning.second, std::numeric_limits::max())), - ManifestRateLimitCap::Capped); + ManifestRateLimitCapPolicy::Capped); // NOLINTEND(bugprone-unchecked-optional-access) // this one is not revoked (and not in the manifest cache at all.) @@ -1228,7 +1228,7 @@ private: BEAST_EXPECT( // NOLINTNEXTLINE(bugprone-unchecked-optional-access) - manifestsOuter.applyManifest(std::move(*m1), ManifestRateLimitCap::Capped) == + manifestsOuter.applyManifest(std::move(*m1), ManifestRateLimitCapPolicy::Capped) == ManifestDisposition::Accepted); BEAST_EXPECT(trustedKeysOuter->listed(masterPublic)); BEAST_EXPECT(trustedKeysOuter->trusted(masterPublic)); @@ -1243,7 +1243,7 @@ private: masterPublic, masterPrivate, signingPublic2, signingKeys2.second, 2)); BEAST_EXPECT( // NOLINTNEXTLINE(bugprone-unchecked-optional-access) - manifestsOuter.applyManifest(std::move(*m2), ManifestRateLimitCap::Capped) == + manifestsOuter.applyManifest(std::move(*m2), ManifestRateLimitCapPolicy::Capped) == ManifestDisposition::Accepted); BEAST_EXPECT(trustedKeysOuter->listed(masterPublic)); BEAST_EXPECT(trustedKeysOuter->trusted(masterPublic)); @@ -1261,7 +1261,7 @@ private: // NOLINTBEGIN(bugprone-unchecked-optional-access) BEAST_EXPECT(max->revoked()); BEAST_EXPECT( - manifestsOuter.applyManifest(std::move(*max), ManifestRateLimitCap::Capped) == + manifestsOuter.applyManifest(std::move(*max), ManifestRateLimitCapPolicy::Capped) == ManifestDisposition::Accepted); // NOLINTEND(bugprone-unchecked-optional-access) @@ -2683,7 +2683,7 @@ private: { valManifests.applyManifest( *deserializeManifest(base64Decode(self->manifest)), - ManifestRateLimitCap::Capped); + ManifestRateLimitCapPolicy::Capped); BEAST_EXPECT( result->load(self->signingPublic, emptyCfgKeys, cfgPublishers, threshold)); } diff --git a/src/xrpld/app/misc/detail/ValidatorList.cpp b/src/xrpld/app/misc/detail/ValidatorList.cpp index 1b4d2bab80..e355cfacab 100644 --- a/src/xrpld/app/misc/detail/ValidatorList.cpp +++ b/src/xrpld/app/misc/detail/ValidatorList.cpp @@ -1105,8 +1105,8 @@ ValidatorList::updatePublisherList( continue; } - if (auto const r = - validatorManifests_.applyManifest(std::move(*m), ManifestRateLimitCap::Uncapped); + if (auto const r = validatorManifests_.applyManifest( + std::move(*m), ManifestRateLimitCapPolicy::Uncapped); r == ManifestDisposition::Invalid) { JLOG(j_.warn()) << "List for " << strHex(pubKey) @@ -1362,8 +1362,8 @@ ValidatorList::verify( // Publisher keys are configured/trusted (checked above), so bypass the // untrusted cap. - auto const result = - publisherManifests_.applyManifest(std::move(manifest), ManifestRateLimitCap::Uncapped); + auto const result = publisherManifests_.applyManifest( + std::move(manifest), ManifestRateLimitCapPolicy::Uncapped); if (revoked && result == ManifestDisposition::Accepted) { diff --git a/src/xrpld/overlay/Message.h b/src/xrpld/overlay/Message.h index e942ed244a..bd4772b451 100644 --- a/src/xrpld/overlay/Message.h +++ b/src/xrpld/overlay/Message.h @@ -4,6 +4,7 @@ #include #include +#include #include @@ -23,6 +24,13 @@ constexpr std::size_t kMaximumMessageSize = megabytes(64); // so we define a separate limit for them. constexpr std::size_t kMaximumPingMessageSize = kilobytes(1); +// Upper bound on the wire size of a TMManifests message: kMaxManifestsPerMessage entries +// of at most kMaxManifestBytes each, plus a small allowance for protobuf +// framing per entry. +constexpr std::size_t kManifestFramingBytes = 8; +constexpr std::size_t kMaximumManifestsMessageSize = + kMaxManifestsPerMessage * (kMaxManifestBytes + kManifestFramingBytes); + // VFALCO NOTE If we forward declare Message and write out shared_ptr // instead of using the in-class type alias, we can remove the // entire ripple.pb.h from the main headers. diff --git a/src/xrpld/overlay/detail/OverlayImpl.cpp b/src/xrpld/overlay/detail/OverlayImpl.cpp index e59cff85d8..5bac7df720 100644 --- a/src/xrpld/overlay/detail/OverlayImpl.cpp +++ b/src/xrpld/overlay/detail/OverlayImpl.cpp @@ -44,6 +44,7 @@ #include #include #include +#include #include #include #include @@ -663,12 +664,17 @@ OverlayImpl::onManifests( std::shared_ptr const& m, std::shared_ptr const& from) { - auto const n = m->list_size(); auto const& journal = from->pJournal(); + // Process every trusted manifest, but stop processing untrusted ones once + // kMaxManifestsPerMessage of them have been handled, so the work stays bounded. + auto const total = static_cast(m->list_size()); + std::size_t untrusted = 0; + bool skippedUntrusted = false; + protocol::TMManifests relay; - for (std::size_t i = 0; i < n; ++i) + for (std::size_t i = 0; i < total; ++i) { auto& s = m->list().Get(i).stobject(); @@ -679,6 +685,19 @@ OverlayImpl::onManifests( // lock: listed() takes the validator-list lock, so ordering it // first avoids holding the two locks in opposite orders. bool const isTrusted = app_.getValidators().listed(mo->masterKey); + + // Bound untrusted work: process at most kMaxManifestsPerMessage + // untrusted manifests, but never skip a trusted one. Trusted + // manifests are not counted against the cap. + if (!isTrusted) + { + if (untrusted >= kMaxManifestsPerMessage) + { + skippedUntrusted = true; + continue; + } + ++untrusted; + } // Updates to a known key are relayed even when untrusted. Use // getSequence, not getManifest, to avoid copying the cached payload // on this hot path. @@ -687,7 +706,8 @@ OverlayImpl::onManifests( auto const result = app_.getValidatorManifests().applyManifest( std::move(*mo), - isTrusted ? ManifestRateLimitCap::Uncapped : ManifestRateLimitCap::Capped); + isTrusted ? ManifestRateLimitCapPolicy::Uncapped + : ManifestRateLimitCapPolicy::Capped); if (result == ManifestDisposition::Accepted) { @@ -726,6 +746,18 @@ OverlayImpl::onManifests( } } + if (skippedUntrusted) + { + // The sender exceeded the untrusted per-message cap. Charge it (once, + // here) so a flood of untrusted manifests is penalized, while an honest + // message of trusted manifests never is. + from->charge(Resource::kFeeMalformedRequest, "too many untrusted manifests"); + + JLOG(journal.warn()) << "Manifests: message had " << total + << " entries; processed all trusted plus the first " + << kMaxManifestsPerMessage << " untrusted"; + } + if (!relay.list().empty()) { forEach([m2 = std::make_shared(relay, protocol::mtMANIFESTS)]( @@ -1228,15 +1260,63 @@ OverlayImpl::getManifestsMessage() if (auto seq = app_.getValidatorManifests().sequence(); seq != manifestListSeq_) { - protocol::TMManifests tm; - + // Phase 1: snapshot the cache under its own lock. Do not call + // Validators::listed() here — that takes the validator-list lock, and + // forEachManifest holds the manifest-cache lock, so consulting trust + // inside the callback would invert the lock order used elsewhere + // (see onManifests) and risk deadlock. Capture the manifest hash now, + // while we have the Manifest object, for the suppression key. + struct CachedManifest + { + PublicKey masterKey; + std::string serialized; + uint256 hash; + }; + std::vector cached; app_.getValidatorManifests().forEachManifest( - [&tm](std::size_t s) { tm.mutable_list()->Reserve(s); }, - [&tm, &hr = app_.getHashRouter()](Manifest const& manifest) { - tm.add_list()->set_stobject(manifest.serialized.data(), manifest.serialized.size()); - hr.addSuppression(manifest.hash()); + [&cached](std::size_t s) { cached.reserve(s); }, + [&cached](Manifest const& manifest) { + cached.push_back( + {.masterKey = manifest.masterKey, + .serialized = manifest.serialized, + .hash = manifest.hash()}); }); + // Phase 2: no cache lock held, so trust checks are safe. Include every + // trusted manifest, then fill any remaining headroom up to + // kMaxManifestsPerMessage with untrusted gossip, so the whole message + // stays within the per-message cap the receiver enforces (trusted + // count is tiny in practice, so this effectively never drops trusted). + std::vector selected; + std::vector untrusted; + for (auto const& e : cached) + { + if (app_.getValidators().listed(e.masterKey)) + { + selected.push_back(&e); + } + else + { + untrusted.push_back(&e); + } + } + + // Cap untrusted only; trusted manifests are all included above. + auto const take = std::min(kMaxManifestsPerMessage, untrusted.size()); + selected.insert(selected.end(), untrusted.begin(), untrusted.begin() + take); + + // Shuffle the order. Cryptographic randomness is not needed here. + std::shuffle(selected.begin(), selected.end(), defaultPrng()); + + protocol::TMManifests tm; + auto& hr = app_.getHashRouter(); + tm.mutable_list()->Reserve(static_cast(selected.size())); + for (auto const* e : selected) + { + tm.add_list()->set_stobject(e->serialized.data(), e->serialized.size()); + hr.addSuppression(e->hash); + } + manifestMessage_.reset(); if (tm.list_size() != 0) diff --git a/src/xrpld/overlay/detail/PeerImp.cpp b/src/xrpld/overlay/detail/PeerImp.cpp index 962ab0f408..3644a441b8 100644 --- a/src/xrpld/overlay/detail/PeerImp.cpp +++ b/src/xrpld/overlay/detail/PeerImp.cpp @@ -60,6 +60,7 @@ #include #include #include +#include #include #include #include @@ -1103,6 +1104,9 @@ PeerImp::onMessage(std::shared_ptr const& m) if (s > 100) fee_.update(Resource::kFeeModerateBurdenPeer, "oversize"); + // OverlayImpl::onManifests bounds the untrusted work and charges the fee + // if the untrusted count exceeds the per-message cap; trusted manifests + // are always processed and not counted against it. app_.getJobQueue().addJob(JtManifest, "RcvManifests", [this, that = shared_from_this(), m]() { overlay_.onManifests(m, that); }); diff --git a/src/xrpld/overlay/detail/ProtocolMessage.h b/src/xrpld/overlay/detail/ProtocolMessage.h index 7c22a8e84c..abd087f3c8 100644 --- a/src/xrpld/overlay/detail/ProtocolMessage.h +++ b/src/xrpld/overlay/detail/ProtocolMessage.h @@ -374,6 +374,16 @@ invokeProtocolMessage(Buffers const& buffers, Handler& handler, std::size_t& hin return result; } + // Drop an oversized TMManifests without penalty: consume the bytes and + // return no error, so the connection is preserved. + if (header->messageType == protocol::mtMANIFESTS && + (header->payloadWireSize > kMaximumManifestsMessageSize || + header->uncompressedSize > kMaximumManifestsMessageSize)) + { + result.first = header->totalWireSize; + return result; + } + bool success = false; switch (header->messageType) From c3ee602002e169521707246ea366d13640296e79 Mon Sep 17 00:00:00 2001 From: Vito Tumas <5780819+Tapanito@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:43:59 +0200 Subject: [PATCH 39/52] test: Split Loan_test.cpp into topical suites (#7864) Co-authored-by: Ayaz Salikhov --- include/xrpl/beast/unit_test/suite.h | 14 + sanitizers/suppressions/ubsan.supp | 2 +- src/test/app/Loan_test.cpp | 9756 ----------------- .../app/{ => lending}/LendingHelpers_test.cpp | 0 .../app/{ => lending}/LoanBroker_test.cpp | 6 +- src/test/app/lending/LoanCashBasis_test.cpp | 1013 ++ .../app/lending/LoanCoverFreezeAuth_test.cpp | 722 ++ src/test/app/lending/LoanInvariants_test.cpp | 873 ++ src/test/app/lending/LoanLifecycle_test.cpp | 689 ++ src/test/app/lending/LoanMisc_test.cpp | 561 + src/test/app/lending/LoanPay_test.cpp | 760 ++ src/test/app/lending/LoanRounding_test.cpp | 993 ++ src/test/app/lending/LoanSecurity_test.cpp | 538 + src/test/app/lending/LoanSet_test.cpp | 607 + src/test/app/lending/LoanTestBase.h | 2949 +++++ src/test/app/lending/LoanValidation_test.cpp | 558 + src/test/app/lending/Loan_test.cpp | 46 + 17 files changed, 10327 insertions(+), 9760 deletions(-) delete mode 100644 src/test/app/Loan_test.cpp rename src/test/app/{ => lending}/LendingHelpers_test.cpp (100%) rename src/test/app/{ => lending}/LoanBroker_test.cpp (99%) create mode 100644 src/test/app/lending/LoanCashBasis_test.cpp create mode 100644 src/test/app/lending/LoanCoverFreezeAuth_test.cpp create mode 100644 src/test/app/lending/LoanInvariants_test.cpp create mode 100644 src/test/app/lending/LoanLifecycle_test.cpp create mode 100644 src/test/app/lending/LoanMisc_test.cpp create mode 100644 src/test/app/lending/LoanPay_test.cpp create mode 100644 src/test/app/lending/LoanRounding_test.cpp create mode 100644 src/test/app/lending/LoanSecurity_test.cpp create mode 100644 src/test/app/lending/LoanSet_test.cpp create mode 100644 src/test/app/lending/LoanTestBase.h create mode 100644 src/test/app/lending/LoanValidation_test.cpp create mode 100644 src/test/app/lending/Loan_test.cpp diff --git a/include/xrpl/beast/unit_test/suite.h b/include/xrpl/beast/unit_test/suite.h index c20fe2522c..e24904a87b 100644 --- a/include/xrpl/beast/unit_test/suite.h +++ b/include/xrpl/beast/unit_test/suite.h @@ -295,6 +295,20 @@ public: return runner_->arg(); } +protected: + /** + * Lets a suite compose other suites (e.g. an aggregator that reruns a + * group of related suites under its own name) via `SuiteInfo::run`. + * + * @return The runner this suite is executing under. + */ + Runner& + runner() const + { + return *runner_; + } + +public: /** * DEPRECATED * @return `true` if the test condition indicates success(a false value) diff --git a/sanitizers/suppressions/ubsan.supp b/sanitizers/suppressions/ubsan.supp index a67a4a0ca3..cb93a617aa 100644 --- a/sanitizers/suppressions/ubsan.supp +++ b/sanitizers/suppressions/ubsan.supp @@ -194,7 +194,7 @@ unsigned-integer-overflow:tests/libxrpl/basics/RangeSet.cpp unsigned-integer-overflow:test/app/Batch_test.cpp unsigned-integer-overflow:test/app/ConfidentialTransfer_test.cpp unsigned-integer-overflow:test/app/Invariants_test.cpp -unsigned-integer-overflow:test/app/Loan_test.cpp +unsigned-integer-overflow:test/app/lending/LoanSecurity_test.cpp unsigned-integer-overflow:test/app/NFToken_test.cpp unsigned-integer-overflow:test/app/OfferMPT_test.cpp unsigned-integer-overflow:test/app/Offer_test.cpp diff --git a/src/test/app/Loan_test.cpp b/src/test/app/Loan_test.cpp deleted file mode 100644 index 977cdb443c..0000000000 --- a/src/test/app/Loan_test.cpp +++ /dev/null @@ -1,9756 +0,0 @@ -#include -// -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace xrpl::test { - -class Loan_test : public beast::unit_test::Suite -{ -protected: - // Ensure that all the features needed for Lending Protocol are included, - // even if they are set to unsupported. - // - // featureLendingProtocolV1_1 is excluded from the default set: it changes - // Vault/LoanBroker accounting (AssetsTotal/DebtTotal/LossUnrealized), and - // most of this file's tests assert whole-life-specific expected values - // for those fields. Tests that specifically exercise the amendment opt - // it back in explicitly (e.g. `all_ | featureLendingProtocolV1_1`). - FeatureBitset const all_{jtx::testableAmendments() - featureLendingProtocolV1_1}; - std::string const iouCurrency_{"IOU"}; - - void - testDisabled() - { - testcase("Disabled"); - // Lending Protocol depends on Single Asset Vault (SAV). Test - // combinations of the two amendments. - // Single Asset Vault depends on MPTokensV1, but don't test every combo - // of that. - using namespace jtx; - auto failAll = [this](FeatureBitset features) { - Env env(*this, features); - - Account const alice{"alice"}; - Account const bob{"bob"}; - env.fund(XRP(10000), alice, bob); - - auto const keylet = keylet::loanBroker(alice, env.seq(alice)); - - using namespace std::chrono_literals; - using namespace loan; - - // counter party signature is optional on LoanSet. Confirm that by - // sending transaction without one. - auto setTx = env.jt(set(alice, keylet.key, Number(10000)), Ter(temDISABLED)); - env(setTx); - - // All loan transactions are disabled. - // 1. LoanSet - setTx = env.jt(setTx, Sig(sfCounterpartySignature, bob), Ter(temDISABLED)); - env(setTx); - // Actual sequence will be based off the loan broker, but we - // obviously don't have one of those if the amendment is disabled - auto const loanKeylet = keylet::loan(keylet.key, env.seq(alice)); - // Other Loan transactions are disabled, too. - // 2. LoanDelete - env(del(alice, loanKeylet.key), Ter(temDISABLED)); - // 3. LoanManage - env(manage(alice, loanKeylet.key, tfLoanImpair), Ter(temDISABLED)); - // 4. LoanPay - env(pay(alice, loanKeylet.key, XRP(500)), Ter(temDISABLED)); - }; - failAll(all_ - featureMPTokensV1); - failAll(all_ - featureSingleAssetVault - featureLendingProtocol); - failAll(all_ - featureSingleAssetVault); - failAll(all_ - featureLendingProtocol); - } - - struct BrokerParameters - { - Number vaultDeposit = 1'000'000; - Number debtMax = 25'000; - TenthBips32 coverRateMin = percentageToTenthBips(10); - int coverDeposit = 1000; - TenthBips16 managementFeeRate{100}; - TenthBips32 coverRateLiquidation = percentageToTenthBips(25); - std::string data = {}; // NOLINT(readability-redundant-member-init) - std::uint32_t flags = 0; - // If set, the vault is created with this sfScale value. Useful for - // tests that need finer loanScale to exercise rounding edge cases. - std::optional vaultScale = - std::nullopt; // NOLINT(readability-redundant-member-init) - - [[nodiscard]] Number - maxCoveredLoanValue(Number const& currentDebt) const - { - NumberRoundModeGuard const mg(Number::RoundingMode::Downward); - auto debtLimit = coverDeposit * kTenthBipsPerUnity.value() / coverRateMin.value(); - - return debtLimit - currentDebt; - } - - static BrokerParameters const& - defaults() - { - static BrokerParameters const kResult{}; - return kResult; - } - - // TODO: create an operator() which returns a transaction similar to - // LoanParameters - }; - - struct BrokerInfo - { - jtx::PrettyAsset asset; - uint256 brokerID; - uint256 vaultID; - BrokerParameters params; - BrokerInfo( - jtx::PrettyAsset const& asset, - Keylet const& brokerKeylet, - Keylet const& vaultKeylet, - BrokerParameters p) - : asset(asset) - , brokerID(brokerKeylet.key) - , vaultID(vaultKeylet.key) - , params(std::move(p)) - { - } - - [[nodiscard]] Keylet - brokerKeylet() const - { - return keylet::loanBroker(brokerID); - } - [[nodiscard]] Keylet - vaultKeylet() const - { - return keylet::vault(vaultID); - } - - [[nodiscard]] int - vaultScale(jtx::Env const& env) const - { - using namespace jtx; - - auto const vaultSle = env.le(keylet::vault(vaultID)); - return getAssetsTotalScale(vaultSle); - } - }; - - struct LoanParameters - { - // The account submitting the transaction. May be borrower or broker. - jtx::Account account; - // The counterparty. Should be the other of borrower or broker. - jtx::Account counter; - // Whether the counterparty is specified in the `counterparty` field, or - // only signs. - bool counterpartyExplicit = true; - Number principalRequest; - // NOLINTBEGIN(readability-redundant-member-init) - std::optional setFee = std::nullopt; - std::optional originationFee = std::nullopt; - std::optional serviceFee = std::nullopt; - std::optional lateFee = std::nullopt; - std::optional closeFee = std::nullopt; - std::optional overFee = std::nullopt; - std::optional interest = std::nullopt; - std::optional lateInterest = std::nullopt; - std::optional closeInterest = std::nullopt; - std::optional overpaymentInterest = std::nullopt; - std::optional payTotal = std::nullopt; - std::optional payInterval = std::nullopt; - std::optional gracePd = std::nullopt; - std::optional flags = std::nullopt; - // NOLINTEND(readability-redundant-member-init) - - template - jtx::JTx - operator()(jtx::Env& env, BrokerInfo const& broker, FN const&... fN) const - { - using namespace jtx; - using namespace jtx::loan; - - JTx jt{loan::set( - account, - broker.brokerID, - broker.asset(principalRequest).number(), - flags.value_or(0))}; - - Sig(sfCounterpartySignature, counter)(env, jt); - - Fee{setFee.value_or(env.current()->fees().base * 2)}(env, jt); - - if (counterpartyExplicit) - kCounterparty(counter)(env, jt); - if (originationFee) - kLoanOriginationFee(broker.asset(*originationFee).number())(env, jt); - if (serviceFee) - kLoanServiceFee(broker.asset(*serviceFee).number())(env, jt); - if (lateFee) - kLatePaymentFee(broker.asset(*lateFee).number())(env, jt); - if (closeFee) - kClosePaymentFee(broker.asset(*closeFee).number())(env, jt); - if (overFee) - kOverpaymentFee (*overFee)(env, jt); - if (interest) - kInterestRate (*interest)(env, jt); - if (lateInterest) - kLateInterestRate (*lateInterest)(env, jt); - if (closeInterest) - kCloseInterestRate (*closeInterest)(env, jt); - if (overpaymentInterest) - kOverpaymentInterestRate (*overpaymentInterest)(env, jt); - if (payTotal) - kPaymentTotal (*payTotal)(env, jt); - if (payInterval) - kPaymentInterval (*payInterval)(env, jt); - if (gracePd) - kGracePeriod (*gracePd)(env, jt); - - return env.jt(jt, fN...); - } - }; - - struct PaymentParameters - { - Number overpaymentFactor = Number{1}; - std::optional overpaymentExtra = std::nullopt; - std::uint32_t flags = 0; - bool showStepBalances = false; - bool validateBalances = true; - - static PaymentParameters const& - defaults() - { - static PaymentParameters const kResult{}; - return kResult; - } - }; - - struct LoanState - { - std::uint32_t previousPaymentDate = 0; - NetClock::time_point startDate; - std::uint32_t nextPaymentDate = 0; - std::uint32_t paymentRemaining = 0; - std::int32_t const loanScale = 0; - Number totalValue = 0; - Number principalOutstanding = 0; - Number managementFeeOutstanding = 0; - Number periodicPayment = 0; - std::uint32_t flags = 0; - std::uint32_t const paymentInterval = 0; - TenthBips32 const interestRate{}; - }; - - /** - * Helper class to compare the expected state of a loan and loan broker - * against the data in the ledger. - */ - struct VerifyLoanStatus - { - public: - jtx::Env const& env; - BrokerInfo const& broker; - jtx::Account const& pseudoAccount; - Keylet const& loanKeylet; - - VerifyLoanStatus( - jtx::Env const& env, - BrokerInfo const& broker, - jtx::Account const& pseudo, - Keylet const& keylet) - : env(env), broker(broker), pseudoAccount(pseudo), loanKeylet(keylet) - { - } - - /** - * Checks the expected broker state against the ledger - */ - void - checkBroker( - Number const& principalOutstanding, - Number const& interestOwed, - TenthBips32 interestRate, - std::uint32_t paymentInterval, - std::uint32_t paymentsRemaining, - std::uint32_t ownerCount) const - { - using namespace jtx; - if (auto brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - env.test.BEAST_EXPECT(brokerSle)) - { - TenthBips16 const managementFeeRate{brokerSle->at(sfManagementFeeRate)}; - auto const brokerDebt = brokerSle->at(sfDebtTotal); - - if (auto vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID))); - env.test.BEAST_EXPECT(vaultSle)) - { - auto const expectedDebt = - env.current()->rules().enabled(featureLendingProtocolV1_1) && - getVaultVersion(vaultSle) == VaultVersion::CashBasis - ? principalOutstanding - : principalOutstanding + interestOwed; - env.test.BEAST_EXPECT(brokerDebt == expectedDebt); - env.test.BEAST_EXPECT( - env.balance(pseudoAccount, broker.asset).number() == - brokerSle->at(sfCoverAvailable)); - env.test.BEAST_EXPECT(brokerSle->at(sfOwnerCount) == ownerCount); - - Account const vaultPseudo{"vaultPseudoAccount", vaultSle->at(sfAccount)}; - env.test.BEAST_EXPECT( - vaultSle->at(sfAssetsAvailable) == - env.balance(vaultPseudo, broker.asset).number()); - if (ownerCount == 0) - { - // The Vault must be perfectly balanced if there - // are no loans outstanding - auto const total = vaultSle->at(sfAssetsTotal); - auto const available = vaultSle->at(sfAssetsAvailable); - env.test.BEAST_EXPECT(total == available); - env.test.BEAST_EXPECT(vaultSle->at(sfLossUnrealized) == 0); - } - } - } - } - - void - checkPayment( - std::int32_t loanScale, - jtx::Account const& account, - jtx::PrettyAmount const& balanceBefore, - STAmount const& expectedPayment, - jtx::PrettyAmount const& adjustment) const - { - auto const borrowerScale = std::max(loanScale, balanceBefore.number().exponent()); - - STAmount const balanceChangeAmount{ - broker.asset, - roundToAsset(broker.asset, expectedPayment + adjustment, borrowerScale)}; - { - auto const difference = roundToScale( - env.balance(account, broker.asset) - (balanceBefore - balanceChangeAmount), - borrowerScale); - env.test.expect( - roundToScale(difference, loanScale) >= beast::kZero, - "Balance before: " + to_string(balanceBefore.value()) + - ", expected change: " + to_string(balanceChangeAmount) + - ", difference (balance after - expected): " + to_string(difference), - __FILE__, - __LINE__); - } - } - - /** - * Checks both the loan and broker expect states against the ledger - */ - void - operator()( - std::uint32_t previousPaymentDate, - std::uint32_t nextPaymentDate, - std::uint32_t paymentRemaining, - Number const& loanScale, - Number const& totalValue, - Number const& principalOutstanding, - Number const& managementFeeOutstanding, - Number const& periodicPayment, - std::uint32_t flags) const - { - using namespace jtx; - if (auto loan = env.le(loanKeylet); env.test.BEAST_EXPECT(loan)) - { - env.test.BEAST_EXPECT(loan->at(sfPreviousPaymentDueDate) == previousPaymentDate); - env.test.BEAST_EXPECT(loan->at(sfPaymentRemaining) == paymentRemaining); - env.test.BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == nextPaymentDate); - env.test.BEAST_EXPECT(loan->at(sfLoanScale) == loanScale); - env.test.BEAST_EXPECT(loan->at(sfTotalValueOutstanding) == totalValue); - env.test.BEAST_EXPECT(loan->at(sfPrincipalOutstanding) == principalOutstanding); - env.test.BEAST_EXPECT( - loan->at(sfManagementFeeOutstanding) == managementFeeOutstanding); - env.test.BEAST_EXPECT(loan->at(sfPeriodicPayment) == periodicPayment); - env.test.BEAST_EXPECT(loan->at(sfFlags) == flags); - - auto const ls = constructLoanState(loan); - - auto const interestRate = TenthBips32{loan->at(sfInterestRate)}; - auto const paymentInterval = loan->at(sfPaymentInterval); - checkBroker( - principalOutstanding, - ls.interestDue, - interestRate, - paymentInterval, - paymentRemaining, - 1); - - if (auto brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - env.test.BEAST_EXPECT(brokerSle)) - { - if (auto vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID))); - env.test.BEAST_EXPECT(vaultSle)) - { - if (((flags & lsfLoanImpaired) != 0u) && ((flags & lsfLoanDefault) == 0u)) - { - env.test.BEAST_EXPECT( - vaultSle->at(sfLossUnrealized) == - (env.current()->rules().enabled(featureLendingProtocolV1_1) && - getVaultVersion(vaultSle) == VaultVersion::CashBasis - ? principalOutstanding - : totalValue - managementFeeOutstanding)); - } - else - { - env.test.BEAST_EXPECT(vaultSle->at(sfLossUnrealized) == 0); - } - } - } - } - } - - /** - * Checks both the loan and broker expect states against the ledger - */ - void - operator()(LoanState const& state) const - { - operator()( - state.previousPaymentDate, - state.nextPaymentDate, - state.paymentRemaining, - state.loanScale, - state.totalValue, - state.principalOutstanding, - state.managementFeeOutstanding, - state.periodicPayment, - state.flags); - }; - }; - - BrokerInfo - createVaultAndBroker( - jtx::Env& env, - jtx::PrettyAsset const& asset, - jtx::Account const& lender, - BrokerParameters const& params = BrokerParameters::defaults()) - { - using namespace jtx; - - Vault const vault{env}; - - auto const deposit = asset(params.vaultDeposit); - auto const debtMaximumValue = asset(params.debtMax).value(); - auto const coverDepositValue = asset(params.coverDeposit).value(); - - auto const coverRateMinValue = params.coverRateMin; - - auto [tx, vaultKeylet] = vault.create({.owner = lender, .asset = asset}); - if (params.vaultScale) - tx[sfScale] = *params.vaultScale; - env(tx); - env.close(); - BEAST_EXPECT(env.le(vaultKeylet)); - - env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = deposit})); - env.close(); - if (auto const vault = env.le(keylet::vault(vaultKeylet.key)); BEAST_EXPECT(vault)) - { - BEAST_EXPECT(vault->at(sfAssetsAvailable) == deposit.value()); - } - - auto const keylet = keylet::loanBroker(lender.id(), env.seq(lender)); - - using namespace loan_broker; - env(set(lender, vaultKeylet.key, params.flags), - kData(params.data), - kManagementFeeRate(params.managementFeeRate), - kDebtMaximum(debtMaximumValue), - kCoverRateMinimum(coverRateMinValue), - kCoverRateLiquidation(TenthBips32(params.coverRateLiquidation))); - - if (coverDepositValue != beast::kZero) - env(coverDeposit(lender, keylet.key, coverDepositValue)); - - env.close(); - - return {asset, keylet, vaultKeylet, params}; - } - - /** - * Get the state without checking anything - */ - LoanState - getCurrentState(jtx::Env const& env, BrokerInfo const& broker, Keylet const& loanKeylet) - { - using d = NetClock::duration; - using tp = NetClock::time_point; - - // Lookup the current loan state - if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan)) - { - return LoanState{ - .previousPaymentDate = loan->at(sfPreviousPaymentDueDate), - .startDate = tp{d{loan->at(sfStartDate)}}, - .nextPaymentDate = loan->at(sfNextPaymentDueDate), - .paymentRemaining = loan->at(sfPaymentRemaining), - .loanScale = loan->at(sfLoanScale), - .totalValue = loan->at(sfTotalValueOutstanding), - .principalOutstanding = loan->at(sfPrincipalOutstanding), - .managementFeeOutstanding = loan->at(sfManagementFeeOutstanding), - .periodicPayment = loan->at(sfPeriodicPayment), - .flags = loan->at(sfFlags), - .paymentInterval = loan->at(sfPaymentInterval), - .interestRate = TenthBips32{loan->at(sfInterestRate)}, - }; - } - return LoanState{}; - } - - /** - * Get the state and check the values against the parameters used in - * `lifecycle` - */ - LoanState - getCurrentState( - jtx::Env const& env, - BrokerInfo const& broker, - Keylet const& loanKeylet, - VerifyLoanStatus const& verifyLoanStatus) - { - using namespace std::chrono_literals; - using d = NetClock::duration; - using tp = NetClock::time_point; - - auto const state = getCurrentState(env, broker, loanKeylet); - BEAST_EXPECT(state.previousPaymentDate == 0); - BEAST_EXPECT(tp{d{state.nextPaymentDate}} == state.startDate + 600s); - BEAST_EXPECT(state.paymentRemaining == 12); - BEAST_EXPECT(state.principalOutstanding == broker.asset(1000).value()); - BEAST_EXPECT( - state.loanScale >= - (broker.asset.integral() - ? 0 - : std::max(broker.vaultScale(env), state.principalOutstanding.exponent()))); - BEAST_EXPECT(state.paymentInterval == 600); - { - NumberRoundModeGuard const mg(Number::RoundingMode::Upward); - BEAST_EXPECT( - state.totalValue == - roundToAsset( - broker.asset, state.periodicPayment * state.paymentRemaining, state.loanScale)); - } - BEAST_EXPECT( - state.managementFeeOutstanding == - computeManagementFee( - broker.asset, - state.totalValue - state.principalOutstanding, - broker.params.managementFeeRate, - state.loanScale)); - - verifyLoanStatus(state); - - return state; - } - - bool - canImpairLoan(jtx::Env const& env, BrokerInfo const& broker, LoanState const& state) - { - if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - BEAST_EXPECT(brokerSle)) - { - if (auto const vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID))); - BEAST_EXPECT(vaultSle)) - { - // log << vaultSle->getJson() << std::endl; - auto const assetsUnavailable = - vaultSle->at(sfAssetsTotal) - vaultSle->at(sfAssetsAvailable); - auto const unrealizedLoss = vaultSle->at(sfLossUnrealized) + - (env.current()->rules().enabled(featureLendingProtocolV1_1) && - getVaultVersion(vaultSle) == VaultVersion::CashBasis - ? state.principalOutstanding - : state.totalValue - state.managementFeeOutstanding); - - if (!BEAST_EXPECT(unrealizedLoss <= assetsUnavailable)) - { - return false; - } - } - } - return true; - } - - enum class AssetType { XRP = 0, IOU = 1, MPT = 2 }; - - // Specify the accounts as params to allow other accounts to be used - jtx::PrettyAsset - createAsset( - jtx::Env& env, - AssetType assetType, - BrokerParameters const& brokerParams, - jtx::Account const& issuer, - jtx::Account const& lender, - jtx::Account const& borrower) - { - using namespace jtx; - - switch (assetType) - { - case AssetType::XRP: - // TODO: remove the factor, and set up loans in drops - return PrettyAsset{xrpIssue(), 1'000'000}; - - case AssetType::IOU: { - PrettyAsset const asset{issuer[iouCurrency_]}; - - auto const limit = - asset(100 * (brokerParams.vaultDeposit + brokerParams.coverDeposit)); - if (lender != issuer) - env(trust(lender, limit)); - if (borrower != issuer) - env(trust(borrower, limit)); - - return asset; - } - - case AssetType::MPT: { - // Enough to cover initial fees - if (!env.le(keylet::account(issuer))) - env.fund(env.current()->fees().accountReserve(10, 1) * 10, issuer); - if (!env.le(keylet::account(lender))) - env.fund(env.current()->fees().accountReserve(10, 1) * 10, noripple(lender)); - if (!env.le(keylet::account(borrower))) - env.fund(env.current()->fees().accountReserve(10, 1) * 10, noripple(borrower)); - - MPTTester mptt{env, issuer, kMptInitNoFund}; - mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); - // Scale the MPT asset so interest is interesting - PrettyAsset const asset{mptt.issuanceID(), 10'000}; - // Need to do the authorization here because mptt isn't - // accessible outside - if (lender != issuer) - mptt.authorize({.account = lender}); - if (borrower != issuer) - mptt.authorize({.account = borrower}); - - env.close(); - - return asset; - } - - default: - throw std::runtime_error("Unknown asset type"); - } - } - - void - describeLoan( - jtx::Env& env, - BrokerParameters const& brokerParams, - LoanParameters const& loanParams, - AssetType assetType, - jtx::Account const& issuer, - jtx::Account const& lender, - jtx::Account const& borrower) - { - using namespace jtx; - - auto const asset = createAsset(env, assetType, brokerParams, issuer, lender, borrower); - auto const principal = asset(loanParams.principalRequest).number(); - auto const interest = loanParams.interest.value_or(TenthBips32{}); - auto const interval = loanParams.payInterval.value_or(LoanSet::kDefaultPaymentInterval); - auto const total = loanParams.payTotal.value_or(LoanSet::kDefaultPaymentTotal); - auto const feeRate = brokerParams.managementFeeRate; - auto const props = computeLoanProperties( - env.current()->rules(), - asset, - principal, - interest, - interval, - total, - feeRate, - asset(brokerParams.vaultDeposit).number().exponent()); - log << "Loan properties:\n" - << "\tPrincipal: " << principal << std::endl - << "\tInterest rate: " << interest << std::endl - << "\tPayment interval: " << interval << std::endl - << "\tManagement Fee Rate: " << feeRate << std::endl - << "\tTotal Payments: " << total << std::endl - << "\tPeriodic Payment: " << props.periodicPayment << std::endl - << "\tTotal Value: " << props.loanState.valueOutstanding << std::endl - << "\tManagement Fee: " << props.loanState.managementFeeDue << std::endl - << "\tLoan Scale: " << props.loanScale << std::endl - << "\tFirst payment principal: " << props.firstPaymentPrincipal << std::endl; - - // checkGuards returns a TER, so success is 0 - BEAST_EXPECT(!checkLoanGuards( - asset, - asset(loanParams.principalRequest).number(), - loanParams.interest.value_or(TenthBips32{}) != beast::kZero, - loanParams.payTotal.value_or(LoanSet::kDefaultPaymentTotal), - props, - env.journal)); - } - - std::optional> - createLoan( - jtx::Env& env, - AssetType assetType, - BrokerParameters const& brokerParams, - LoanParameters const& loanParams, - jtx::Account const& issuer, - jtx::Account const& lender, - jtx::Account const& borrower) - { - using namespace jtx; - - // Enough to cover initial fees - env.fund(env.current()->fees().accountReserve(10, 1) * 10, issuer); - if (lender != issuer) - env.fund(env.current()->fees().accountReserve(10, 1) * 10, noripple(lender)); - if (borrower != issuer && borrower != lender) - env.fund(env.current()->fees().accountReserve(10, 1) * 10, noripple(borrower)); - - describeLoan(env, brokerParams, loanParams, assetType, issuer, lender, borrower); - - // Make the asset - auto const asset = createAsset(env, assetType, brokerParams, issuer, lender, borrower); - - env.close(); - if (asset.native() || lender != issuer) - { - env( - pay((asset.native() ? env.master : issuer), - lender, - asset(brokerParams.vaultDeposit + brokerParams.coverDeposit))); - } - // Fund the borrower later once we know the total loan - // size - - BrokerInfo const broker = createVaultAndBroker(env, asset, lender, brokerParams); - - auto const pseudoAcctOpt = [&]() -> std::optional { - auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - if (!BEAST_EXPECT(brokerSle)) - return std::nullopt; - auto const brokerPseudo = brokerSle->at(sfAccount); - return Account("Broker pseudo-account", brokerPseudo); - }(); - if (!pseudoAcctOpt) - return std::nullopt; - Account const& pseudoAcct = *pseudoAcctOpt; - - auto const loanKeyletOpt = [&]() -> std::optional { - auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - if (!BEAST_EXPECT(brokerSle)) - return std::nullopt; - - // Broker has no loans - BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); - - // The loan keylet is based on the LoanSequence of the - // _LOAN_BROKER_ object. - auto const loanSequence = brokerSle->at(sfLoanSequence); - return keylet::loan(broker.brokerID, loanSequence); - }(); - if (!loanKeyletOpt) - return std::nullopt; - Keylet const& loanKeylet = *loanKeyletOpt; - - env(loanParams(env, broker)); - - env.close(); - - return std::make_tuple(broker, loanKeylet, pseudoAcct); - } - - static void - topUpBorrower( - jtx::Env& env, - BrokerInfo const& broker, - jtx::Account const& issuer, - jtx::Account const& borrower, - LoanState const& state, - std::optional const& servFee) - { - using namespace jtx; - - STAmount const serviceFee = broker.asset(servFee.value_or(0)); - - // Ensure the borrower has enough funds to make the payments - // (including tx fees, if necessary) - auto const borrowerBalance = env.balance(borrower, broker.asset); - - auto const baseFee = env.current()->fees().base; - - // Add extra for transaction fees and reserves, if appropriate, or a - // tiny amount for the extra paid in each transaction - auto const totalNeeded = state.totalValue + (serviceFee * state.paymentRemaining) + - (broker.asset.native() ? Number( - baseFee * state.paymentRemaining + - accountReserve(*env.current(), borrower.id(), env.journal)) - : broker.asset(15).number()); - - auto const shortage = totalNeeded - borrowerBalance.number(); - - if (shortage > beast::kZero && (broker.asset.native() || issuer != borrower)) - { - env( - pay((broker.asset.native() ? env.master : issuer), - borrower, - STAmount{broker.asset, shortage})); - } - } - - void - makeLoanPayments( - jtx::Env& env, - BrokerInfo const& broker, - LoanParameters const& loanParams, - Keylet const& loanKeylet, - VerifyLoanStatus const& verifyLoanStatus, - jtx::Account const& issuer, - jtx::Account const& lender, - jtx::Account const& borrower, - PaymentParameters const& paymentParams = PaymentParameters::defaults()) - { - // Make all the individual payments - using namespace jtx; - using namespace jtx::loan; - using namespace std::chrono_literals; - using d = NetClock::duration; - - bool const showStepBalances = paymentParams.showStepBalances; - - auto const currencyLabel = getCurrencyLabel(broker.asset); - - auto const baseFee = env.current()->fees().base; - - env.close(); - auto state = getCurrentState(env, broker, loanKeylet); - - verifyLoanStatus(state); - - STAmount const serviceFee = broker.asset(loanParams.serviceFee.value_or(0)); - - topUpBorrower(env, broker, issuer, borrower, state, loanParams.serviceFee); - - // Periodic payment amount will consist of - // 1. principal outstanding (1000) - // 2. interest interest rate (at 12%) - // 3. payment interval (600s) - // 4. loan service fee (2) - // Calculate these values without the helper functions - // to verify they're working correctly The numbers in - // the below BEAST_EXPECTs may not hold across assets. - auto const periodicRate = loanPeriodicRate(state.interestRate, state.paymentInterval); - STAmount const roundedPeriodicPayment{ - broker.asset, - roundPeriodicPayment(broker.asset, state.periodicPayment, state.loanScale)}; - - if (!showStepBalances) - { - log << currencyLabel << " Payment components: " - << "Payments remaining, " - << "rawInterest, rawPrincipal, " - "rawMFee, " - << "trackedValueDelta, trackedPrincipalDelta, " - "trackedInterestDelta, trackedMgmtFeeDelta, special" - << std::endl; - } - - // Include the service fee - STAmount const totalDue = roundToScale( - roundedPeriodicPayment + serviceFee, state.loanScale, Number::RoundingMode::Upward); - - auto currentRoundedState = constructLoanState( - state.totalValue, state.principalOutstanding, state.managementFeeOutstanding); - { - auto const raw = computeTheoreticalLoanState( - env.current()->rules(), - state.periodicPayment, - periodicRate, - state.paymentRemaining, - broker.params.managementFeeRate); - - if (showStepBalances) - { - log << currencyLabel << " Starting loan balances: " - << "\n\tTotal value: " << currentRoundedState.valueOutstanding - << "\n\tPrincipal: " << currentRoundedState.principalOutstanding - << "\n\tInterest: " << currentRoundedState.interestDue - << "\n\tMgmt fee: " << currentRoundedState.managementFeeDue - << "\n\tPayments remaining " << state.paymentRemaining << std::endl; - } - else - { - log << currencyLabel << " Loan starting state: " << state.paymentRemaining << ", " - << raw.interestDue << ", " << raw.principalOutstanding << ", " - << raw.managementFeeDue << ", " << currentRoundedState.valueOutstanding << ", " - << currentRoundedState.principalOutstanding << ", " - << currentRoundedState.interestDue << ", " - << currentRoundedState.managementFeeDue << std::endl; - } - } - - // Try to pay a little extra to show that it's _not_ - // taken - auto const extraAmount = paymentParams.overpaymentExtra - ? broker.asset(*paymentParams.overpaymentExtra).value() - : std::min(broker.asset(10).value(), STAmount{broker.asset, totalDue / 20}); - - STAmount const transactionAmount = - STAmount{broker.asset, totalDue * paymentParams.overpaymentFactor} + extraAmount; - - auto const borrowerInitialBalance = env.balance(borrower, broker.asset).number(); - auto const initialState = state; - xrpl::detail::PaymentComponents totalPaid{ - .trackedValueDelta = 0, .trackedPrincipalDelta = 0, .trackedManagementFeeDelta = 0}; - Number totalInterestPaid = 0; - Number totalFeesPaid = 0; - std::size_t totalPaymentsMade = 0; - - xrpl::LoanState currentTrueState = computeTheoreticalLoanState( - env.current()->rules(), - state.periodicPayment, - periodicRate, - state.paymentRemaining, - broker.params.managementFeeRate); - - auto validateBorrowerBalance = [&]() { - if (borrower == issuer || !paymentParams.validateBalances) - return; - auto const totalSpent = - (totalPaid.trackedValueDelta + totalFeesPaid + - (broker.asset.native() ? Number(baseFee) * totalPaymentsMade : kNumZero)); - BEAST_EXPECT( - env.balance(borrower, broker.asset).number() == - borrowerInitialBalance - totalSpent); - }; - - auto const defaultRound = broker.asset.integral() ? 3 : 0; - auto truncate = [defaultRound](Number const& n, std::optional places = std::nullopt) { - auto const p = places.value_or(defaultRound); - if (p == 0) - return n; - auto const factor = Number{1, p}; - return (n * factor).truncate() / factor; - }; - while (state.paymentRemaining > 0) - { - validateBorrowerBalance(); - // Compute the expected principal amount - auto const paymentComponents = xrpl::detail::computePaymentComponents( - env.current()->rules(), - broker.asset.raw(), - state.loanScale, - state.totalValue, - state.principalOutstanding, - state.managementFeeOutstanding, - state.periodicPayment, - periodicRate, - state.paymentRemaining, - broker.params.managementFeeRate); - - BEAST_EXPECT( - paymentComponents.trackedValueDelta <= roundedPeriodicPayment || - (paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final && - paymentComponents.trackedValueDelta >= roundedPeriodicPayment)); - BEAST_EXPECT( - paymentComponents.trackedValueDelta == - paymentComponents.trackedPrincipalDelta + paymentComponents.trackedInterestPart() + - paymentComponents.trackedManagementFeeDelta); - - xrpl::LoanState const nextTrueState = computeTheoreticalLoanState( - env.current()->rules(), - state.periodicPayment, - periodicRate, - state.paymentRemaining - 1, - broker.params.managementFeeRate); - xrpl::detail::LoanStateDeltas const deltas = currentTrueState - nextTrueState; - BEAST_EXPECT( - deltas.total() == deltas.principal + deltas.interest + deltas.managementFee); - BEAST_EXPECT( - paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final || - deltas.total() == state.periodicPayment || - (state.loanScale - (deltas.total() - state.periodicPayment).exponent()) > 14); - - if (!showStepBalances) - { - log << currencyLabel << " Payment components: " << state.paymentRemaining << ", " - - << deltas.interest << ", " << deltas.principal << ", " << deltas.managementFee - << ", " << paymentComponents.trackedValueDelta << ", " - << paymentComponents.trackedPrincipalDelta << ", " - << paymentComponents.trackedInterestPart() << ", " - << paymentComponents.trackedManagementFeeDelta << ", " << [&]() -> char const* { - if (paymentComponents.specialCase == ::xrpl::detail::PaymentSpecialCase::Final) - return "final"; - if (paymentComponents.specialCase == ::xrpl::detail::PaymentSpecialCase::Extra) - return "extra"; - return "none"; - }() << std::endl; - } - - auto const totalDueAmount = - STAmount{broker.asset, paymentComponents.trackedValueDelta + serviceFee}; - - if (paymentParams.validateBalances) - { - // Due to the rounding algorithms to keep the interest and - // principal in sync with "true" values, the computed amount - // may be a little less than the rounded fixed payment - // amount. For integral types, the difference should be < 3 - // (1 unit for each of the interest and management fee). For - // IOUs, the difference should be dust. - Number const diff = totalDue - totalDueAmount; - BEAST_EXPECT( - paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final || - diff == beast::kZero || - (diff > beast::kZero && - ((broker.asset.integral() && (static_cast(diff) < 3)) || - (state.loanScale - diff.exponent() > 13)))); - - BEAST_EXPECT( - paymentComponents.trackedPrincipalDelta >= beast::kZero && - paymentComponents.trackedPrincipalDelta <= state.principalOutstanding); - BEAST_EXPECT( - paymentComponents.specialCase != xrpl::detail::PaymentSpecialCase::Final || - paymentComponents.trackedPrincipalDelta == state.principalOutstanding); - } - - auto const borrowerBalanceBeforePayment = env.balance(borrower, broker.asset); - - // Make the payment - env(pay(borrower, loanKeylet.key, transactionAmount, paymentParams.flags)); - - env.close(d{state.paymentInterval / 2}); - - if (paymentParams.validateBalances) - { - // Need to account for fees if the loan is in XRP - PrettyAmount adjustment = broker.asset(0); - if (broker.asset.native()) - { - adjustment = env.current()->fees().base; - } - - // Check the result - verifyLoanStatus.checkPayment( - state.loanScale, - borrower, - borrowerBalanceBeforePayment, - totalDueAmount, - adjustment); - } - - if (showStepBalances) - { - auto const loanSle = env.le(loanKeylet); - if (!BEAST_EXPECT(loanSle)) - { - // No reason for this not to exist - return; - } - auto const current = constructLoanState(loanSle); - auto const errors = nextTrueState - current; - log << currencyLabel << " Loan balances: " - << "\n\tAmount taken: " << paymentComponents.trackedValueDelta - << "\n\tTotal value: " << current.valueOutstanding - << " (true: " << truncate(nextTrueState.valueOutstanding) - << ", error: " << truncate(errors.total()) - << ")\n\tPrincipal: " << current.principalOutstanding - << " (true: " << truncate(nextTrueState.principalOutstanding) - << ", error: " << truncate(errors.principal) - << ")\n\tInterest: " << current.interestDue - << " (true: " << truncate(nextTrueState.interestDue) - << ", error: " << truncate(errors.interest) - << ")\n\tMgmt fee: " << current.managementFeeDue - << " (true: " << truncate(nextTrueState.managementFeeDue) - << ", error: " << truncate(errors.managementFee) << ")\n\tPayments remaining " - << loanSle->at(sfPaymentRemaining) << std::endl; - - currentRoundedState = current; - } - - --state.paymentRemaining; - state.previousPaymentDate = state.nextPaymentDate; - if (paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final) - { - state.paymentRemaining = 0; - state.nextPaymentDate = 0; - } - else - { - state.nextPaymentDate += state.paymentInterval; - } - state.principalOutstanding -= paymentComponents.trackedPrincipalDelta; - state.managementFeeOutstanding -= paymentComponents.trackedManagementFeeDelta; - state.totalValue -= paymentComponents.trackedValueDelta; - - if (paymentParams.validateBalances) - verifyLoanStatus(state); - - totalPaid.trackedValueDelta += paymentComponents.trackedValueDelta; - totalPaid.trackedPrincipalDelta += paymentComponents.trackedPrincipalDelta; - totalPaid.trackedManagementFeeDelta += paymentComponents.trackedManagementFeeDelta; - totalInterestPaid += paymentComponents.trackedInterestPart(); - totalFeesPaid += serviceFee; - ++totalPaymentsMade; - - currentTrueState = nextTrueState; - } - validateBorrowerBalance(); - - // Loan is paid off - BEAST_EXPECT(state.paymentRemaining == 0); - BEAST_EXPECT(state.principalOutstanding == 0); - - auto const initialInterestDue = initialState.totalValue - - (initialState.principalOutstanding + initialState.managementFeeOutstanding); - if (paymentParams.validateBalances) - { - // Make sure all the payments add up - BEAST_EXPECT(totalPaid.trackedValueDelta == initialState.totalValue); - BEAST_EXPECT(totalPaid.trackedPrincipalDelta == initialState.principalOutstanding); - BEAST_EXPECT( - totalPaid.trackedManagementFeeDelta == initialState.managementFeeOutstanding); - // This is almost a tautology given the previous checks, but - // check it anyway for completeness. - BEAST_EXPECT(totalInterestPaid == initialInterestDue); - BEAST_EXPECT(totalPaymentsMade == initialState.paymentRemaining); - } - - if (showStepBalances) - { - auto const loanSle = env.le(loanKeylet); - if (!BEAST_EXPECT(loanSle)) - { - // No reason for this not to exist - return; - } - log << currencyLabel << " Total amounts paid: " - << "\n\tTotal value: " << totalPaid.trackedValueDelta - << " (initial: " << truncate(initialState.totalValue) - << ", error: " << truncate(initialState.totalValue - totalPaid.trackedValueDelta) - << ")\n\tPrincipal: " << totalPaid.trackedPrincipalDelta - << " (initial: " << truncate(initialState.principalOutstanding) << ", error: " - << truncate(initialState.principalOutstanding - totalPaid.trackedPrincipalDelta) - << ")\n\tInterest: " << totalInterestPaid - << " (initial: " << truncate(initialInterestDue) - << ", error: " << truncate(initialInterestDue - totalInterestPaid) - << ")\n\tMgmt fee: " << totalPaid.trackedManagementFeeDelta - << " (initial: " << truncate(initialState.managementFeeOutstanding) << ", error: " - << truncate( - initialState.managementFeeOutstanding - totalPaid.trackedManagementFeeDelta) - << ")\n\tTotal payments made: " << totalPaymentsMade << std::endl; - } - } - - void - runLoan( - AssetType assetType, - BrokerParameters const& brokerParams, - LoanParameters const& loanParams, - FeatureBitset features) - { - using namespace jtx; - - Account const issuer("issuer"); - Account const lender("lender"); - Account const borrower("borrower"); - - Env env(*this, features); - - auto loanResult = - createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); - if (BEAST_EXPECT(loanResult); !loanResult.has_value()) - return; - - auto broker = std::get(*loanResult); - auto loanKeylet = std::get(*loanResult); - auto pseudoAcct = std::get(*loanResult); - - VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); - - makeLoanPayments( - env, - broker, - loanParams, - loanKeylet, - verifyLoanStatus, - issuer, - lender, - borrower, - PaymentParameters{.showStepBalances = true}); - } - - /** - * Runs through the complete lifecycle of a loan - * - * 1. Create a loan. - * 2. Test a bunch of transaction failure conditions. - * 3. Use the `toEndOfLife` callback to take the loan to 0. How that is done - * depends on the callback. e.g. Default, Early payoff, make all the - * normal payments, etc. - * 4. Delete the loan. The loan will alternate between being deleted by the - * lender and the borrower. - */ - void - lifecycle( - std::string const& caseLabel, - char const* label, - jtx::Env& env, - Number const& loanAmount, - int interestExponent, - jtx::Account const& lender, - jtx::Account const& borrower, - jtx::Account const& evan, - BrokerInfo const& broker, - jtx::Account const& pseudoAcct, - std::uint32_t flags, - // The end of life callback is expected to take the loan to 0 payments - // remaining, one way or another - std::function - toEndOfLife) - { - auto const [keylet, loanSequence] = [&]() { - auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - if (!BEAST_EXPECT(brokerSle)) - { - // will be invalid - return std::make_pair(keylet::loan(broker.brokerID), std::uint32_t(0)); - } - - // Broker has no loans - BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); - - // The loan keylet is based on the LoanSequence of the _LOAN_BROKER_ - // object. - auto const loanSequence = brokerSle->at(sfLoanSequence); - return std::make_pair(keylet::loan(broker.brokerID, loanSequence), loanSequence); - }(); - - VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, keylet); - - // No loans yet - verifyLoanStatus.checkBroker(0, 0, TenthBips32{0}, 1, 0, 0); - - if (!BEAST_EXPECT(loanSequence != 0)) - return; - - testcase << caseLabel << " " << label; - - using namespace jtx; - using namespace loan; - using namespace std::chrono_literals; - - auto applyExponent = [interestExponent, this](TenthBips32 value) mutable { - BEAST_EXPECT(value > TenthBips32(0)); - while (interestExponent > 0) - { - auto const oldValue = value; - value *= 10; - --interestExponent; - BEAST_EXPECT(value / 10 == oldValue); - } - while (interestExponent < 0) - { - auto const oldValue = value; - value /= 10; - ++interestExponent; - BEAST_EXPECT(value * 10 == oldValue); - } - return value; - }; - - auto const borrowerOwnerCount = env.ownerCount(borrower); - - auto const loanSetFee = env.current()->fees().base * 2; - LoanParameters const loanParams{ - .account = borrower, - .counter = lender, - .counterpartyExplicit = false, - .principalRequest = loanAmount, - .setFee = loanSetFee, - .originationFee = 1, - .serviceFee = 2, - .lateFee = 3, - .closeFee = 4, - .overFee = applyExponent(percentageToTenthBips(5) / 10), - .interest = applyExponent(percentageToTenthBips(12)), - // 2.4% - .lateInterest = applyExponent(percentageToTenthBips(24) / 10), - .closeInterest = applyExponent(percentageToTenthBips(36) / 10), - .overpaymentInterest = applyExponent(percentageToTenthBips(48) / 10), - .payTotal = 12, - .payInterval = 600, - .gracePd = 60, - .flags = flags, - }; - Number const principalRequestAmount = broker.asset(loanParams.principalRequest).value(); - auto const originationFeeAmount = broker.asset(*loanParams.originationFee).value(); - auto const serviceFeeAmount = broker.asset(*loanParams.serviceFee).value(); - auto const lateFeeAmount = broker.asset(*loanParams.lateFee).value(); - auto const closeFeeAmount = broker.asset(*loanParams.closeFee).value(); - - auto const borrowerStartbalance = env.balance(borrower, broker.asset); - - auto createJtx = loanParams(env, broker); - // Successfully create a Loan - env(createJtx); - - env.close(); - - auto const startDate = env.current()->header().parentCloseTime.time_since_epoch().count(); - - if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - BEAST_EXPECT(brokerSle)) - { - BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 1); - } - - { - // Need to account for fees if the loan is in XRP - PrettyAmount adjustment = broker.asset(0); - if (broker.asset.native()) - { - adjustment = 2 * env.current()->fees().base; - } - - BEAST_EXPECT( - env.balance(borrower, broker.asset).value() == - borrowerStartbalance.value() + principalRequestAmount - originationFeeAmount - - adjustment.value()); - } - - auto const loanFlags = - createJtx.stx->isFlag(tfLoanOverpayment) ? lsfLoanOverpayment : LedgerSpecificFlags(0); - - if (auto loan = env.le(keylet); BEAST_EXPECT(loan)) - { - // log << "loan after create: " << to_string(loan->getJson()) - // << std::endl; - BEAST_EXPECT( - loan->isFlag(lsfLoanOverpayment) == createJtx.stx->isFlag(tfLoanOverpayment)); - BEAST_EXPECT(loan->at(sfLoanSequence) == loanSequence); - BEAST_EXPECT(loan->at(sfBorrower) == borrower.id()); - BEAST_EXPECT(loan->at(sfLoanBrokerID) == broker.brokerID); - BEAST_EXPECT(loan->at(sfLoanOriginationFee) == originationFeeAmount); - BEAST_EXPECT(loan->at(sfLoanServiceFee) == serviceFeeAmount); - BEAST_EXPECT(loan->at(sfLatePaymentFee) == lateFeeAmount); - BEAST_EXPECT(loan->at(sfClosePaymentFee) == closeFeeAmount); - BEAST_EXPECT(loan->at(sfOverpaymentFee) == *loanParams.overFee); - BEAST_EXPECT(loan->at(sfInterestRate) == *loanParams.interest); - BEAST_EXPECT(loan->at(sfLateInterestRate) == *loanParams.lateInterest); - BEAST_EXPECT(loan->at(sfCloseInterestRate) == *loanParams.closeInterest); - BEAST_EXPECT(loan->at(sfOverpaymentInterestRate) == *loanParams.overpaymentInterest); - BEAST_EXPECT(loan->at(sfStartDate) == startDate); - BEAST_EXPECT(loan->at(sfPaymentInterval) == *loanParams.payInterval); - BEAST_EXPECT(loan->at(sfGracePeriod) == *loanParams.gracePd); - BEAST_EXPECT(loan->at(sfPreviousPaymentDueDate) == 0); - BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == startDate + *loanParams.payInterval); - BEAST_EXPECT(loan->at(sfPaymentRemaining) == *loanParams.payTotal); - BEAST_EXPECT( - loan->at(sfLoanScale) >= - (broker.asset.integral() - ? 0 - : std::max(broker.vaultScale(env), principalRequestAmount.exponent()))); - BEAST_EXPECT(loan->at(sfPrincipalOutstanding) == principalRequestAmount); - } - - auto state = getCurrentState(env, broker, keylet, verifyLoanStatus); - - auto const loanProperties = computeLoanProperties( - env.current()->rules(), - broker.asset.raw(), - state.principalOutstanding, - state.interestRate, - state.paymentInterval, - state.paymentRemaining, - broker.params.managementFeeRate, - state.loanScale); - - verifyLoanStatus( - 0, - startDate + *loanParams.payInterval, - *loanParams.payTotal, - state.loanScale, - loanProperties.loanState.valueOutstanding, - principalRequestAmount, - loanProperties.loanState.managementFeeDue, - loanProperties.periodicPayment, - loanFlags | 0); - - // Manage the loan - // no-op - env(manage(lender, keylet.key, 0)); - { - // no flags - auto jt = manage(lender, keylet.key, 0); - jt.removeMember(sfFlags.getName()); - env(jt); - } - // Only the lender can manage - env(manage(evan, keylet.key, 0), Ter(tecNO_PERMISSION)); - // unknown flags - env(manage(lender, keylet.key, tfLoanManageMask), Ter(temINVALID_FLAG)); - // combinations of flags are not allowed - env(manage(lender, keylet.key, tfLoanUnimpair | tfLoanImpair), Ter(temINVALID_FLAG)); - env(manage(lender, keylet.key, tfLoanImpair | tfLoanDefault), Ter(temINVALID_FLAG)); - env(manage(lender, keylet.key, tfLoanUnimpair | tfLoanDefault), Ter(temINVALID_FLAG)); - env(manage(lender, keylet.key, tfLoanUnimpair | tfLoanImpair | tfLoanDefault), - Ter(temINVALID_FLAG)); - // invalid loan ID - env(manage(lender, broker.brokerID, tfLoanImpair), Ter(tecNO_ENTRY)); - // Loan is unimpaired, can't unimpair it again - env(manage(lender, keylet.key, tfLoanUnimpair), Ter(tecNO_PERMISSION)); - // Loan is unimpaired, it can go into default, but only after it's past - // due - env(manage(lender, keylet.key, tfLoanDefault), Ter(tecTOO_SOON)); - - // Check the vault - bool const canImpair = canImpairLoan(env, broker, state); - // Impair the loan, if possible - env(manage(lender, keylet.key, tfLoanImpair), - canImpair ? Ter(tesSUCCESS) : Ter(tecLIMIT_EXCEEDED)); - // Unimpair the loan - env(manage(lender, keylet.key, tfLoanUnimpair), - canImpair ? Ter(tesSUCCESS) : Ter(tecNO_PERMISSION)); - - auto const nextDueDate = startDate + *loanParams.payInterval; - - env.close(); - - verifyLoanStatus( - 0, - nextDueDate, - *loanParams.payTotal, - loanProperties.loanScale, - loanProperties.loanState.valueOutstanding, - principalRequestAmount, - loanProperties.loanState.managementFeeDue, - loanProperties.periodicPayment, - loanFlags | 0); - - // Can't delete the loan yet. It has payments remaining. - env(del(lender, keylet.key), Ter(tecHAS_OBLIGATIONS)); - - if (BEAST_EXPECT(toEndOfLife)) - toEndOfLife(keylet, verifyLoanStatus); - env.close(); - - // Verify the loan is at EOL - if (auto loan = env.le(keylet); BEAST_EXPECT(loan)) - { - BEAST_EXPECT(loan->at(sfPaymentRemaining) == 0); - BEAST_EXPECT(loan->at(sfPrincipalOutstanding) == 0); - } - auto const borrowerStartingBalance = env.balance(borrower, broker.asset); - - // Try to delete the loan broker with an active loan - env(loan_broker::del(lender, broker.brokerID), Ter(tecHAS_OBLIGATIONS)); - // Ensure the above tx doesn't get ordered after the LoanDelete and - // delete our broker! - env.close(); - - // Test failure cases - env(del(lender, keylet.key, tfLoanOverpayment), Ter(temINVALID_FLAG)); - env(del(evan, keylet.key), Ter(tecNO_PERMISSION)); - env(del(lender, broker.brokerID), Ter(tecNO_ENTRY)); - - // Delete the loan - // Either the borrower or the lender can delete the loan. Alternate - // between who does it across tests. - static unsigned kDeleteCounter = 0; - auto const deleter = ((++kDeleteCounter % 2) != 0u) ? lender : borrower; - env(del(deleter, keylet.key)); - env.close(); - - PrettyAmount adjustment = broker.asset(0); - if (deleter == borrower) - { - // Need to account for fees if the loan is in XRP - if (broker.asset.native()) - { - adjustment = env.current()->fees().base; - } - } - - // No loans left - verifyLoanStatus.checkBroker(0, 0, *loanParams.interest, 1, 0, 0); - - BEAST_EXPECT( - env.balance(borrower, broker.asset).value() == - borrowerStartingBalance.value() - adjustment); - BEAST_EXPECT(env.ownerCount(borrower) == borrowerOwnerCount); - - if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - BEAST_EXPECT(brokerSle)) - { - BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); - } - } - - static std::string - getCurrencyLabel(Asset const& asset) - { - if (asset.native()) - return "XRP"; - if (asset.holds()) - return "IOU"; - if (asset.holds()) - return "MPT"; - return "Unknown"; - } - - /** - * Wrapper to run a series of lifecycle tests for a given asset and loan - * amount - * - * Will be used in the future to vary the loan parameters. For now, it is - * only called once. - * - * Tests a bunch of LoanSet failure conditions before lifecycle. - */ - template - void - testCaseWrapper( - jtx::Env& env, - jtx::MPTTester& mptt, - std::array const& assets, - BrokerInfo const& broker, - Number const& loanAmount, - int interestExponent) - { - using namespace jtx; - using namespace lending; - - auto const& asset = broker.asset.raw(); - auto const currencyLabel = getCurrencyLabel(asset); - auto const caseLabel = [&]() { - std::stringstream ss; - ss << "Lifecycle: " << loanAmount << " " << currencyLabel - << " Scale interest to: " << interestExponent << " "; - return ss.str(); - }(); - testcase << caseLabel; - - using namespace loan; - using namespace std::chrono_literals; - using d = NetClock::duration; - using tp = NetClock::time_point; - - Account const issuer{"issuer"}; - // For simplicity, lender will be the sole actor for the vault & - // brokers. - Account const lender{"lender"}; - // Borrower only wants to borrow - Account const borrower{"borrower"}; - // Evan will attempt to be naughty - Account const evan{"evan"}; - // Do not fund alice - Account const alice{"alice"}; - - Number const principalRequest = broker.asset(loanAmount).value(); - Number const maxCoveredLoanValue = broker.params.maxCoveredLoanValue(0); - BEAST_EXPECT(maxCoveredLoanValue == 1000 * 100 / 10); - Number const maxCoveredLoanRequest = broker.asset(maxCoveredLoanValue).value(); - Number const totalVaultRequest = broker.asset(broker.params.vaultDeposit).value(); - Number const debtMaximumRequest = broker.asset(broker.params.debtMax).value(); - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - - auto const pseudoAcct = [&]() { - auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - if (!BEAST_EXPECT(brokerSle)) - return Account{lender}; - auto const brokerPseudo = brokerSle->at(sfAccount); - return Account("Broker pseudo-account", brokerPseudo); - }(); - - auto const baseFee = env.current()->fees().base; - - auto badKeylet = keylet::vault(lender.id(), env.seq(lender)); - // Try some failure cases - // flags are checked first - env(set(evan, broker.brokerID, principalRequest, tfLoanSetMask), - Sig(sfCounterpartySignature, lender), - loanSetFee, - Ter(temINVALID_FLAG)); - - // field length validation - // sfData: good length, bad account - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kData(std::string(kMaxDataPayloadLength, 'X')), - loanSetFee, - Ter(tefBAD_AUTH)); - // sfData: too long - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kData(std::string(kMaxDataPayloadLength + 1, 'Y')), - loanSetFee, - Ter(temINVALID)); - - // field range validation - // sfOverpaymentFee: good value, bad account - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kOverpaymentFee(kMaxOverpaymentFee), - loanSetFee, - Ter(tefBAD_AUTH)); - // sfOverpaymentFee: too big - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kOverpaymentFee(kMaxOverpaymentFee + 1), - loanSetFee, - Ter(temINVALID)); - - // sfInterestRate: good value, bad account - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kInterestRate(kMaxInterestRate), - loanSetFee, - Ter(tefBAD_AUTH)); - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kInterestRate(TenthBips32(0)), - loanSetFee, - Ter(tefBAD_AUTH)); - // sfInterestRate: too big - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kInterestRate(kMaxInterestRate + 1), - loanSetFee, - Ter(temINVALID)); - // sfInterestRate: too small - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kInterestRate(TenthBips32(-1)), - loanSetFee, - Ter(temINVALID)); - - // sfLateInterestRate: good value, bad account - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kLateInterestRate(kMaxLateInterestRate), - loanSetFee, - Ter(tefBAD_AUTH)); - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kLateInterestRate(TenthBips32(0)), - loanSetFee, - Ter(tefBAD_AUTH)); - // sfLateInterestRate: too big - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kLateInterestRate(kMaxLateInterestRate + 1), - loanSetFee, - Ter(temINVALID)); - // sfLateInterestRate: too small - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kLateInterestRate(TenthBips32(-1)), - loanSetFee, - Ter(temINVALID)); - - // sfCloseInterestRate: good value, bad account - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kCloseInterestRate(kMaxCloseInterestRate), - loanSetFee, - Ter(tefBAD_AUTH)); - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kCloseInterestRate(TenthBips32(0)), - loanSetFee, - Ter(tefBAD_AUTH)); - // sfCloseInterestRate: too big - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kCloseInterestRate(kMaxCloseInterestRate + 1), - loanSetFee, - Ter(temINVALID)); - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kCloseInterestRate(TenthBips32(-1)), - loanSetFee, - Ter(temINVALID)); - - // sfOverpaymentInterestRate: good value, bad account - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kOverpaymentInterestRate(kMaxOverpaymentInterestRate), - loanSetFee, - Ter(tefBAD_AUTH)); - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kOverpaymentInterestRate(TenthBips32(0)), - loanSetFee, - Ter(tefBAD_AUTH)); - // sfOverpaymentInterestRate: too big - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kOverpaymentInterestRate(kMaxOverpaymentInterestRate + 1), - loanSetFee, - Ter(temINVALID)); - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kOverpaymentInterestRate(TenthBips32(-1)), - loanSetFee, - Ter(temINVALID)); - - // sfPaymentTotal: good value, bad account - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kPaymentTotal(LoanSet::kMinPaymentTotal), - loanSetFee, - Ter(tefBAD_AUTH)); - // sfPaymentTotal: too small (there is no max) - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kPaymentTotal(LoanSet::kMinPaymentTotal - 1), - loanSetFee, - Ter(temINVALID)); - - // sfPaymentInterval: good value, bad account - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kPaymentInterval(LoanSet::kMinPaymentInterval), - loanSetFee, - Ter(tefBAD_AUTH)); - // sfPaymentInterval: too small (there is no max) - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kPaymentInterval(LoanSet::kMinPaymentInterval - 1), - loanSetFee, - Ter(temINVALID)); - - // sfGracePeriod: good value, bad account - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, borrower), - kPaymentInterval(LoanSet::kMinPaymentInterval * 2), - kGracePeriod(LoanSet::kMinPaymentInterval * 2), - loanSetFee, - Ter(tefBAD_AUTH)); - // sfGracePeriod: larger than paymentInterval - env(set(evan, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - kPaymentInterval(LoanSet::kMinPaymentInterval * 2), - kGracePeriod(LoanSet::kMinPaymentInterval * 3), - loanSetFee, - Ter(temINVALID)); - - // insufficient fee - single sign - env(set(borrower, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - Ter(telINSUF_FEE_P)); - // insufficient fee - multisign - env(signers(lender, 2, {{evan, 1}, {borrower, 1}})); - env(signers(borrower, 2, {{evan, 1}, {lender, 1}})); - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Msig(evan, lender), - Msig(sfCounterpartySignature, evan, borrower), - Fee(env.current()->fees().base * 5 - 1), - Ter(telINSUF_FEE_P)); - // Bad multisign signatures for borrower (Account) - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Msig(alice, issuer), - Msig(sfCounterpartySignature, evan, borrower), - Fee(env.current()->fees().base * 5), - Ter(tefBAD_SIGNATURE)); - // Bad multisign signatures for issuer (Counterparty) - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Msig(evan, lender), - Msig(sfCounterpartySignature, alice, issuer), - Fee(env.current()->fees().base * 5 - 1), - Ter(tefBAD_SIGNATURE)); - env(signers(lender, kNone)); - env(signers(borrower, kNone)); - // multisign sufficient fee, but no signers set up - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Msig(evan, lender), - Msig(sfCounterpartySignature, evan, borrower), - Fee(env.current()->fees().base * 5), - Ter(tefNOT_MULTI_SIGNING)); - // not the broker owner, no counterparty, not signed by broker - // owner - env(set(borrower, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, evan), - loanSetFee, - Ter(tefBAD_AUTH)); - // not the broker owner, counterparty is borrower - env(set(evan, broker.brokerID, principalRequest), - kCounterparty(borrower), - Sig(sfCounterpartySignature, borrower), - loanSetFee, - Ter(tecNO_PERMISSION)); - // not a LoanBroker object, no counterparty - env(set(lender, badKeylet.key, principalRequest), - Sig(sfCounterpartySignature, evan), - loanSetFee, - Ter(temBAD_SIGNER)); - // not a LoanBroker object, counterparty is valid - env(set(lender, badKeylet.key, principalRequest), - kCounterparty(borrower), - Sig(sfCounterpartySignature, borrower), - loanSetFee, - Ter(tecNO_ENTRY)); - // borrower doesn't exist - env(set(lender, broker.brokerID, principalRequest), - kCounterparty(alice), - Sig(sfCounterpartySignature, alice), - loanSetFee, - Ter(terNO_ACCOUNT)); - - // Request more funds than the vault has available - env(set(evan, broker.brokerID, totalVaultRequest + 1), - Sig(sfCounterpartySignature, lender), - loanSetFee, - Ter(tecINSUFFICIENT_FUNDS)); - - // Request more funds than the broker's first-loss capital can - // cover. - env(set(evan, broker.brokerID, maxCoveredLoanRequest + 1), - Sig(sfCounterpartySignature, lender), - loanSetFee, - Ter(tecINSUFFICIENT_FUNDS)); - - // Frozen trust line / locked MPT issuance - // XRP can not be frozen, but run through the loop anyway to test - // the tecLIMIT_EXCEEDED case - { - auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - if (!BEAST_EXPECT(brokerSle)) - return; - - auto const vaultPseudo = [&]() { - auto const vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID))); - if (!BEAST_EXPECT(vaultSle)) - { - // This will be wrong, but the test has failed anyway. - return Account{lender}; - } - auto vaultPseudo = Account("Vault pseudo-account", vaultSle->at(sfAccount)); - return vaultPseudo; - }(); - - auto const [freeze, deepfreeze, unfreeze, expectedResult] = - [&]() -> std::tuple< - std::function, - std::function, - std::function, - TER> { - // Freeze / lock the asset - std::function const empty; - if (broker.asset.native()) - { - // XRP can't be frozen - return std::make_tuple(empty, empty, empty, tesSUCCESS); - } - if (broker.asset.holds()) - { - auto freeze = [&](Account const& holder) { - env(trust(issuer, holder[iouCurrency_](0), tfSetFreeze)); - }; - auto deepfreeze = [&](Account const& holder) { - env(trust(issuer, holder[iouCurrency_](0), tfSetFreeze | tfSetDeepFreeze)); - }; - auto unfreeze = [&](Account const& holder) { - env(trust( - issuer, holder[iouCurrency_](0), tfClearFreeze | tfClearDeepFreeze)); - }; - return std::make_tuple(freeze, deepfreeze, unfreeze, tecFROZEN); - } - - auto freeze = [&](Account const& holder) { - mptt.set({.account = issuer, .holder = holder, .flags = tfMPTLock}); - }; - auto unfreeze = [&](Account const& holder) { - mptt.set({.account = issuer, .holder = holder, .flags = tfMPTUnlock}); - }; - return std::make_tuple(freeze, empty, unfreeze, tecLOCKED); - }(); - - // Try freezing the accounts that can't be frozen - if (freeze) - { - for (auto const& account : {vaultPseudo, evan}) - { - // Freeze the account - freeze(account); - - // Try to create a loan with a frozen line - env(set(evan, broker.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - loanSetFee, - Ter(expectedResult)); - - // Unfreeze the account - BEAST_EXPECT(unfreeze); - unfreeze(account); - - // Ensure the line is unfrozen with a request that is fine - // except too it requests more principal than the broker can - // carry - env(set(evan, broker.brokerID, debtMaximumRequest + 1), - Sig(sfCounterpartySignature, lender), - loanSetFee, - Ter(tecLIMIT_EXCEEDED)); - } - } - - // Deep freeze the borrower, which prevents them from receiving - // funds - if (deepfreeze) - { - // Make sure evan has a trust line that so the issuer can - // freeze it. (Don't need to do this for the borrower, - // because LoanSet will create a line to the borrower - // automatically.) - env(trust(evan, issuer[iouCurrency_](100'000))); - - for (auto const& account : {// these accounts can't be frozen, which deep freeze - // implies - vaultPseudo, - evan, - // these accounts can't be deep frozen - lender}) - { - // Freeze evan - deepfreeze(account); - - // Try to create a loan with a deep frozen line - env(set(evan, broker.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - loanSetFee, - Ter(expectedResult)); - - // Unfreeze evan - BEAST_EXPECT(unfreeze); - unfreeze(account); - - // Ensure the line is unfrozen with a request that is fine - // except too it requests more principal than the broker can - // carry - env(set(evan, broker.brokerID, debtMaximumRequest + 1), - Sig(sfCounterpartySignature, lender), - loanSetFee, - Ter(tecLIMIT_EXCEEDED)); - } - } - } - - // Finally! Create a loan - - auto coverAvailable = [&env, this](uint256 const& brokerID, Number const& expected) { - if (auto const brokerSle = env.le(keylet::loanBroker(brokerID)); - BEAST_EXPECT(brokerSle)) - { - auto const available = brokerSle->at(sfCoverAvailable); - BEAST_EXPECT(available == expected); - return available; - } - return Number{}; - }; - auto getDefaultInfo = [&env, this](LoanState const& state, BrokerInfo const& broker) { - if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - BEAST_EXPECT(brokerSle)) - { - BEAST_EXPECT( - state.loanScale >= - (broker.asset.integral() - ? 0 - : std::max( - broker.vaultScale(env), state.principalOutstanding.exponent()))); - NumberRoundModeGuard const mg(Number::RoundingMode::Upward); - auto const defaultAmount = roundToAsset( - broker.asset, - std::min( - tenthBipsOfValue( - tenthBipsOfValue( - brokerSle->at(sfDebtTotal), broker.params.coverRateMin), - broker.params.coverRateLiquidation), - state.totalValue - state.managementFeeOutstanding), - state.loanScale); - return std::make_pair(defaultAmount, brokerSle->at(sfOwner)); - } - return std::make_pair(Number{}, AccountID{}); - }; - auto replenishCover = [&env, &coverAvailable]( - BrokerInfo const& broker, - AccountID const& brokerAcct, - Number const& startingCoverAvailable, - Number const& amountToBeCovered) { - coverAvailable(broker.brokerID, startingCoverAvailable - amountToBeCovered); - env(loan_broker::coverDeposit( - brokerAcct, broker.brokerID, STAmount{broker.asset, amountToBeCovered})); - coverAvailable(broker.brokerID, startingCoverAvailable); - env.close(); - }; - - auto defaultImmediately = [&](std::uint32_t baseFlag, bool impair = true) { - return [&, impair, baseFlag]( - Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { - // toEndOfLife - // - // Default the loan - - // Initialize values with the current state - auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus); - BEAST_EXPECT(state.flags == baseFlag); - - auto const& broker = verifyLoanStatus.broker; - auto const startingCoverAvailable = coverAvailable( - broker.brokerID, broker.asset(broker.params.coverDeposit).number()); - - if (impair) - { - // Check the vault - bool const canImpair = canImpairLoan(env, broker, state); - // Impair the loan, if possible - env(manage(lender, loanKeylet.key, tfLoanImpair), - canImpair ? Ter(tesSUCCESS) : Ter(tecLIMIT_EXCEEDED)); - - if (canImpair) - { - state.flags |= tfLoanImpair; - state.nextPaymentDate = env.now().time_since_epoch().count(); - - // Once the loan is impaired, it can't be impaired again - env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION)); - } - verifyLoanStatus(state); - } - - auto const nextDueDate = tp{d{state.nextPaymentDate}}; - - // Can't default the loan yet. The grace period hasn't - // expired - env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecTOO_SOON)); - - // Let some time pass so that the loan can be - // defaulted - env.close(nextDueDate + 60s); - - auto const [amountToBeCovered, brokerAcct] = getDefaultInfo(state, broker); - - // Default the loan - env(manage(lender, loanKeylet.key, tfLoanDefault)); - env.close(); - - // The LoanBroker just lost some of it's first-loss capital. - // Replenish it. - replenishCover(broker, brokerAcct, startingCoverAvailable, amountToBeCovered); - - state.flags |= tfLoanDefault; - state.paymentRemaining = 0; - state.totalValue = 0; - state.principalOutstanding = 0; - state.managementFeeOutstanding = 0; - state.nextPaymentDate = 0; - verifyLoanStatus(state); - - // Once a loan is defaulted, it can't be managed - env(manage(lender, loanKeylet.key, tfLoanUnimpair), Ter(tecNO_PERMISSION)); - env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION)); - // Can't make a payment on it either - env(pay(borrower, loanKeylet.key, broker.asset(300)), Ter(tecKILLED)); - }; - }; - - auto singlePayment = [&](Keylet const& loanKeylet, - VerifyLoanStatus const& verifyLoanStatus, - LoanState& state, - STAmount const& payoffAmount, - std::uint32_t numPayments, - std::uint32_t baseFlag, - std::uint32_t txFlags) { - // toEndOfLife - // - verifyLoanStatus(state); - - // Send some bogus pay transactions - env(pay(borrower, keylet::loan(uint256(0)).key, broker.asset(10), txFlags), - Ter(temINVALID)); - // broker.asset(80) is less than a single payment, but all these - // checks fail before that matters - env(pay(borrower, loanKeylet.key, broker.asset(-80), txFlags), Ter(temBAD_AMOUNT)); - env(pay(borrower, broker.brokerID, broker.asset(80), txFlags), Ter(tecNO_ENTRY)); - env(pay(evan, loanKeylet.key, broker.asset(80), txFlags), Ter(tecNO_PERMISSION)); - - // TODO: Write a general "isFlag" function? See STObject::isFlag. - // Maybe add a static overloaded member? - if (!(state.flags & lsfLoanOverpayment)) - { - // If the loan does not allow overpayments, send a payment that - // tries to make an overpayment. Do not include `txFlags`, so we - // don't end up duplicating the next test transaction. - // - // fixCleanup3_1_3 gates tfLoanOverpayment as a valid flag: - // with fix on → preflight passes, apply returns tecNO_PERMISSION; - // with fix off → preflight rejects the flag, returns temINVALID_FLAG. - bool const hasFix313 = env.current()->rules().enabled(fixCleanup3_1_3); - STAmount const overpayAmount{broker.asset, state.periodicPayment * Number{15, -1}}; - XRPAmount const overpayFee{ - baseFee * (Number{15, -1} / kLoanPaymentsPerFeeIncrement + 1)}; - env(pay(borrower, loanKeylet.key, overpayAmount, tfLoanOverpayment), - Fee(overpayFee), - Ter(hasFix313 ? TER{tecNO_PERMISSION} : TER{temINVALID_FLAG})); - - if (hasFix313) - { - env.disableFeature(fixCleanup3_1_3); - env(pay(borrower, loanKeylet.key, overpayAmount, tfLoanOverpayment), - Fee(overpayFee), - Ter(temINVALID_FLAG)); - env.enableFeature(fixCleanup3_1_3); - } - } - // Try to send a payment marked as multiple mutually exclusive - // payment types. Do not include `txFlags`, so we don't duplicate - // the prior test transaction. - env(pay(borrower, - loanKeylet.key, - broker.asset(state.periodicPayment * 2), - tfLoanLatePayment | tfLoanFullPayment), - Ter(temINVALID_FLAG)); - env(pay(borrower, - loanKeylet.key, - broker.asset(state.periodicPayment * 2), - tfLoanLatePayment | tfLoanOverpayment), - Ter(temINVALID_FLAG)); - env(pay(borrower, - loanKeylet.key, - broker.asset(state.periodicPayment * 2), - tfLoanOverpayment | tfLoanFullPayment), - Ter(temINVALID_FLAG)); - env(pay(borrower, - loanKeylet.key, - broker.asset(state.periodicPayment * 2), - tfLoanLatePayment | tfLoanOverpayment | tfLoanFullPayment), - Ter(temINVALID_FLAG)); - - { - auto const otherAsset = - broker.asset.raw() == assets[0].raw() ? assets[1] : assets[0]; - env(pay(borrower, loanKeylet.key, otherAsset(100), txFlags), Ter(tecWRONG_ASSET)); - } - - // Amount doesn't cover a single payment - env(pay(borrower, loanKeylet.key, STAmount{broker.asset, 1}, txFlags), - Ter(tecINSUFFICIENT_PAYMENT)); - - // Get the balance after these failed transactions take - // fees - auto const borrowerBalanceBeforePayment = env.balance(borrower, broker.asset); - - BEAST_EXPECT(payoffAmount > state.principalOutstanding); - // Try to pay a little extra to show that it's _not_ - // taken - auto const transactionAmount = payoffAmount + broker.asset(10); - - // Send a transaction that tries to pay more than the borrowers's - // balance - XRPAmount const badFee{ - baseFee * - (borrowerBalanceBeforePayment.number() * 2 / state.periodicPayment / - kLoanPaymentsPerFeeIncrement + - 1)}; - env(pay(borrower, - loanKeylet.key, - STAmount{broker.asset, borrowerBalanceBeforePayment.number() * 2}, - txFlags), - Fee(badFee), - Ter(tecINSUFFICIENT_FUNDS)); - - XRPAmount const goodFee{baseFee * (numPayments / kLoanPaymentsPerFeeIncrement + 1)}; - env(pay(borrower, loanKeylet.key, transactionAmount, txFlags), Fee(goodFee)); - - env.close(); - - // log << env.meta()->getJson() << std::endl; - - // Need to account for fees if the loan is in XRP - PrettyAmount adjustment = broker.asset(0); - if (broker.asset.native()) - { - adjustment = badFee + goodFee; - } - - state.paymentRemaining = 0; - state.principalOutstanding = 0; - state.totalValue = 0; - state.managementFeeOutstanding = 0; - state.previousPaymentDate = - state.nextPaymentDate + (state.paymentInterval * (numPayments - 1)); - state.nextPaymentDate = 0; - verifyLoanStatus(state); - - verifyLoanStatus.checkPayment( - state.loanScale, borrower, borrowerBalanceBeforePayment, payoffAmount, adjustment); - - // Can't impair or default a paid off loan - env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION)); - env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecNO_PERMISSION)); - }; - - auto fullPayment = [&](std::uint32_t baseFlag) { - return [&, baseFlag]( - Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { - // toEndOfLife - // - auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus); - env.close(state.startDate + 20s); - auto const loanAge = (env.now() - state.startDate).count(); - BEAST_EXPECT(loanAge == 30); - - // Full payoff amount will consist of - // 1. principal outstanding (1000) - // 2. accrued interest (at 12%) - // 3. prepayment penalty (closeInterest at 3.6%) - // 4. close payment fee (4) - // Calculate these values without the helper functions - // to verify they're working correctly The numbers in - // the below BEAST_EXPECTs may not hold across assets. - Number const interval = state.paymentInterval; - auto const periodicRate = interval * Number(12, -2) / kSecondsInYear; - BEAST_EXPECT( - periodicRate == Number(2283105022831050228ULL, -24, Number::Normalized{})); - STAmount const principalOutstanding{broker.asset, state.principalOutstanding}; - STAmount const accruedInterest{ - broker.asset, state.principalOutstanding * periodicRate * loanAge / interval}; - BEAST_EXPECT(accruedInterest == broker.asset(Number(1141552511415525, -19))); - STAmount const prepaymentPenalty{ - broker.asset, state.principalOutstanding * Number(36, -3)}; - BEAST_EXPECT(prepaymentPenalty == broker.asset(36)); - STAmount const closePaymentFee = broker.asset(4); - auto const payoffAmount = roundToScale( - principalOutstanding + accruedInterest + prepaymentPenalty + closePaymentFee, - state.loanScale); - BEAST_EXPECT( - payoffAmount == - roundToAsset( - broker.asset, - broker.asset(Number(1040000114155251, -12)).number(), - state.loanScale)); - - // The terms of this loan actually make the early payoff - // more expensive than just making payments - BEAST_EXPECT( - payoffAmount > - state.paymentRemaining * (state.periodicPayment + broker.asset(2).value())); - - singlePayment( - loanKeylet, - verifyLoanStatus, - state, - payoffAmount, - 1, - baseFlag, - tfLoanFullPayment); - }; - }; - - auto combineAllPayments = [&](std::uint32_t baseFlag) { - return - [&, baseFlag](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { - // toEndOfLife - // - - auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus); - env.close(); - - BEAST_EXPECT( - STAmount(broker.asset, state.periodicPayment) == - broker.asset(Number(8333457002039338267, -17))); - - // Make all the payments in one transaction - // service fee is 2 - auto const startingPayments = state.paymentRemaining; - STAmount const payoffAmount = [&]() { - NumberRoundModeGuard const mg(Number::RoundingMode::Upward); - auto const rawPayoff = - startingPayments * (state.periodicPayment + broker.asset(2).value()); - STAmount payoffAmount{broker.asset, rawPayoff}; - BEAST_EXPECTS( - payoffAmount == broker.asset(Number(1024014840244721, -12)), - to_string(payoffAmount)); - BEAST_EXPECT(payoffAmount > state.principalOutstanding); - - payoffAmount = roundToScale(payoffAmount, state.loanScale); - - return payoffAmount; - }(); - - auto const totalPayoffValue = - state.totalValue + startingPayments * broker.asset(2).value(); - STAmount const totalPayoffAmount{broker.asset, totalPayoffValue}; - - BEAST_EXPECTS( - totalPayoffAmount == payoffAmount, - "Payoff amount: " + to_string(payoffAmount) + - ". Total Value: " + to_string(totalPayoffAmount)); - - singlePayment( - loanKeylet, - verifyLoanStatus, - state, - payoffAmount, - state.paymentRemaining, - baseFlag, - 0); - }; - }; - - // There are a lot of fields that can be set on a loan, but most - // of them only affect the "math" when a payment is made. The - // only one that really affects behavior is the - // `tfLoanOverpayment` flag. - lifecycle( - caseLabel, - "Loan overpayment allowed - Impair and Default", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - defaultImmediately(lsfLoanOverpayment)); - - lifecycle( - caseLabel, - "Loan overpayment prohibited - Impair and Default", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - 0, - defaultImmediately(0)); - - lifecycle( - caseLabel, - "Loan overpayment allowed - Default without Impair", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - defaultImmediately(lsfLoanOverpayment, false)); - - lifecycle( - caseLabel, - "Loan overpayment prohibited - Default without Impair", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - 0, - defaultImmediately(0, false)); - - lifecycle( - caseLabel, - "Loan overpayment prohibited - Pay off immediately", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - 0, - fullPayment(0)); - - lifecycle( - caseLabel, - "Loan overpayment allowed - Pay off immediately", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - fullPayment(lsfLoanOverpayment)); - - lifecycle( - caseLabel, - "Loan overpayment prohibited - Combine all payments", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - 0, - combineAllPayments(0)); - - lifecycle( - caseLabel, - "Loan overpayment allowed - Combine all payments", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - combineAllPayments(lsfLoanOverpayment)); - - lifecycle( - caseLabel, - "Loan overpayment prohibited - Make payments", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - 0, - [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { - // toEndOfLife - // - // Draw and make multiple payments - auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus); - BEAST_EXPECT(state.flags == 0); - env.close(); - - verifyLoanStatus(state); - - env.close(state.startDate + 20s); - auto const loanAge = (env.now() - state.startDate).count(); - BEAST_EXPECT(loanAge == 30); - - // Periodic payment amount will consist of - // 1. principal outstanding (1000) - // 2. interest interest rate (at 12%) - // 3. payment interval (600s) - // 4. loan service fee (2) - // Calculate these values without the helper functions - // to verify they're working correctly The numbers in - // the below BEAST_EXPECTs may not hold across assets. - Number const interval = state.paymentInterval; - auto const periodicRate = interval * Number(12, -2) / kSecondsInYear; - BEAST_EXPECT( - periodicRate == Number(2283105022831050228, -24, Number::Normalized{})); - STAmount const roundedPeriodicPayment{ - broker.asset, - roundPeriodicPayment(broker.asset, state.periodicPayment, state.loanScale)}; - - testcase << currencyLabel << " Payment components: " - << "Payments remaining, rawInterest, rawPrincipal, " - "rawMFee, trackedValueDelta, trackedPrincipalDelta, " - "trackedInterestDelta, trackedMgmtFeeDelta, special"; - - auto const serviceFee = broker.asset(2); - - BEAST_EXPECT( - roundedPeriodicPayment == - roundToScale( - broker.asset( - Number(8333457002039338267, -17), Number::RoundingMode::Upward), - state.loanScale, - Number::RoundingMode::Upward)); - // 83334570.01162141 - // Include the service fee - STAmount const totalDue = roundToScale( - roundedPeriodicPayment + serviceFee, - state.loanScale, - Number::RoundingMode::Upward); - // Only check the first payment since the rounding - // may drift as payments are made - BEAST_EXPECT( - totalDue == - roundToScale( - broker.asset( - Number(8533457002039338267, -17), Number::RoundingMode::Upward), - state.loanScale, - Number::RoundingMode::Upward)); - - { - auto const raw = computeTheoreticalLoanState( - env.current()->rules(), - state.periodicPayment, - periodicRate, - state.paymentRemaining, - broker.params.managementFeeRate); - auto const rounded = constructLoanState( - state.totalValue, - state.principalOutstanding, - state.managementFeeOutstanding); - testcase << currencyLabel << " Loan starting state: " << state.paymentRemaining - << ", " << raw.interestDue << ", " << raw.principalOutstanding << ", " - << raw.managementFeeDue << ", " << rounded.valueOutstanding << ", " - << rounded.principalOutstanding << ", " << rounded.interestDue << ", " - << rounded.managementFeeDue; - } - - // Try to pay a little extra to show that it's _not_ - // taken - STAmount const transactionAmount = - STAmount{broker.asset, totalDue} + broker.asset(10); - // Only check the first payment since the rounding - // may drift as payments are made - BEAST_EXPECT( - transactionAmount == - roundToScale( - broker.asset(Number(9533457002039400, -14), Number::RoundingMode::Upward), - state.loanScale, - Number::RoundingMode::Upward)); - - auto const initialState = state; - xrpl::detail::PaymentComponents totalPaid{ - .trackedValueDelta = 0, - .trackedPrincipalDelta = 0, - .trackedManagementFeeDelta = 0}; - Number totalInterestPaid = 0; - std::size_t totalPaymentsMade = 0; - - xrpl::LoanState currentTrueState = computeTheoreticalLoanState( - env.current()->rules(), - state.periodicPayment, - periodicRate, - state.paymentRemaining, - broker.params.managementFeeRate); - - while (state.paymentRemaining > 0) - { - // Compute the expected principal amount - auto const paymentComponents = xrpl::detail::computePaymentComponents( - env.current()->rules(), - broker.asset.raw(), - state.loanScale, - state.totalValue, - state.principalOutstanding, - state.managementFeeOutstanding, - state.periodicPayment, - periodicRate, - state.paymentRemaining, - broker.params.managementFeeRate); - - BEAST_EXPECTS( - paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final || - paymentComponents.trackedValueDelta <= roundedPeriodicPayment, - "Delta: " + to_string(paymentComponents.trackedValueDelta) + - ", periodic payment: " + to_string(roundedPeriodicPayment)); - - xrpl::LoanState const nextTrueState = computeTheoreticalLoanState( - env.current()->rules(), - state.periodicPayment, - periodicRate, - state.paymentRemaining - 1, - broker.params.managementFeeRate); - xrpl::detail::LoanStateDeltas const deltas = currentTrueState - nextTrueState; - - testcase << currencyLabel << " Payment components: " << state.paymentRemaining - << ", " << deltas.interest << ", " << deltas.principal << ", " - << deltas.managementFee << ", " << paymentComponents.trackedValueDelta - << ", " << paymentComponents.trackedPrincipalDelta << ", " - << paymentComponents.trackedInterestPart() << ", " - << paymentComponents.trackedManagementFeeDelta << ", " - << [&]() -> char const* { - if (paymentComponents.specialCase == - ::xrpl::detail::PaymentSpecialCase::Final) - return "final"; - if (paymentComponents.specialCase == - ::xrpl::detail::PaymentSpecialCase::Extra) - return "extra"; - return "none"; - }(); - - auto const totalDueAmount = STAmount{ - broker.asset, paymentComponents.trackedValueDelta + serviceFee.number()}; - - // Due to the rounding algorithms to keep the interest and - // principal in sync with "true" values, the computed amount - // may be a little less than the rounded fixed payment - // amount. For integral types, the difference should be < 3 - // (1 unit for each of the interest and management fee). For - // IOUs, the difference should be after the 8th digit. - Number const diff = totalDue - totalDueAmount; - BEAST_EXPECT( - paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final || - diff == beast::kZero || - (diff > beast::kZero && - ((broker.asset.integral() && (static_cast(diff) < 3)) || - (state.loanScale - diff.exponent() > 13)))); - - BEAST_EXPECT( - paymentComponents.trackedValueDelta == - paymentComponents.trackedPrincipalDelta + - paymentComponents.trackedInterestPart() + - paymentComponents.trackedManagementFeeDelta); - BEAST_EXPECT( - paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final || - paymentComponents.trackedValueDelta <= roundedPeriodicPayment); - - BEAST_EXPECT( - state.paymentRemaining < 12 || - roundToAsset( - broker.asset, - deltas.principal, - state.loanScale, - Number::RoundingMode::Upward) == - roundToScale( - broker.asset( - Number(8333228691531218890, -17), Number::RoundingMode::Upward), - state.loanScale, - Number::RoundingMode::Upward)); - BEAST_EXPECT( - paymentComponents.trackedPrincipalDelta >= beast::kZero && - paymentComponents.trackedPrincipalDelta <= state.principalOutstanding); - BEAST_EXPECT( - paymentComponents.specialCase != xrpl::detail::PaymentSpecialCase::Final || - paymentComponents.trackedPrincipalDelta == state.principalOutstanding); - BEAST_EXPECT( - paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final || - (state.periodicPayment.exponent() - - (deltas.principal + deltas.interest + deltas.managementFee - - state.periodicPayment) - .exponent()) > 14); - - auto const borrowerBalanceBeforePayment = env.balance(borrower, broker.asset); - - if (canImpairLoan(env, broker, state)) - { - // Making a payment will unimpair the loan - env(manage(lender, loanKeylet.key, tfLoanImpair)); - } - - env.close(); - - // Make the payment - env(pay(borrower, loanKeylet.key, transactionAmount)); - - env.close(); - - // Need to account for fees if the loan is in XRP - PrettyAmount adjustment = broker.asset(0); - if (broker.asset.native()) - { - adjustment = env.current()->fees().base; - } - - // Check the result - verifyLoanStatus.checkPayment( - state.loanScale, - borrower, - borrowerBalanceBeforePayment, - totalDueAmount, - adjustment); - - --state.paymentRemaining; - state.previousPaymentDate = state.nextPaymentDate; - if (paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final) - { - state.paymentRemaining = 0; - state.nextPaymentDate = 0; - } - else - { - state.nextPaymentDate += state.paymentInterval; - } - state.principalOutstanding -= paymentComponents.trackedPrincipalDelta; - state.managementFeeOutstanding -= paymentComponents.trackedManagementFeeDelta; - state.totalValue -= paymentComponents.trackedValueDelta; - - verifyLoanStatus(state); - - totalPaid.trackedValueDelta += paymentComponents.trackedValueDelta; - totalPaid.trackedPrincipalDelta += paymentComponents.trackedPrincipalDelta; - totalPaid.trackedManagementFeeDelta += - paymentComponents.trackedManagementFeeDelta; - totalInterestPaid += paymentComponents.trackedInterestPart(); - ++totalPaymentsMade; - - currentTrueState = nextTrueState; - } - - // Loan is paid off - BEAST_EXPECT(state.paymentRemaining == 0); - BEAST_EXPECT(state.principalOutstanding == 0); - - // Make sure all the payments add up - BEAST_EXPECT(totalPaid.trackedValueDelta == initialState.totalValue); - BEAST_EXPECT(totalPaid.trackedPrincipalDelta == initialState.principalOutstanding); - BEAST_EXPECT( - totalPaid.trackedManagementFeeDelta == initialState.managementFeeOutstanding); - // This is almost a tautology given the previous checks, but - // check it anyway for completeness. - BEAST_EXPECT( - totalInterestPaid == - initialState.totalValue - - (initialState.principalOutstanding + - initialState.managementFeeOutstanding)); - BEAST_EXPECT(totalPaymentsMade == initialState.paymentRemaining); - - // Can't impair or default a paid off loan - env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION)); - env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecNO_PERMISSION)); - }); - -#if LOAN_TODO - // TODO - - /* - LoanPay fails with tecINVARIANT_FAILED error when loan_broker(also - borrower) tries to do the payment. Here's the scenario: Create a XRP - loan with loan broker as borrower, loan origination fee and loan service - fee. Loan broker makes the first payment with periodic payment and loan - service fee. - */ - - auto time = [&](std::string label, std::function timed) { - if (!BEAST_EXPECT(timed)) - return; - - using clock_type = std::chrono::steady_clock; - using duration_type = std::chrono::milliseconds; - - auto const start = clock_type::now(); - timed(); - auto const duration = - std::chrono::duration_cast(clock_type::now() - start); - - log << label << " took " << duration.count() << "ms" << std::endl; - - return duration; - }; - - lifecycle( - caseLabel, - "timing", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { - // Estimate optimal values for kLoanPaymentsPerFeeIncrement and - // kLoanMaximumPaymentsPerTransaction. - using namespace loan; - - auto const state = getCurrentState(env, broker, verifyLoanStatus.keylet); - auto const serviceFee = broker.asset(2).value(); - - STAmount const totalDue{ - broker.asset, - roundPeriodicPayment( - broker.asset, state.periodicPayment + serviceFee, state.loanScale)}; - - // Make a single payment - time("single payment", [&]() { env(pay(borrower, loanKeylet.key, totalDue)); }); - env.close(); - - // Make all but the final payment - auto const numPayments = (state.paymentRemaining - 2); - STAmount const bigPayment{broker.asset, totalDue * numPayments}; - XRPAmount const bigFee{baseFee * (numPayments / kLoanPaymentsPerFeeIncrement + 1)}; - time("ten payments", [&]() { - env(pay(borrower, loanKeylet.key, bigPayment), Fee(bigFee)); - }); - env.close(); - - time("final payment", [&]() { - // Make the final payment - env(pay(borrower, loanKeylet.key, totalDue + STAmount{broker.asset, 1})); - }); - env.close(); - }); - - lifecycle( - caseLabel, - "Loan overpayment allowed - Explicit overpayment", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; }); - - lifecycle( - caseLabel, - "Loan overpayment prohibited - Late payment", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; }); - - lifecycle( - caseLabel, - "Loan overpayment allowed - Late payment", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; }); - - lifecycle( - caseLabel, - "Loan overpayment allowed - Late payment and overpayment", - env, - loanAmount, - interestExponent, - lender, - borrower, - evan, - broker, - pseudoAcct, - tfLoanOverpayment, - [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; }); - -#endif - } - - void - testLoanSet(FeatureBitset features) - { - using namespace jtx; - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - struct CaseArgs - { - bool requireAuth = false; - bool authorizeBorrower = false; - int initialXRP = 1'000'000; - }; - - auto const testCase = [&, this]( - std::function mptTest, - std::function iouTest, - CaseArgs args = {}) { - Env env(*this, features); - env.fund(XRP(args.initialXRP), issuer, lender, borrower); - env.close(); - if (args.requireAuth) - { - env(fset(issuer, asfRequireAuth)); - env.close(); - } - - // We need two different asset types, MPT and IOU. Prepare MPT - // first - MPTTester mptt{env, issuer, kMptInitNoFund}; - - auto const kNone = LedgerSpecificFlags(0); - mptt.create( - {.flags = tfMPTCanTransfer | tfMPTCanLock | - (args.requireAuth ? tfMPTRequireAuth : kNone)}); - env.close(); - PrettyAsset const mptAsset = mptt.issuanceID(); - mptt.authorize({.account = lender}); - mptt.authorize({.account = borrower}); - env.close(); - if (args.requireAuth) - { - mptt.authorize({.account = issuer, .holder = lender}); - if (args.authorizeBorrower) - mptt.authorize({.account = issuer, .holder = borrower}); - env.close(); - } - - env(pay(issuer, lender, mptAsset(10'000'000))); - env.close(); - - // Prepare IOU - PrettyAsset const iouAsset = issuer[iouCurrency_]; - env(trust(lender, iouAsset(10'000'000))); - env(trust(borrower, iouAsset(10'000'000))); - env.close(); - if (args.requireAuth) - { - env(trust(issuer, iouAsset(0), lender, tfSetfAuth)); - env(pay(issuer, lender, iouAsset(10'000'000))); - if (args.authorizeBorrower) - { - env(trust(issuer, iouAsset(0), borrower, tfSetfAuth)); - env(pay(issuer, borrower, iouAsset(10'000))); - } - } - else - { - env(pay(issuer, lender, iouAsset(10'000'000))); - env(pay(issuer, borrower, iouAsset(10'000))); - } - env.close(); - - // Create vaults and loan brokers - std::array const assets{mptAsset, iouAsset}; - std::vector brokers; - brokers.reserve(assets.size()); - for (auto const& asset : assets) - { - brokers.emplace_back(createVaultAndBroker(env, asset, lender)); - } - - if (mptTest) - mptTest(env, brokers[0], mptt); - if (iouTest) - iouTest(env, brokers[1]); - }; - - testCase( - [&, this](Env& env, BrokerInfo const& broker, auto&) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase("MPT issuer is borrower, issuer submits"); - env(set(issuer, broker.brokerID, principalRequest), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5)); - - testcase("MPT issuer is borrower, lender submits"); - env(set(lender, broker.brokerID, principalRequest), - kCounterparty(issuer), - Sig(sfCounterpartySignature, issuer), - Fee(env.current()->fees().base * 5)); - }, - [&, this](Env& env, BrokerInfo const& broker) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase("IOU issuer is borrower, issuer submits"); - env(set(issuer, broker.brokerID, principalRequest), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5)); - - testcase("IOU issuer is borrower, lender submits"); - env(set(lender, broker.brokerID, principalRequest), - kCounterparty(issuer), - Sig(sfCounterpartySignature, issuer), - Fee(env.current()->fees().base * 5)); - }, - CaseArgs{.requireAuth = true}); - - testCase( - [&, this](Env& env, BrokerInfo const& broker, auto&) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase("MPT unauthorized borrower, borrower submits"); - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5), - Ter{tecNO_AUTH}); - - testcase("MPT unauthorized borrower, lender submits"); - env(set(lender, broker.brokerID, principalRequest), - kCounterparty(borrower), - Sig(sfCounterpartySignature, borrower), - Fee(env.current()->fees().base * 5), - Ter{tecNO_AUTH}); - }, - [&, this](Env& env, BrokerInfo const& broker) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase("IOU unauthorized borrower, borrower submits"); - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5), - Ter{tecNO_AUTH}); - - testcase("IOU unauthorized borrower, lender submits"); - env(set(lender, broker.brokerID, principalRequest), - kCounterparty(borrower), - Sig(sfCounterpartySignature, borrower), - Fee(env.current()->fees().base * 5), - Ter{tecNO_AUTH}); - }, - CaseArgs{.requireAuth = true}); - - auto const [acctReserve, incReserve] = [this]() -> std::pair { - Env const env{*this, testableAmendments()}; - return { - env.current()->fees().accountReserve(0, 1).drops() / kDropsPerXrp.drops(), - env.current()->fees().increment.drops() / kDropsPerXrp.drops()}; - }(); - - testCase( - [&, this](Env& env, BrokerInfo const& broker, MPTTester& mptt) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase( - "MPT authorized borrower, borrower submits, borrower has " - "no reserve"); - mptt.authorize({.account = borrower, .flags = tfMPTUnauthorize}); - env.close(); - - auto const mptoken = keylet::mptoken(mptt.issuanceID(), borrower); - auto const sleMPT1 = env.le(mptoken); - BEAST_EXPECT(sleMPT1 == nullptr); - - // Burn some XRP - env(noop(borrower), Fee(XRP((acctReserve * 2) + (incReserve * 2)))); - env.close(); - - // Cannot create loan, not enough reserve to create MPToken - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5), - Ter{tecINSUFFICIENT_RESERVE}); - env.close(); - - // Can create loan now, will implicitly create MPToken - env(pay(issuer, borrower, XRP(incReserve))); - env.close(); - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5)); - env.close(); - - auto const sleMPT2 = env.le(mptoken); - BEAST_EXPECT(sleMPT2 != nullptr); - }, - {}, - CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1}); - - testCase( - {}, - [&, this](Env& env, BrokerInfo const& broker) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase( - "IOU authorized borrower, borrower submits, borrower has " - "no reserve"); - // Remove trust line from borrower to issuer - env.trust(broker.asset(0), borrower); - env.close(); - - env(pay(borrower, issuer, broker.asset(10'000))); - env.close(); - auto const trustline = keylet::trustLine(borrower, broker.asset.raw().get()); - auto const sleLine1 = env.le(trustline); - BEAST_EXPECT(sleLine1 == nullptr); - - // Burn some XRP - env(noop(borrower), Fee(XRP((acctReserve * 2) + (incReserve * 2)))); - env.close(); - - // Cannot create loan, not enough reserve to create trust line - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5), - Ter{tecNO_LINE_INSUF_RESERVE}); - env.close(); - - // Can create loan now, will implicitly create trust line - env(pay(issuer, borrower, XRP(incReserve))); - env.close(); - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5)); - env.close(); - - auto const sleLine2 = env.le(trustline); - BEAST_EXPECT(sleLine2 != nullptr); - }, - CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1}); - - testCase( - [&, this](Env& env, BrokerInfo const& broker, MPTTester& mptt) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase( - "MPT authorized borrower, borrower submits, lender has " - "no reserve"); - auto const mptoken = keylet::mptoken(mptt.issuanceID(), lender); - auto const sleMPT1 = env.le(mptoken); - BEAST_EXPECT(sleMPT1 != nullptr); - - env(pay(lender, issuer, broker.asset(sleMPT1->at(sfMPTAmount)))); - env.close(); - - mptt.authorize({.account = lender, .flags = tfMPTUnauthorize}); - env.close(); - - auto const sleMPT2 = env.le(mptoken); - BEAST_EXPECT(sleMPT2 == nullptr); - - // Burn some XRP - env(noop(lender), Fee(XRP(incReserve))); - env.close(); - - // Cannot create loan, not enough reserve to create MPToken - env(set(borrower, broker.brokerID, principalRequest), - kLoanOriginationFee(broker.asset(1).value()), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5), - Ter{tecINSUFFICIENT_RESERVE}); - env.close(); - - // Can create loan now, will implicitly create MPToken - env(pay(issuer, lender, XRP(incReserve))); - env.close(); - env(set(borrower, broker.brokerID, principalRequest), - kLoanOriginationFee(broker.asset(1).value()), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5)); - env.close(); - - auto const sleMPT3 = env.le(mptoken); - BEAST_EXPECT(sleMPT3 != nullptr); - }, - {}, - CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1}); - - testCase( - {}, - [&, this](Env& env, BrokerInfo const& broker) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase( - "IOU authorized borrower, borrower submits, lender has no " - "reserve"); - // Remove trust line from lender to issuer - env.trust(broker.asset(0), lender); - env.close(); - - auto const trustline = keylet::trustLine(lender, broker.asset.raw().get()); - auto const sleLine1 = env.le(trustline); - BEAST_EXPECT(sleLine1 != nullptr); - - env(pay(lender, issuer, broker.asset(abs(sleLine1->at(sfBalance).value())))); - env.close(); - auto const sleLine2 = env.le(trustline); - BEAST_EXPECT(sleLine2 == nullptr); - - // Burn some XRP - env(noop(lender), Fee(XRP(incReserve))); - env.close(); - - // Cannot create loan, not enough reserve to create trust line - env(set(borrower, broker.brokerID, principalRequest), - kLoanOriginationFee(broker.asset(1).value()), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5), - Ter{tecNO_LINE_INSUF_RESERVE}); - env.close(); - - // Can create loan now, will implicitly create trust line - env(pay(issuer, lender, XRP(incReserve))); - env.close(); - env(set(borrower, broker.brokerID, principalRequest), - kLoanOriginationFee(broker.asset(1).value()), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5)); - env.close(); - - auto const sleLine3 = env.le(trustline); - BEAST_EXPECT(sleLine3 != nullptr); - }, - CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1}); - - testCase( - [&, this](Env& env, BrokerInfo const& broker, MPTTester& mptt) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase("MPT authorized borrower, unauthorized lender"); - auto const mptoken = keylet::mptoken(mptt.issuanceID(), lender); - auto const sleMPT1 = env.le(mptoken); - BEAST_EXPECT(sleMPT1 != nullptr); - - env(pay(lender, issuer, broker.asset(sleMPT1->at(sfMPTAmount)))); - env.close(); - - mptt.authorize({.account = lender, .flags = tfMPTUnauthorize}); - env.close(); - - auto const sleMPT2 = env.le(mptoken); - BEAST_EXPECT(sleMPT2 == nullptr); - - // Cannot create loan, lender not authorized to receive fee - env(set(borrower, broker.brokerID, principalRequest), - kLoanOriginationFee(broker.asset(1).value()), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5), - Ter{tecNO_AUTH}); - env.close(); - - // Cannot create loan, even without an origination fee - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5), - Ter{tecNO_AUTH}); - env.close(); - - // No MPToken for lender - no authorization and no payment - auto const sleMPT3 = env.le(mptoken); - BEAST_EXPECT(sleMPT3 == nullptr); - }, - {}, - CaseArgs{.requireAuth = true, .authorizeBorrower = true}); - - testCase( - [&, this](Env& env, BrokerInfo const& broker, auto&) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase("MPT authorized borrower, borrower submits"); - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5)); - }, - [&, this](Env& env, BrokerInfo const& broker) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase("IOU authorized borrower, borrower submits"); - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5)); - }, - CaseArgs{.requireAuth = true, .authorizeBorrower = true}); - - testCase( - [&, this](Env& env, BrokerInfo const& broker, auto&) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase("MPT authorized borrower, lender submits"); - env(set(lender, broker.brokerID, principalRequest), - kCounterparty(borrower), - Sig(sfCounterpartySignature, borrower), - Fee(env.current()->fees().base * 5)); - }, - [&, this](Env& env, BrokerInfo const& broker) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - - testcase("IOU authorized borrower, lender submits"); - env(set(lender, broker.brokerID, principalRequest), - kCounterparty(borrower), - Sig(sfCounterpartySignature, borrower), - Fee(env.current()->fees().base * 5)); - }, - CaseArgs{.requireAuth = true, .authorizeBorrower = true}); - - jtx::Account const alice{"alice"}; - jtx::Account const bella{"bella"}; - auto const msigSetup = [&](Env& env, Account const& account) { - json::Value const tx1 = signers(account, 2, {{alice, 1}, {bella, 1}}); - env(tx1); - env.close(); - }; - - testCase( - [&, this](Env& env, BrokerInfo const& broker, auto&) { - using namespace loan; - msigSetup(env, lender); - Number const principalRequest = broker.asset(1'000).value(); - - testcase( - "MPT authorized borrower, borrower submits, lender " - "multisign"); - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Msig(sfCounterpartySignature, alice, bella), - Fee(env.current()->fees().base * 5)); - }, - [&, this](Env& env, BrokerInfo const& broker) { - using namespace loan; - msigSetup(env, lender); - Number const principalRequest = broker.asset(1'000).value(); - - testcase( - "IOU authorized borrower, borrower submits, lender " - "multisign"); - env(set(borrower, broker.brokerID, principalRequest), - kCounterparty(lender), - Msig(sfCounterpartySignature, alice, bella), - Fee(env.current()->fees().base * 5)); - }, - CaseArgs{.requireAuth = true, .authorizeBorrower = true}); - - testCase( - [&, this](Env& env, BrokerInfo const& broker, auto&) { - using namespace loan; - msigSetup(env, borrower); - Number const principalRequest = broker.asset(1'000).value(); - - testcase( - "MPT authorized borrower, lender submits, borrower " - "multisign"); - env(set(lender, broker.brokerID, principalRequest), - kCounterparty(borrower), - Msig(sfCounterpartySignature, alice, bella), - Fee(env.current()->fees().base * 5)); - }, - [&, this](Env& env, BrokerInfo const& broker) { - using namespace loan; - msigSetup(env, borrower); - Number const principalRequest = broker.asset(1'000).value(); - - testcase( - "IOU authorized borrower, lender submits, borrower " - "multisign"); - env(set(lender, broker.brokerID, principalRequest), - kCounterparty(borrower), - Msig(sfCounterpartySignature, alice, bella), - Fee(env.current()->fees().base * 5)); - }, - CaseArgs{.requireAuth = true, .authorizeBorrower = true}); - - testCase( - [&, this](Env& env, BrokerInfo const& broker, auto&) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - Vault const vault{env}; - auto tx = vault.set({.owner = lender, .id = broker.vaultID}); - tx[sfAssetsMaximum] = BrokerParameters::defaults().vaultDeposit; - env(tx); - env.close(); - - testcase("Vault at maximum value"); - env(set(issuer, broker.brokerID, principalRequest), - kCounterparty(lender), - kInterestRate(TenthBips32(10'000)), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5), - Ter(tecLIMIT_EXCEEDED)); - }, - nullptr); - - testCase( - [&, this](Env& env, BrokerInfo const& broker, auto&) { - using namespace loan; - Number const principalRequest = broker.asset(1'000).value(); - Vault const vault{env}; - auto tx = vault.set({.owner = lender, .id = broker.vaultID}); - tx[sfAssetsMaximum] = - BrokerParameters::defaults().vaultDeposit + broker.asset(1).number(); - env(tx); - env.close(); - - testcase("Vault maximum value exceeded"); - env(set(issuer, broker.brokerID, principalRequest), - kCounterparty(lender), - kInterestRate(TenthBips32(100'000)), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 5), - kPaymentTotal(2), - kPaymentInterval(3600 * 24), - Ter(tecLIMIT_EXCEEDED)); - }, - nullptr); - } - - void - testLifecycle(FeatureBitset features) - { - testcase("Lifecycle"); - using namespace jtx; - - // Create 3 loan brokers: one for XRP, one for an IOU, and one for - // an MPT. That'll require three corresponding SAVs. - Env env(*this, features); - - Account const issuer{"issuer"}; - // For simplicity, lender will be the sole actor for the vault & - // brokers. - Account const lender{"lender"}; - // Borrower only wants to borrow - Account const borrower{"borrower"}; - // Evan will attempt to be naughty - Account const evan{"evan"}; - // Do not fund alice - Account const alice{"alice"}; - - // Fund the accounts and trust lines with the same amount so that - // tests can use the same values regardless of the asset. - env.fund(XRP(100'000'000), issuer, noripple(lender, borrower, evan)); - env.close(); - - // Create assets - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - PrettyAsset const iouAsset = issuer[iouCurrency_]; - env(trust(lender, iouAsset(10'000'000))); - env(trust(borrower, iouAsset(10'000'000))); - env(trust(evan, iouAsset(10'000'000))); - env(pay(issuer, evan, iouAsset(1'000'000))); - env(pay(issuer, lender, iouAsset(10'000'000))); - // Fund the borrower with enough to cover interest and fees - env(pay(issuer, borrower, iouAsset(10'000))); - env.close(); - - MPTTester mptt{env, issuer, kMptInitNoFund}; - mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); - // Scale the MPT asset a little bit so we can get some interest - PrettyAsset const mptAsset{mptt.issuanceID(), 100}; - mptt.authorize({.account = lender}); - mptt.authorize({.account = borrower}); - mptt.authorize({.account = evan}); - env(pay(issuer, lender, mptAsset(10'000'000))); - env(pay(issuer, evan, mptAsset(1'000'000))); - // Fund the borrower with enough to cover interest and fees - env(pay(issuer, borrower, mptAsset(10'000))); - env.close(); - - std::array const assets{iouAsset, xrpAsset, mptAsset}; - - // Create vaults and loan brokers - std::vector brokers; - brokers.reserve(assets.size()); - for (auto const& asset : assets) - { - brokers.emplace_back(createVaultAndBroker( - env, asset, lender, BrokerParameters{.data = "spam spam spam spam"})); - } - - // Create and update Loans - for (auto const& broker : brokers) - { - for (int amountExponent = 3; amountExponent >= 3; --amountExponent) - { - Number const loanAmount{1, amountExponent}; - for (int interestExponent = 0; interestExponent >= 0; --interestExponent) - { - testCaseWrapper(env, mptt, assets, broker, loanAmount, interestExponent); - } - } - - if (auto brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - BEAST_EXPECT(brokerSle)) - { - BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); - BEAST_EXPECT(brokerSle->at(sfDebtTotal) == 0); - - auto const coverAvailable = brokerSle->at(sfCoverAvailable); - env(loan_broker::coverWithdraw( - lender, broker.brokerID, STAmount(broker.asset, coverAvailable))); - env.close(); - - brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - BEAST_EXPECT(brokerSle && brokerSle->at(sfCoverAvailable) == 0); - } - // Verify we can delete the loan broker - env(loan_broker::del(lender, broker.brokerID)); - env.close(); - } - } - - void - testSelfLoan(FeatureBitset features) - { - testcase << "Self Loan"; - - using namespace jtx; - using namespace std::chrono_literals; - // Create 3 loan brokers: one for XRP, one for an IOU, and one for - // an MPT. That'll require three corresponding SAVs. - Env env(*this, features); - - Account const issuer{"issuer"}; - // For simplicity, lender will be the sole actor for the vault & - // brokers. - Account const lender{"lender"}; - - // Fund the accounts and trust lines with the same amount so that - // tests can use the same values regardless of the asset. - env.fund(XRP(100'000'000), issuer, noripple(lender)); - env.close(); - - // Use an XRP asset for simplicity - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - - // Create vaults and loan brokers - BrokerInfo broker{createVaultAndBroker(env, xrpAsset, lender)}; - - using namespace loan; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest{1, 3}; - - // The LoanSet json can be created without a counterparty signature, - // but it will not pass preflight - auto createJson = env.json( - set(lender, broker.brokerID, broker.asset(principalRequest).value()), Fee(loanSetFee)); - env(createJson, Ter(temBAD_SIGNER)); - - // Adding an empty counterparty signature object also fails, but - // at the RPC level. - createJson = env.json(createJson, Json(sfCounterpartySignature, json::ValueType::Object)); - env(createJson, Ter(telENV_RPC_FAILED)); - - if (auto const jt = env.jt(createJson); BEAST_EXPECT(jt.stx)) - { - Serializer s; - jt.stx->add(s); - auto const jr = env.rpc("submit", strHex(s.slice())); - - BEAST_EXPECT(jr.isMember(jss::result)); - auto const jResult = jr[jss::result]; - BEAST_EXPECT(jResult[jss::error] == "invalidTransaction"); - BEAST_EXPECT( - jResult[jss::error_exception] == - "fails local checks: Transaction has bad signature."); - } - - // Copy the transaction signature into the counterparty signature. - json::Value counterpartyJson{json::ValueType::Object}; - counterpartyJson[sfTxnSignature] = createJson[sfTxnSignature]; - counterpartyJson[sfSigningPubKey] = createJson[sfSigningPubKey]; - if (!BEAST_EXPECT(!createJson.isMember(jss::Signers))) - counterpartyJson[sfSigners] = createJson[sfSigners]; - - // The duplicated signature works - createJson = env.json(createJson, Json(sfCounterpartySignature, counterpartyJson)); - env(createJson); - - env.close(); - - auto const startDate = env.current()->header().parentCloseTime; - - // Loan is successfully created - { - auto const res = env.rpc("account_objects", lender.human()); - auto const objects = res[jss::result][jss::account_objects]; - - std::map types; - BEAST_EXPECT(objects.size() == 4); - for (auto const& object : objects) - { - ++types[object[sfLedgerEntryType].asString()]; - } - BEAST_EXPECT(types.size() == 4); - for (std::string const type : {"MPToken", "Vault", "LoanBroker", "Loan"}) - { - BEAST_EXPECT(types[type] == 1); - } - } - auto const loanID = [&]() { - json::Value params(json::ValueType::Object); - params[jss::account] = lender.human(); - params[jss::type] = "Loan"; - auto const res = env.rpc("json", "account_objects", to_string(params)); - auto const objects = res[jss::result][jss::account_objects]; - - BEAST_EXPECT(objects.size() == 1); - - auto const loan = objects[0u]; - BEAST_EXPECT(loan[sfBorrower] == lender.human()); - // soeDEFAULT fields are not returned if they're in the default - // state - BEAST_EXPECT(!loan.isMember(sfCloseInterestRate)); - BEAST_EXPECT(!loan.isMember(sfClosePaymentFee)); - BEAST_EXPECT(loan[sfFlags] == 0); - BEAST_EXPECT(loan[sfGracePeriod] == 60); - BEAST_EXPECT(!loan.isMember(sfInterestRate)); - BEAST_EXPECT(!loan.isMember(sfLateInterestRate)); - BEAST_EXPECT(!loan.isMember(sfLatePaymentFee)); - BEAST_EXPECT(loan[sfLoanBrokerID] == to_string(broker.brokerID)); - BEAST_EXPECT(!loan.isMember(sfLoanOriginationFee)); - BEAST_EXPECT(loan[sfLoanSequence] == 1); - BEAST_EXPECT(!loan.isMember(sfLoanServiceFee)); - BEAST_EXPECT(loan[sfNextPaymentDueDate] == loan[sfStartDate].asUInt() + 60); - BEAST_EXPECT(!loan.isMember(sfOverpaymentFee)); - BEAST_EXPECT(!loan.isMember(sfOverpaymentInterestRate)); - BEAST_EXPECT(loan[sfPaymentInterval] == 60); - BEAST_EXPECT(loan[sfPeriodicPayment] == "1000000000"); - BEAST_EXPECT(loan[sfPaymentRemaining] == 1); - BEAST_EXPECT(!loan.isMember(sfPreviousPaymentDueDate)); - BEAST_EXPECT(loan[sfPrincipalOutstanding] == "1000000000"); - BEAST_EXPECT(loan[sfTotalValueOutstanding] == "1000000000"); - BEAST_EXPECT(!loan.isMember(sfLoanScale)); - BEAST_EXPECT(loan[sfStartDate].asUInt() == startDate.time_since_epoch().count()); - - return loan["index"].asString(); - }(); - auto const loanKeylet{keylet::loan(uint256{std::string_view(loanID)})}; - - env.close(startDate); - - // Make a payment - env(pay(lender, loanKeylet.key, broker.asset(1000))); - } - - void - testBatchBypassCounterparty(FeatureBitset features) - { - // From FIND-001 - testcase << "Batch Bypass Counterparty"; - - bool const lendingBatchEnabled = !std::ranges::any_of( - Batch::kDisabledTxTypes, - [](auto const& disabled) { return disabled == ttLOAN_BROKER_SET; }); - - using namespace jtx; - using namespace std::chrono_literals; - Env env(*this, features); - - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - BrokerParameters const brokerParams; - env.fund(XRP(brokerParams.vaultDeposit * 100), lender, borrower); - env.close(); - - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - - BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; - - using namespace loan; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest{1, 3}; - - auto forgedLoanSet = set(borrower, broker.brokerID, principalRequest, 0); - - json::Value randomData{json::ValueType::Object}; - randomData[jss::SigningPubKey] = json::StaticString{"2600"}; - json::Value sigObject{json::ValueType::Object}; - sigObject[jss::SigningPubKey] = strHex(lender.pk().slice()); - Serializer ss; - ss.add32(HashPrefix::TxSign); - parse(randomData).addWithoutSigningFields(ss); - auto const sig = xrpl::sign(borrower.pk(), borrower.sk(), ss.slice()); - sigObject[jss::TxnSignature] = strHex(Slice{sig.data(), sig.size()}); - - forgedLoanSet[json::StaticString{"CounterpartySignature"}] = sigObject; - - // ? Fails because the lender hasn't signed the tx - env(env.json(forgedLoanSet, Fee(loanSetFee)), Ter(telENV_RPC_FAILED)); - - auto const seq = env.seq(borrower); - auto const batchFee = batch::calcBatchFee(env, 1, 2); - // ! Should fail because the lender hasn't signed the tx - env(batch::outer(borrower, seq, batchFee, tfAllOrNothing), - batch::Inner(forgedLoanSet, seq + 1), - batch::Inner(pay(borrower, lender, XRP(1)), seq + 2), - Ter(lendingBatchEnabled ? temBAD_SIGNATURE : temINVALID_INNER_BATCH)); - env.close(); - - // ? Check that the loan was NOT created - { - json::Value params(json::ValueType::Object); - params[jss::account] = borrower.human(); - params[jss::type] = "Loan"; - auto const res = env.rpc("json", "account_objects", to_string(params)); - auto const objects = res[jss::result][jss::account_objects]; - BEAST_EXPECT(objects.size() == 0); - } - } - - void - testWrongMaxDebtBehavior(FeatureBitset features) - { - // From FIND-003 - testcase << "Wrong Max Debt Behavior"; - - using namespace jtx; - using namespace std::chrono_literals; - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - - BrokerParameters const brokerParams{.debtMax = 0}; - env.fund(XRP(brokerParams.vaultDeposit * 100), issuer, noripple(lender)); - env.close(); - - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - - BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; - - if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - BEAST_EXPECT(brokerSle)) - { - BEAST_EXPECT(brokerSle->at(sfDebtMaximum) == 0); - } - - using namespace loan; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest{1, 3}; - - auto createJson = env.json(set(lender, broker.brokerID, principalRequest), Fee(loanSetFee)); - - json::Value counterpartyJson{json::ValueType::Object}; - counterpartyJson[sfTxnSignature] = createJson[sfTxnSignature]; - counterpartyJson[sfSigningPubKey] = createJson[sfSigningPubKey]; - if (!BEAST_EXPECT(!createJson.isMember(jss::Signers))) - counterpartyJson[sfSigners] = createJson[sfSigners]; - - createJson = env.json(createJson, Json(sfCounterpartySignature, counterpartyJson)); - env(createJson); - - env.close(); - } - - void - testLoanPayComputePeriodicPaymentValidRateInvariant(FeatureBitset features) - { - // From FIND-012 - testcase << "LoanPay xrpl::detail::computePeriodicPayment : " - "valid rate"; - - using namespace jtx; - using namespace std::chrono_literals; - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - BrokerParameters const brokerParams; - env.fund(XRP(brokerParams.vaultDeposit * 100), issuer, lender, borrower); - env.close(); - - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; - - using namespace loan; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest{640562, -5}; - - Number const serviceFee{2462611968}; - std::uint32_t const numPayments{4294967295 / 800}; - - auto createJson = env.json( - set(borrower, broker.brokerID, principalRequest), - Fee(loanSetFee), - kLoanServiceFee(serviceFee), - kPaymentTotal(numPayments), - Json(sfCounterpartySignature, json::ValueType::Object)); - - createJson["CloseInterestRate"] = 55374; - createJson["ClosePaymentFee"] = "3825205248"; - createJson["LatePaymentFee"] = "237"; - createJson["LoanOriginationFee"] = "0"; - createJson["OverpaymentFee"] = 35167; - createJson["OverpaymentInterestRate"] = 1360; - createJson["PaymentInterval"] = 727; - - auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID)); - auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - auto const keylet = keylet::loan(broker.brokerID, loanSequence); - - createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); - // Fails in preclaim because principal requested can't be - // represented as XRP - env(createJson, Ter(tecPRECISION_LOSS)); - env.close(); - - BEAST_EXPECT(!env.le(keylet)); - - Number const actualPrincipal{6}; - - createJson[sfPrincipalRequested] = actualPrincipal; - createJson.removeMember(sfSequence.jsonName); - createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); - // Fails in doApply because the payment is too small to be - // represented as XRP. - env(createJson, Ter(tecPRECISION_LOSS)); - env.close(); - } - - void - testRPC(FeatureBitset features) - { - // This will expand as more test cases are added. Some functionality - // is tested in other test functions. - testcase("RPC"); - - using namespace jtx; - - Env env(*this, features); - - auto lowerFee = [&]() { - // Run the local fee back down. - while (env.app().getFeeTrack().lowerLocalFee()) - ; - }; - - auto const baseFee = env.current()->fees().base; - - Account const alice{"alice"}; - std::string const borrowerPass = "borrower"; - Account const borrower{borrowerPass, KeyType::Ed25519}; - auto const lenderPass = "lender"; - Account const lender{lenderPass, KeyType::Ed25519}; - - env.fund(XRP(1'000'000), alice, lender, borrower); - env.close(); - env(noop(lender)); - env(noop(lender)); - env(noop(lender)); - env(noop(lender)); - env(noop(lender)); - env.close(); - - { - testcase("RPC AccountSet"); - json::Value txJson{json::ValueType::Object}; - txJson[sfTransactionType] = "AccountSet"; - txJson[sfAccount] = borrower.human(); - - auto const signParams = [&]() { - json::Value signParams{json::ValueType::Object}; - signParams[jss::passphrase] = borrowerPass; - signParams[jss::key_type] = "ed25519"; - signParams[jss::tx_json] = txJson; - return signParams; - }(); - auto const jSign = env.rpc("json", "sign", to_string(signParams)); - BEAST_EXPECT(jSign.isMember(jss::result) && jSign[jss::result].isMember(jss::tx_json)); - auto txSignResult = jSign[jss::result][jss::tx_json]; - auto txSignBlob = jSign[jss::result][jss::tx_blob].asString(); - txSignResult.removeMember(jss::hash); - - auto const jtx = env.jt(txJson, Sig(borrower)); - BEAST_EXPECT(txSignResult == jtx.jv); - - lowerFee(); - auto const jSubmit = env.rpc("submit", txSignBlob); - BEAST_EXPECT( - jSubmit.isMember(jss::result) && - jSubmit[jss::result].isMember(jss::engine_result) && - jSubmit[jss::result][jss::engine_result].asString() == "tesSUCCESS"); - - lowerFee(); - env(jtx.jv, Sig(kNone), Seq(kNone), Fee(kNone), Ter(tefPAST_SEQ)); - } - - { - testcase("RPC LoanSet - illegal signature_target"); - - json::Value txJson{json::ValueType::Object}; - txJson[sfTransactionType] = "AccountSet"; - txJson[sfAccount] = borrower.human(); - - auto const borrowerSignParams = [&]() { - json::Value params{json::ValueType::Object}; - params[jss::passphrase] = borrowerPass; - params[jss::key_type] = "ed25519"; - params[jss::signature_target] = "Destination"; - params[jss::tx_json] = txJson; - return params; - }(); - auto const jSignBorrower = env.rpc("json", "sign", to_string(borrowerSignParams)); - BEAST_EXPECT( - jSignBorrower.isMember(jss::result) && - jSignBorrower[jss::result].isMember(jss::error) && - jSignBorrower[jss::result][jss::error] == "invalidParams" && - jSignBorrower[jss::result].isMember(jss::error_message) && - jSignBorrower[jss::result][jss::error_message] == "Destination"); - } - { - testcase("RPC LoanSet - sign and submit borrower initiated"); - // 1. Borrower creates the transaction - json::Value txJson{json::ValueType::Object}; - txJson[sfTransactionType] = "LoanSet"; - txJson[sfAccount] = borrower.human(); - txJson[sfCounterparty] = lender.human(); - txJson[sfLoanBrokerID] = - "FF924CD18A236C2B49CF8E80A351CEAC6A10171DC9F110025646894FEC" - "F83F" - "5C"; - txJson[sfPrincipalRequested] = "100000000"; - txJson[sfPaymentTotal] = 10000; - txJson[sfPaymentInterval] = 3600; - txJson[sfGracePeriod] = 300; - txJson[sfFlags] = 65536; // tfLoanOverpayment - txJson[sfFee] = to_string(24 * baseFee / 10); - - // 2. Borrower signs the transaction - auto const borrowerSignParams = [&]() { - json::Value params{json::ValueType::Object}; - params[jss::passphrase] = borrowerPass; - params[jss::key_type] = "ed25519"; - params[jss::tx_json] = txJson; - return params; - }(); - auto const jSignBorrower = env.rpc("json", "sign", to_string(borrowerSignParams)); - BEAST_EXPECTS( - jSignBorrower.isMember(jss::result) && - jSignBorrower[jss::result].isMember(jss::tx_json), - to_string(jSignBorrower)); - auto const txBorrowerSignResult = jSignBorrower[jss::result][jss::tx_json]; - auto const txBorrowerSignBlob = jSignBorrower[jss::result][jss::tx_blob].asString(); - - // 2a. Borrower attempts to submit the transaction. It doesn't - // work - { - lowerFee(); - auto const jSubmitBlob = env.rpc("submit", txBorrowerSignBlob); - BEAST_EXPECT(jSubmitBlob.isMember(jss::result)); - auto const jSubmitBlobResult = jSubmitBlob[jss::result]; - BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json)); - // Transaction fails because the CounterpartySignature is - // missing - BEAST_EXPECT( - jSubmitBlobResult.isMember(jss::engine_result) && - jSubmitBlobResult[jss::engine_result].asString() == "temBAD_SIGNER"); - } - - // 3. Borrower sends the signed transaction to the lender - // 4. Lender signs the transaction - auto const lenderSignParams = [&]() { - json::Value params{json::ValueType::Object}; - params[jss::passphrase] = lenderPass; - params[jss::key_type] = "ed25519"; - params[jss::signature_target] = "CounterpartySignature"; - params[jss::tx_json] = txBorrowerSignResult; - return params; - }(); - auto const jSignLender = env.rpc("json", "sign", to_string(lenderSignParams)); - BEAST_EXPECT( - jSignLender.isMember(jss::result) && - jSignLender[jss::result].isMember(jss::tx_json)); - auto const txLenderSignResult = jSignLender[jss::result][jss::tx_json]; - auto const txLenderSignBlob = jSignLender[jss::result][jss::tx_blob].asString(); - - // 5. Lender submits the signed transaction blob - lowerFee(); - auto const jSubmitBlob = env.rpc("submit", txLenderSignBlob); - BEAST_EXPECT(jSubmitBlob.isMember(jss::result)); - auto const jSubmitBlobResult = jSubmitBlob[jss::result]; - BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json)); - auto const jSubmitBlobTx = jSubmitBlobResult[jss::tx_json]; - // To get far enough to return tecNO_ENTRY means that the - // signatures all validated. Of course the transaction won't - // succeed because no Vault or Broker were created. - BEAST_EXPECTS( - jSubmitBlobResult.isMember(jss::engine_result) && - jSubmitBlobResult[jss::engine_result].asString() == "tecNO_ENTRY", - to_string(jSubmitBlobResult)); - - BEAST_EXPECT( - !jSubmitBlob.isMember(jss::error) && !jSubmitBlobResult.isMember(jss::error)); - - // 4-alt. Lender submits the transaction json originally - // received from the Borrower. It gets signed, but is now a - // duplicate, so fails. Borrower could done this instead of - // steps 4 and 5. - lowerFee(); - auto const jSubmitJson = env.rpc("json", "submit", to_string(lenderSignParams)); - BEAST_EXPECT(jSubmitJson.isMember(jss::result)); - auto const jSubmitJsonResult = jSubmitJson[jss::result]; - BEAST_EXPECT(jSubmitJsonResult.isMember(jss::tx_json)); - auto const jSubmitJsonTx = jSubmitJsonResult[jss::tx_json]; - // Since the previous tx claimed a fee, this duplicate is not - // going anywhere - BEAST_EXPECTS( - jSubmitJsonResult.isMember(jss::engine_result) && - jSubmitJsonResult[jss::engine_result].asString() == "tefPAST_SEQ", - to_string(jSubmitJsonResult)); - - BEAST_EXPECT( - !jSubmitJson.isMember(jss::error) && !jSubmitJsonResult.isMember(jss::error)); - - BEAST_EXPECT(jSubmitBlobTx == jSubmitJsonTx); - } - - { - testcase("RPC LoanSet - sign and submit lender initiated"); - // 1. Lender creates the transaction - json::Value txJson{json::ValueType::Object}; - txJson[sfTransactionType] = "LoanSet"; - txJson[sfAccount] = lender.human(); - txJson[sfCounterparty] = borrower.human(); - txJson[sfLoanBrokerID] = - "FF924CD18A236C2B49CF8E80A351CEAC6A10171DC9F110025646894FEC" - "F83F" - "5C"; - txJson[sfPrincipalRequested] = "100000000"; - txJson[sfPaymentTotal] = 10000; - txJson[sfPaymentInterval] = 3600; - txJson[sfGracePeriod] = 300; - txJson[sfFlags] = 65536; // tfLoanOverpayment - txJson[sfFee] = to_string(24 * baseFee / 10); - - // 2. Lender signs the transaction - auto const lenderSignParams = [&]() { - json::Value params{json::ValueType::Object}; - params[jss::passphrase] = lenderPass; - params[jss::key_type] = "ed25519"; - params[jss::tx_json] = txJson; - return params; - }(); - auto const jSignLender = env.rpc("json", "sign", to_string(lenderSignParams)); - BEAST_EXPECT( - jSignLender.isMember(jss::result) && - jSignLender[jss::result].isMember(jss::tx_json)); - auto const txLenderSignResult = jSignLender[jss::result][jss::tx_json]; - auto const txLenderSignBlob = jSignLender[jss::result][jss::tx_blob].asString(); - - // 2a. Lender attempts to submit the transaction. It doesn't - // work - { - lowerFee(); - auto const jSubmitBlob = env.rpc("submit", txLenderSignBlob); - BEAST_EXPECT(jSubmitBlob.isMember(jss::result)); - auto const jSubmitBlobResult = jSubmitBlob[jss::result]; - BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json)); - // Transaction fails because the CounterpartySignature is - // missing - BEAST_EXPECT( - jSubmitBlobResult.isMember(jss::engine_result) && - jSubmitBlobResult[jss::engine_result].asString() == "temBAD_SIGNER"); - } - - // 3. Lender sends the signed transaction to the Borrower - // 4. Borrower signs the transaction - auto const borrowerSignParams = [&]() { - json::Value params{json::ValueType::Object}; - params[jss::passphrase] = borrowerPass; - params[jss::key_type] = "ed25519"; - params[jss::signature_target] = "CounterpartySignature"; - params[jss::tx_json] = txLenderSignResult; - return params; - }(); - auto const jSignBorrower = env.rpc("json", "sign", to_string(borrowerSignParams)); - BEAST_EXPECT( - jSignBorrower.isMember(jss::result) && - jSignBorrower[jss::result].isMember(jss::tx_json)); - auto const txBorrowerSignResult = jSignBorrower[jss::result][jss::tx_json]; - auto const txBorrowerSignBlob = jSignBorrower[jss::result][jss::tx_blob].asString(); - - // 5. Borrower submits the signed transaction blob - lowerFee(); - auto const jSubmitBlob = env.rpc("submit", txBorrowerSignBlob); - BEAST_EXPECT(jSubmitBlob.isMember(jss::result)); - auto const jSubmitBlobResult = jSubmitBlob[jss::result]; - BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json)); - auto const jSubmitBlobTx = jSubmitBlobResult[jss::tx_json]; - // To get far enough to return tecNO_ENTRY means that the - // signatures all validated. Of course the transaction won't - // succeed because no Vault or Broker were created. - BEAST_EXPECTS( - jSubmitBlobResult.isMember(jss::engine_result) && - jSubmitBlobResult[jss::engine_result].asString() == "tecNO_ENTRY", - to_string(jSubmitBlobResult)); - - BEAST_EXPECT( - !jSubmitBlob.isMember(jss::error) && !jSubmitBlobResult.isMember(jss::error)); - - // 4-alt. Borrower submits the transaction json originally - // received from the Lender. It gets signed, but is now a - // duplicate, so fails. Lender could done this instead of steps - // 4 and 5. - lowerFee(); - auto const jSubmitJson = env.rpc("json", "submit", to_string(borrowerSignParams)); - BEAST_EXPECT(jSubmitJson.isMember(jss::result)); - auto const jSubmitJsonResult = jSubmitJson[jss::result]; - BEAST_EXPECT(jSubmitJsonResult.isMember(jss::tx_json)); - auto const jSubmitJsonTx = jSubmitJsonResult[jss::tx_json]; - // Since the previous tx claimed a fee, this duplicate is not - // going anywhere - BEAST_EXPECTS( - jSubmitJsonResult.isMember(jss::engine_result) && - jSubmitJsonResult[jss::engine_result].asString() == "tefPAST_SEQ", - to_string(jSubmitJsonResult)); - - BEAST_EXPECT( - !jSubmitJson.isMember(jss::error) && !jSubmitJsonResult.isMember(jss::error)); - - BEAST_EXPECT(jSubmitBlobTx == jSubmitJsonTx); - } - } - - void - testServiceFeeOnBrokerDeepFreeze() - { - testcase << "Service Fee On Broker Deep Freeze"; - using namespace jtx; - using namespace loan; - Account const issuer("issuer"); - Account const borrower("borrower"); - Account const broker("broker"); - auto const iou = issuer["IOU"]; - - for (bool const deepFreeze : {true, false}) - { - Env env(*this); - - auto getCoverBalance = [&](BrokerInfo const& brokerInfo, auto const& accountField) { - if (auto const le = env.le(keylet::loanBroker(brokerInfo.brokerID)); - BEAST_EXPECT(le)) - { - auto const account = le->at(accountField); - if (auto const sleLine = env.le(keylet::trustLine(account, iou)); - BEAST_EXPECT(sleLine)) - { - STAmount balance = sleLine->at(sfBalance); - if (account > issuer.id()) - balance.negate(); - return balance; - } - } - return STAmount{iou}; - }; - - env.fund(XRP(20'000), issuer, broker, borrower); - env.close(); - - env(trust(broker, iou(20'000'000))); - env(pay(issuer, broker, iou(10'000'000))); - env.close(); - - auto const brokerInfo = createVaultAndBroker(env, iou, broker); - - BEAST_EXPECT(getCoverBalance(brokerInfo, sfAccount) == iou(1'000)); - - auto const keylet = keylet::loan(brokerInfo.brokerID, 1); - - env(set(borrower, brokerInfo.brokerID, 10'000), - Sig(sfCounterpartySignature, broker), - kLoanServiceFee(iou(100).value()), - kPaymentInterval(100), - Fee(XRP(100))); - env.close(); - - env(trust(borrower, iou(20'000'000))); - // The borrower increases their limit and acquires some IOU so - // they can pay interest - env(pay(issuer, borrower, iou(500))); - env.close(); - - if (auto const le = env.le(keylet::loan(keylet.key)); BEAST_EXPECT(le)) - { - if (deepFreeze) - { - env(trust(issuer, broker["IOU"](0), tfSetFreeze | tfSetDeepFreeze)); - env.close(); - } - - env(pay(borrower, keylet.key, iou(10'100)), Fee(XRP(100))); - env.close(); - - if (deepFreeze) - { - // The fee goes to the broker pseudo-account - BEAST_EXPECT(getCoverBalance(brokerInfo, sfAccount) == iou(1'100)); - BEAST_EXPECT(getCoverBalance(brokerInfo, sfOwner) == iou(8'999'000)); - } - else - { - // The fee goes to the broker account - BEAST_EXPECT(getCoverBalance(brokerInfo, sfOwner) == iou(8'999'100)); - BEAST_EXPECT(getCoverBalance(brokerInfo, sfAccount) == iou(1'000)); - } - } - }; - } - - void - testIssuerLoan() - { - testcase << "Issuer Loan"; - - using namespace jtx; - using namespace loan; - Account const issuer("issuer"); - Account const borrower = issuer; - Account const lender("lender"); - Env env(*this); - - env.fund(XRP(1'000), issuer, lender); - - static constexpr std::int64_t kIssuerBalance = 10'000'000; - MPTTester const asset( - {.env = env, .issuer = issuer, .holders = {lender}, .pay = kIssuerBalance}); - - BrokerParameters const brokerParams{ - .debtMax = 200, - }; - auto const broker = createVaultAndBroker(env, asset, lender, brokerParams); - auto const loanSetFee = Fee(env.current()->fees().base * 2); - // Create Loan - env(set(borrower, broker.brokerID, 200), Sig(sfCounterpartySignature, lender), loanSetFee); - env.close(); - // Issuer should not create MPToken - BEAST_EXPECT(!env.le(keylet::mptoken(asset.issuanceID(), issuer))); - // Issuer "borrowed" 200, OutstandingAmount decreased by 200 - BEAST_EXPECT(env.balance(issuer, asset) == asset(-kIssuerBalance + 200)); - // Pay Loan - auto const loanKeylet = keylet::loan(broker.brokerID, 1); - env(pay(borrower, loanKeylet.key, asset(200))); - env.close(); - // Issuer "re-payed" 200, OutstandingAmount increased by 200 - BEAST_EXPECT(env.balance(issuer, asset) == asset(-kIssuerBalance)); - } - - void - testInvalidLoanDelete() - { - testcase("Invalid LoanDelete"); - using namespace jtx; - using namespace loan; - - // preflight: temINVALID, LoanID == zero - { - Account const alice{"alice"}; - Env env(*this); - env.fund(XRP(1'000), alice); - env.close(); - env(del(alice, beast::kZero), Ter(temINVALID)); - } - } - - void - testInvalidLoanManage() - { - testcase("Invalid LoanManage"); - using namespace jtx; - using namespace loan; - - // preflight: temINVALID, LoanID == zero - { - Account const alice{"alice"}; - Env env(*this); - env.fund(XRP(1'000), alice); - env.close(); - env(manage(alice, beast::kZero, tfLoanDefault), Ter(temINVALID)); - } - } - - void - testInvalidLoanPay() - { - testcase("Invalid LoanPay"); - using namespace jtx; - using namespace loan; - Account const lender{"lender"}; - Account const issuer{"issuer"}; - Account const borrower{"borrower"}; - auto const iou = issuer["IOU"]; - - // preclaim - Env env(*this); - env.fund(XRP(1'000), lender, issuer, borrower); - env(trust(lender, iou(10'000'000))); - env(pay(issuer, lender, iou(5'000'000))); - BrokerInfo brokerInfo{createVaultAndBroker(env, issuer["IOU"], lender)}; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - STAmount const debtMaximumRequest = brokerInfo.asset(1'000).value(); - - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - loanSetFee); - - env.close(); - - std::uint32_t const loanSequence = 1; - auto const loanKeylet = keylet::loan(brokerInfo.brokerID, loanSequence); - - env(fset(issuer, asfGlobalFreeze)); - env.close(); - - // preclaim: tecFROZEN - env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecFROZEN)); - env.close(); - - env(fclear(issuer, asfGlobalFreeze)); - env.close(); - - auto const pseudoBroker = [&]() -> std::optional { - if (auto brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); - BEAST_EXPECT(brokerSle)) - { - return Account{"pseudo", brokerSle->at(sfAccount)}; - } - - return std::nullopt; - }(); - if (!pseudoBroker) - return; - - // Lender and pseudoaccount must both be frozen - env(trust(issuer, lender["IOU"](1'000), lender, tfSetFreeze | tfSetDeepFreeze)); - env(trust( - issuer, (*pseudoBroker)["IOU"](1'000), *pseudoBroker, tfSetFreeze | tfSetDeepFreeze)); - env.close(); - - // preclaim: tecFROZEN due to deep frozen - env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecFROZEN)); - env.close(); - - // Only one needs to be unfrozen - env(trust(issuer, lender["IOU"](1'000), tfClearFreeze | tfClearDeepFreeze)); - env.close(); - - // The payment is late by this point - env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecEXPIRED)); - env.close(); - env(pay(borrower, loanKeylet.key, debtMaximumRequest, tfLoanLatePayment)); - env.close(); - - // preclaim: tecKILLED - // note that tecKILLED in loanMakePayment() - // doesn't happen because of the preclaim check. - env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecKILLED)); - } - - void - testInvalidLoanSet() - { - testcase("Invalid LoanSet"); - using namespace jtx; - using namespace loan; - Account const lender{"lender"}; - Account const issuer{"issuer"}; - Account const borrower{"borrower"}; - Account const sponsor{"sponsor"}; - auto const iou = issuer["IOU"]; - - auto testWrapper = [&](auto&& test) { - Env env(*this); - env.fund(XRP(1'000), lender, issuer, borrower, sponsor); - env(trust(lender, iou(10'000'000))); - env(pay(issuer, lender, iou(5'000'000))); - BrokerInfo const brokerInfo{createVaultAndBroker(env, issuer["IOU"], lender)}; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const debtMaximumRequest = brokerInfo.asset(1'000).value(); - test(env, brokerInfo, loanSetFee, debtMaximumRequest); - }; - - // preflight: - testWrapper([&](Env& env, - BrokerInfo const& brokerInfo, - jtx::Fee const& loanSetFee, - Number const& debtMaximumRequest) { - for (auto const sponsorFlags : {spfSponsorReserve, spfSponsorReserve | spfSponsorFee}) - { - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - sponsor::As(sponsor, sponsorFlags), - Sig(sfCounterpartySignature, lender), - loanSetFee, - Ter(temINVALID_FLAG)); - } - - // first temBAD_SIGNER: TODO - // invalid grace period - { - // zero grace period - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - kGracePeriod(0), - loanSetFee, - Ter(temINVALID)); - - // grace period less than default minimum - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - kGracePeriod(LoanSet::kDefaultGracePeriod - 1), - loanSetFee, - Ter(temINVALID)); - - // grace period greater than payment interval - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - kPaymentInterval(120), - kGracePeriod(121), - loanSetFee, - Ter(temINVALID)); - } - // empty/zero broker ID - { - auto jv = set(borrower, uint256{}, debtMaximumRequest); - - auto testZeroBrokerID = [&](std::string const& id, std::uint32_t flags = 0) { - // empty broker ID - jv[sfLoanBrokerID] = id; - env(jv, - Sig(sfCounterpartySignature, lender), - loanSetFee, - Txflags(flags), - Ter(temINVALID)); - }; - // empty broker ID - testZeroBrokerID(std::string("")); - // zero broker ID - // needs a flag to distinguish the parsed STTx from the prior - // test - testZeroBrokerID(to_string(uint256{}), tfFullyCanonicalSig); - } - - // preflightCheckSigningKey() failure: - // can it happen? the signature is checked before transactor - // executes - - JTx const tx = env.jt( - set(borrower, brokerInfo.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - loanSetFee); - STTx local = *(tx.stx); - auto counterpartySig = local.getFieldObject(sfCounterpartySignature); - auto badPubKey = counterpartySig.getFieldVL(sfSigningPubKey); - badPubKey[20] ^= 0xAA; - counterpartySig.setFieldVL(sfSigningPubKey, badPubKey); - local.setFieldObject(sfCounterpartySignature, counterpartySig); - json::Value jvResult; - jvResult[jss::tx_blob] = strHex(local.getSerializer().slice()); - auto res = env.rpc("json", "submit", to_string(jvResult))["result"]; - BEAST_EXPECT( - res[jss::error] == "invalidTransaction" && - res[jss::error_exception] == - "fails local checks: Counterparty: Invalid signature."); - }); - - // preclaim: - testWrapper([&](Env& env, - BrokerInfo const& brokerInfo, - jtx::Fee const& loanSetFee, - Number const& debtMaximumRequest) { - // canAddHoldingFailure (IOU only, if MPT doesn't have - // MPTCanTransfer set, then can't create Vault/LoanBroker, - // and LoanSet will fail with different error - env(fclear(issuer, asfDefaultRipple)); - env.close(); - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - loanSetFee, - Ter(terNO_RIPPLE)); - }); - - // doApply: - testWrapper([&](Env& env, - BrokerInfo const& brokerInfo, - jtx::Fee const& loanSetFee, - Number const& debtMaximumRequest) { - auto const amt = - env.balance(borrower) - accountReserve(*env.current(), borrower.id(), env.journal); - env(pay(borrower, issuer, amt)); - - // tecINSUFFICIENT_RESERVE - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - loanSetFee, - Ter(tecINSUFFICIENT_RESERVE)); - - // addEmptyHolding failure - env(pay(issuer, borrower, amt)); - env(fset(issuer, asfGlobalFreeze)); - env.close(); - - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - loanSetFee, - Ter(tecFROZEN)); - }); - } - - void - testAccountSendMptMinAmountInvariant(FeatureBitset features) - { - // (From FIND-006) - testcase << "LoanSet trigger xrpl::accountSendMPT : minimum amount " - "and MPT"; - - using namespace jtx; - using namespace std::chrono_literals; - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env.close(); - - MPTTester mptt{env, issuer, kMptInitNoFund}; - mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); - PrettyAsset const mptAsset = mptt.issuanceID(); - mptt.authorize({.account = lender}); - mptt.authorize({.account = borrower}); - env(pay(issuer, lender, mptAsset(2'000'000))); - env(pay(issuer, borrower, mptAsset(1'000))); - env.close(); - - BrokerInfo const broker{createVaultAndBroker(env, mptAsset, lender)}; - - using namespace loan; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest{1, 3}; - - auto createJson = env.json( - set(borrower, broker.brokerID, principalRequest), - Fee(loanSetFee), - Json(sfCounterpartySignature, json::ValueType::Object)); - - createJson["CloseInterestRate"] = 76671; - createJson["ClosePaymentFee"] = "2061925410"; - createJson["GracePeriod"] = 434; - createJson["InterestRate"] = 50302; - createJson["LateInterestRate"] = 30322; - createJson["LatePaymentFee"] = "294427911"; - createJson["LoanOriginationFee"] = "3250635102"; - createJson["LoanServiceFee"] = "9557386"; - createJson["OverpaymentFee"] = 51249; - createJson["OverpaymentInterestRate"] = 14304; - createJson["PaymentInterval"] = 434; - createJson["PaymentTotal"] = "2891743748"; - createJson["PrincipalRequested"] = "8516.98"; - - auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID)); - - createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); - env(createJson, Ter(temINVALID)); - env.close(); - } - - void - testLoanPayDebtDecreaseInvariant(FeatureBitset features) - { - // From FIND-007 - testcase << "LoanPay xrpl::LoanPay::doApply : debtDecrease " - "rounding good"; - - using namespace jtx; - using namespace std::chrono_literals; - using namespace lending; - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env.close(); - - PrettyAsset const iouAsset = issuer[iouCurrency_]; - auto trustLenderTx = env.json(trust(lender, iouAsset(1'000'000'000))); - env(trustLenderTx); - auto trustBorrowerTx = env.json(trust(borrower, iouAsset(1'000'000'000))); - env(trustBorrowerTx); - auto payLenderTx = pay(issuer, lender, iouAsset(100'000'000)); - env(payLenderTx); - auto payIssuerTx = pay(issuer, borrower, iouAsset(1'000'000)); - env(payIssuerTx); - env.close(); - - BrokerInfo broker{createVaultAndBroker(env, iouAsset, lender)}; - - using namespace loan; - - auto const baseFee = env.current()->fees().base; - auto const loanSetFee = Fee(baseFee * 2); - Number const principalRequest{1, 3}; - - auto createJson = env.json( - set(borrower, broker.brokerID, principalRequest), - Fee(loanSetFee), - Json(sfCounterpartySignature, json::ValueType::Object)); - - createJson["ClosePaymentFee"] = "0"; - createJson["GracePeriod"] = 60; - createJson["InterestRate"] = 24346; - createJson["LateInterestRate"] = 65535; - createJson["LatePaymentFee"] = "0"; - createJson["LoanOriginationFee"] = "218"; - createJson["LoanServiceFee"] = "0"; - createJson["PaymentInterval"] = 60; - createJson["PaymentTotal"] = 5678; - createJson["PrincipalRequested"] = "9924.81"; - - auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID)); - auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - auto const keylet = keylet::loan(broker.brokerID, loanSequence); - - createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); - env(createJson, Ter(tesSUCCESS)); - env.close(); - - auto const pseudoAcct = [&]() { - auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - if (!BEAST_EXPECT(brokerSle)) - return Account{lender}; - auto const brokerPseudo = brokerSle->at(sfAccount); - return Account("Broker pseudo-account", brokerPseudo); - }(); - - VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, keylet); - auto const originalState = getCurrentState(env, broker, keylet); - verifyLoanStatus(originalState); - - Number const payment{3'269'349'176'470'588, -12}; - XRPAmount const payFee{ - baseFee * - ((payment / originalState.periodicPayment) / kLoanPaymentsPerFeeIncrement + 1)}; - auto loanPayTx = - env.json(pay(borrower, keylet.key, STAmount{broker.asset, payment}), Fee(payFee)); - BEAST_EXPECT(to_string(payment) == "3269.349176470588"); - env(loanPayTx, Ter(tesSUCCESS)); - env.close(); - - auto const newState = getCurrentState(env, broker, keylet); - BEAST_EXPECT( - isRounded(broker.asset, newState.managementFeeOutstanding, originalState.loanScale)); - BEAST_EXPECT(newState.managementFeeOutstanding < originalState.managementFeeOutstanding); - BEAST_EXPECT(isRounded(broker.asset, newState.totalValue, originalState.loanScale)); - BEAST_EXPECT( - isRounded(broker.asset, newState.principalOutstanding, originalState.loanScale)); - } - - void - testLoanPayComputePeriodicPaymentValidTotalInterestInvariant(FeatureBitset features) - { - // From FIND-010 - testcase << "xrpl::loanComputePaymentParts : valid total interest"; - - using namespace jtx; - using namespace std::chrono_literals; - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env.close(); - - PrettyAsset const iouAsset = issuer[iouCurrency_]; - auto trustLenderTx = env.json(trust(lender, iouAsset(1'000'000'000))); - env(trustLenderTx); - auto trustBorrowerTx = env.json(trust(borrower, iouAsset(1'000'000'000))); - env(trustBorrowerTx); - auto payLenderTx = pay(issuer, lender, iouAsset(100'000'000)); - env(payLenderTx); - auto payIssuerTx = pay(issuer, borrower, iouAsset(1'000'000)); - env(payIssuerTx); - env.close(); - - BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)}; - - using namespace loan; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest{1, 3}; - - auto createJson = env.json( - set(borrower, broker.brokerID, principalRequest), - Fee(loanSetFee), - Json(sfCounterpartySignature, json::ValueType::Object)); - - createJson["CloseInterestRate"] = 47299; - createJson["ClosePaymentFee"] = "3985819770"; - createJson["InterestRate"] = 92; - createJson["LatePaymentFee"] = "3866894865"; - createJson["LoanOriginationFee"] = "0"; - createJson["LoanServiceFee"] = "2348810240"; - createJson["OverpaymentFee"] = 58545; - createJson["PaymentInterval"] = 60; - createJson["PaymentTotal"] = 1; - createJson["PrincipalRequested"] = "0.000763058"; - - auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID)); - auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - auto const keylet = keylet::loan(broker.brokerID, loanSequence); - - createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); - env(createJson); - env.close(); - - auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); - loanPayTx["Amount"]["value"] = "0.000281284125490196"; - env(loanPayTx, Ter(tecINSUFFICIENT_PAYMENT)); - env.close(); - } - - void - testDosLoanPay(FeatureBitset features) - { - bool const feeCapped = features[fixCleanup3_1_3]; - - // From FIND-005 - testcase << "DoS LoanPay: fee calculation " << (feeCapped ? "capped" : "uncapped"); - - using namespace jtx; - using namespace std::chrono_literals; - using namespace lending; - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env.close(); - - BEAST_EXPECT(feeCapped == env.current()->rules().enabled(fixCleanup3_1_3)); - - PrettyAsset const iouAsset = issuer[iouCurrency_]; - env(trust(lender, iouAsset(100'000'000))); - env(trust(borrower, iouAsset(100'000'000))); - env(pay(issuer, lender, iouAsset(10'000'000))); - env(pay(issuer, borrower, iouAsset(1'000))); - env.close(); - - BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)}; - - using namespace loan; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest{3959'37, -2}; - auto const baseFee = env.current()->fees().base; - - auto const createJson = env.json( - set(borrower, broker.brokerID, principalRequest), - Fee(loanSetFee), - Json(sfCounterpartySignature, json::ValueType::Object), - kClosePaymentFee(0), - kGracePeriod(60), - kInterestRate(TenthBips32(20930)), - kLateInterestRate(TenthBips32(77049)), - kLatePaymentFee(0), - kLoanServiceFee(0), - kOverpaymentFee(TenthBips32(7)), - kOverpaymentInterestRate(TenthBips32(66653)), - kPaymentInterval(60), - kPaymentTotal(3239184)); - - // There are enough payments due on this loan that it only needs to be - // created once, and can be paid on multiple times. Just don't create a - // gazillion test cases. - auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID)); - auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - auto const keylet = keylet::loan(broker.brokerID, loanSequence); - - env(createJson, Sig(sfCounterpartySignature, lender)); - env.close(); - - auto const roundedPayment = [&]() { - auto const stateBefore = getCurrentState(env, broker, keylet); - BEAST_EXPECT(stateBefore.paymentRemaining == 3239184); - BEAST_EXPECT(stateBefore.paymentRemaining > kLoanMaximumPaymentsPerTransaction); - - return roundToAsset( - iouAsset, - stateBefore.periodicPayment, - stateBefore.loanScale, - Number::RoundingMode::Upward); - }(); - - auto test = [&](int const payFactor, - int const feeFactor, - TER const expectedTer = tesSUCCESS) { - auto const stateBefore = getCurrentState(env, broker, keylet); - BEAST_EXPECT(stateBefore.paymentRemaining <= 3239184); - BEAST_EXPECT(stateBefore.paymentRemaining > kLoanMaximumPaymentsPerTransaction); - - Number const amount = roundedPayment * payFactor; - auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, amount})); - XRPAmount const payFee{baseFee * feeFactor}; - env(loanPayTx, Ter(expectedTer), Fee(payFee)); - env.close(); - auto const expectedChange = isTesSuccess(expectedTer) - ? std::min(kLoanMaximumPaymentsPerTransaction, payFactor) - : 0; - - auto const stateAfter = getCurrentState(env, broker, keylet); - BEAST_EXPECT( - stateAfter.paymentRemaining == stateBefore.paymentRemaining - expectedChange); - }; - - static constexpr std::int64_t kMaxFeeIncrements = - kLoanMaximumPaymentsPerTransaction / kLoanPaymentsPerFeeIncrement; - - TER const failWithoutFix = feeCapped ? (TER)tesSUCCESS : (TER)telINSUF_FEE_P; - - // * Amount well above threshold -> capped fee - // The original test case - way over the limit - more fee is always ok - test(1819878, 363976); - // The capped fee is only sufficient if the amendment is enabled. - test(1819878, kMaxFeeIncrements, failWithoutFix); - - // * Amount exactly at threshold -> capped fee - test(kLoanMaximumPaymentsPerTransaction, kMaxFeeIncrements); - // More fee is always ok - test(kLoanMaximumPaymentsPerTransaction, kMaxFeeIncrements + 10); - - // * Amount below threshold -> normal calculation - test(1, 1); - test(kLoanPaymentsPerFeeIncrement * 2, 2); - test(0, 0, temBAD_AMOUNT); - test(0, 1, temBAD_AMOUNT); - // Fee difference rounds evenly - test( - kLoanMaximumPaymentsPerTransaction - 10, - ((kLoanMaximumPaymentsPerTransaction - 10) / kLoanPaymentsPerFeeIncrement) - 1, - telINSUF_FEE_P); - test( - kLoanMaximumPaymentsPerTransaction - 10, - ((kLoanMaximumPaymentsPerTransaction - 10) / kLoanPaymentsPerFeeIncrement)); - // More fee is always ok - test( - kLoanMaximumPaymentsPerTransaction - 10, - ((kLoanMaximumPaymentsPerTransaction - 10) / kLoanPaymentsPerFeeIncrement) + 3); - // Fee rounds up - for (int under = 1; under < kLoanPaymentsPerFeeIncrement; ++under) - { - test(kLoanMaximumPaymentsPerTransaction - under, kMaxFeeIncrements - 1, telINSUF_FEE_P); - test(kLoanMaximumPaymentsPerTransaction - under, kMaxFeeIncrements); - } - // Only when you get one less fee increment can you pay less - test( - kLoanMaximumPaymentsPerTransaction - kLoanPaymentsPerFeeIncrement, - kMaxFeeIncrements - 1); - // And again, more fee is always ok. - test(kLoanMaximumPaymentsPerTransaction - kLoanPaymentsPerFeeIncrement, kMaxFeeIncrements); - } - - void - testLoanPayComputePeriodicPaymentValidTotalPrincipalPaidInvariant(FeatureBitset features) - { - // From FIND-009 - testcase << "xrpl::loanComputePaymentParts : totalPrincipalPaid " - "rounded"; - - using namespace jtx; - using namespace std::chrono_literals; - using namespace lending; - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env.close(); - - PrettyAsset const iouAsset = issuer[iouCurrency_]; - auto trustLenderTx = env.json(trust(lender, iouAsset(1'000'000'000))); - env(trustLenderTx); - auto trustBorrowerTx = env.json(trust(borrower, iouAsset(1'000'000'000))); - env(trustBorrowerTx); - auto payLenderTx = pay(issuer, lender, iouAsset(100'000'000)); - env(payLenderTx); - auto payIssuerTx = pay(issuer, borrower, iouAsset(1'000'000)); - env(payIssuerTx); - env.close(); - - BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)}; - - using namespace loan; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest{1, 3}; - - auto createJson = env.json( - set(borrower, broker.brokerID, principalRequest), - Fee(loanSetFee), - Json(sfCounterpartySignature, json::ValueType::Object)); - - createJson["ClosePaymentFee"] = "0"; - createJson["InterestRate"] = 24346; - createJson["LateInterestRate"] = 65535; - createJson["LatePaymentFee"] = "0"; - createJson["LoanOriginationFee"] = "218"; - createJson["LoanServiceFee"] = "0"; - createJson["PaymentInterval"] = 60; - createJson["PaymentTotal"] = 5678; - createJson["PrincipalRequested"] = "9924.81"; - - auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID)); - auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - auto const keylet = keylet::loan(broker.brokerID, loanSequence); - - createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); - env(createJson, Ter(tesSUCCESS)); - env.close(); - - auto const baseFee = env.current()->fees().base; - - auto const stateBefore = getCurrentState(env, broker, keylet); - - { - auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); - Number const amount{3074'745'058'823'529, -12}; - BEAST_EXPECT(to_string(amount) == "3074.745058823529"); - XRPAmount const payFee{ - baseFee * - (amount / stateBefore.periodicPayment / kLoanPaymentsPerFeeIncrement + 1)}; - loanPayTx["Amount"]["value"] = to_string(amount); - env(loanPayTx, Fee(payFee), Ter(tesSUCCESS)); - env.close(); - } - - { - auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); - Number const amount{6732'118'170'944'051, -12}; - BEAST_EXPECT(to_string(amount) == "6732.118170944051"); - XRPAmount const payFee{ - baseFee * - (amount / stateBefore.periodicPayment / kLoanPaymentsPerFeeIncrement + 1)}; - loanPayTx["Amount"]["value"] = to_string(amount); - env(loanPayTx, Fee(payFee), Ter(tesSUCCESS)); - env.close(); - } - - auto const stateAfter = getCurrentState(env, broker, keylet); - // Total interest outstanding is non-negative - BEAST_EXPECT(stateAfter.totalValue >= stateAfter.principalOutstanding); - // Principal paid is non-negative - BEAST_EXPECT(stateBefore.principalOutstanding >= stateAfter.principalOutstanding); - // Total value change is non-negative - BEAST_EXPECT(stateBefore.totalValue >= stateAfter.totalValue); - // Value delta is larger or same as principal delta (meaning - // non-negative interest paid) - BEAST_EXPECT( - (stateBefore.totalValue - stateAfter.totalValue) >= - (stateBefore.principalOutstanding - stateAfter.principalOutstanding)); - } - - void - testLoanPayComputePeriodicPaymentValidTotalInterestPaidInvariant(FeatureBitset features) - { - // From FIND-008 - testcase << "xrpl::loanComputePaymentParts : loanValueChange rounded"; - - using namespace jtx; - using namespace std::chrono_literals; - using namespace lending; - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env.close(); - - PrettyAsset const iouAsset = issuer[iouCurrency_]; - auto trustLenderTx = env.json(trust(lender, iouAsset(1'000'000'000))); - env(trustLenderTx); - auto trustBorrowerTx = env.json(trust(borrower, iouAsset(1'000'000'000))); - env(trustBorrowerTx); - auto payLenderTx = pay(issuer, lender, iouAsset(100'000'000)); - env(payLenderTx); - auto payIssuerTx = pay(issuer, borrower, iouAsset(10'000'000)); - env(payIssuerTx); - env.close(); - - BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)}; - { - auto const coverDepositValue = broker.asset(broker.params.coverDeposit * 10).value(); - env(loan_broker::coverDeposit(lender, broker.brokerID, coverDepositValue)); - env.close(); - } - - using namespace loan; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest{1, 3}; - - auto createJson = env.json( - set(borrower, broker.brokerID, principalRequest), - Fee(loanSetFee), - Json(sfCounterpartySignature, json::ValueType::Object)); - - createJson["ClosePaymentFee"] = "0"; - createJson["InterestRate"] = 12833; - createJson["LateInterestRate"] = 77048; - createJson["LatePaymentFee"] = "0"; - createJson["LoanOriginationFee"] = "218"; - createJson["LoanServiceFee"] = "0"; - createJson["PaymentInterval"] = 752; - createJson["PaymentTotal"] = 5678; - createJson["PrincipalRequested"] = "9924.81"; - - auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID)); - auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - auto const keylet = keylet::loan(broker.brokerID, loanSequence); - - createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); - env(createJson, Ter(tesSUCCESS)); - env.close(); - - auto const baseFee = env.current()->fees().base; - - auto const stateBefore = getCurrentState(env, broker, keylet); - BEAST_EXPECT(stateBefore.paymentRemaining == 5678); - BEAST_EXPECT(stateBefore.paymentRemaining > kLoanMaximumPaymentsPerTransaction); - - auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); - Number const amount{9924'81, -2}; - BEAST_EXPECT(to_string(amount) == "9924.81"); - XRPAmount const payFee{ - baseFee * (amount / stateBefore.periodicPayment / kLoanPaymentsPerFeeIncrement + 1)}; - loanPayTx["Amount"]["value"] = to_string(amount); - env(loanPayTx, Fee(payFee), Ter(tesSUCCESS)); - env.close(); - - auto const stateAfter = getCurrentState(env, broker, keylet); - BEAST_EXPECT( - stateAfter.paymentRemaining == - stateBefore.paymentRemaining - kLoanMaximumPaymentsPerTransaction); - } - - void - testLoanNextPaymentDueDateOverflow(FeatureBitset features) - { - // For FIND-013 - testcase << "Prevent nextPaymentDueDate overflow"; - - using namespace jtx; - using namespace std::chrono_literals; - using namespace lending; - Env env{*this, features}; - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env.close(); - - PrettyAsset const iouAsset = issuer[iouCurrency_]; - auto trustLenderTx = env.json(trust(lender, iouAsset(1'000'000'000))); - env(trustLenderTx); - auto trustBorrowerTx = env.json(trust(borrower, iouAsset(1'000'000'000))); - env(trustBorrowerTx); - auto payLenderTx = pay(issuer, lender, iouAsset(100'000'000)); - env(payLenderTx); - auto payIssuerTx = pay(issuer, borrower, iouAsset(10'000'000)); - env(payIssuerTx); - env.close(); - - BrokerParameters const brokerParams{.debtMax = Number{0}, .coverRateMin = TenthBips32{1}}; - BrokerInfo broker{createVaultAndBroker(env, iouAsset, lender, brokerParams)}; - - using namespace loan; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - - using timeType = decltype(sfNextPaymentDueDate)::type::value_type; - static_assert(std::is_same_v); - constexpr timeType kMaxTime = std::numeric_limits::max(); - static_assert(kMaxTime == 4'294'967'295); - - auto const baseJson = [&]() { - auto createJson = env.json( - set(borrower, broker.brokerID, Number{55524'81, -2}), - Fee(loanSetFee), - kClosePaymentFee(0), - kGracePeriod(LoanSet::kDefaultGracePeriod), - kInterestRate(TenthBips32(12833)), - kLateInterestRate(TenthBips32(77048)), - kLatePaymentFee(0), - kLoanOriginationFee(218), - Json(sfCounterpartySignature, json::ValueType::Object)); - - createJson.removeMember(sfSequence.getJsonName()); - - return createJson; - }(); - - auto const baseFee = env.current()->fees().base; - - auto parentCloseTime = [&]() { - return env.current()->parentCloseTime().time_since_epoch().count(); - }; - auto maxLoanTime = [&]() { - auto const startDate = parentCloseTime(); - - BEAST_EXPECT(startDate >= 50); - - return kMaxTime - startDate; - }; - - { - // straight-up overflow: interval - auto const interval = maxLoanTime() + 1; - auto const total = 1; - auto createJson = env.json(baseJson, kPaymentInterval(interval), kPaymentTotal(total)); - - env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); - env.close(); - } - { - // straight-up overflow: total - // min interval is 60 - auto const interval = 60; - auto const total = maxLoanTime() + 1; - auto createJson = env.json(baseJson, kPaymentInterval(interval), kPaymentTotal(total)); - - env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); - env.close(); - } - { - // straight-up overflow: grace period - // min interval is 60 - auto const interval = maxLoanTime() + 1; - auto const total = 1; - auto const grace = interval; - auto createJson = env.json( - baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace)); - - // The grace period can't be larger than the interval. - env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); - env.close(); - } - { - // Overflow with multiplication of a few large intervals - auto const interval = 1'000'000'000; - auto const total = 10; - auto createJson = env.json(baseJson, kPaymentInterval(interval), kPaymentTotal(total)); - - env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); - env.close(); - } - { - // Overflow with multiplication of many small payments - // min interval is 60 - auto const interval = 60; - auto const total = 1'000'000'000; - auto createJson = env.json(baseJson, kPaymentInterval(interval), kPaymentTotal(total)); - - env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); - env.close(); - } - { - // Overflow with an absurdly large grace period - // min interval is 60 - auto const total = 60; - auto const interval = (maxLoanTime() - total) / total; - auto const grace = interval; - auto createJson = env.json( - baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace)); - - env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); - env.close(); - } - { - // Start date when the ledger is closed will be larger - auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID)); - auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - auto const keylet = keylet::loan(broker.brokerID, loanSequence); - - auto const grace = 100; - auto const interval = maxLoanTime() - grace; - auto const total = 1; - auto createJson = env.json( - baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace)); - - env(createJson, Sig(sfCounterpartySignature, lender), Ter(tesSUCCESS)); - env.close(); - - // The transaction is killed in the closed ledger - auto const meta = env.meta(); - if (BEAST_EXPECT(meta)) - { - BEAST_EXPECT(meta->at(sfTransactionResult) == tecKILLED); - } - - // If the transaction had succeeded, the loan would exist - auto const loanSle = env.le(keylet); - // but it doesn't - BEAST_EXPECT(!loanSle); - } - { - // Start date when the ledger is closed will be larger - auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID)); - auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - auto const keylet = keylet::loan(broker.brokerID, loanSequence); - - auto const closeStartDate = ((parentCloseTime() / 10) + 1) * 10; - auto const grace = 5'000; - auto const interval = kMaxTime - closeStartDate - grace; - auto const total = 1; - auto createJson = env.json( - baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace)); - - env(createJson, Sig(sfCounterpartySignature, lender), Ter(tesSUCCESS)); - env.close(); - - // The transaction succeeds in the closed ledger - auto const meta = env.meta(); - if (BEAST_EXPECT(meta)) - { - BEAST_EXPECT(meta->at(sfTransactionResult) == tesSUCCESS); - } - - // This loan exists - auto const afterState = getCurrentState(env, broker, keylet); - BEAST_EXPECT(afterState.nextPaymentDate == kMaxTime - grace); - BEAST_EXPECT(afterState.previousPaymentDate == 0); - BEAST_EXPECT(afterState.paymentRemaining == 1); - } - - { - // Ensure the borrower has funds to pay back the loan - env(pay(issuer, borrower, iouAsset(Number{1'055'524'81, -2}))); - - // Start date when the ledger is closed will be larger - auto const closeStartDate = ((parentCloseTime() / 10) + 1) * 10; - auto const grace = 5'000; - auto const maxLoanTime = kMaxTime - closeStartDate - grace; - auto const total = [&]() { - if (maxLoanTime % 5 == 0) - return 5; - if (maxLoanTime % 3 == 0) - return 3; - if (maxLoanTime % 2 == 0) - return 2; - return 0; - }(); - if (!BEAST_EXPECT(total != 0)) - return; - - auto const brokerState = env.le(keylet::loanBroker(broker.brokerID)); - // Intentionally shadow the outer values - auto const loanSequence = brokerState->at(sfLoanSequence); - auto const keylet = keylet::loan(broker.brokerID, loanSequence); - - auto const interval = maxLoanTime / total; - auto createJson = env.json( - baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace)); - - env(createJson, Sig(sfCounterpartySignature, lender), Ter(tesSUCCESS)); - env.close(); - - // This loan exists - auto const beforeState = getCurrentState(env, broker, keylet); - BEAST_EXPECT(beforeState.nextPaymentDate == closeStartDate + interval); - BEAST_EXPECT(beforeState.previousPaymentDate == 0); - BEAST_EXPECT(beforeState.paymentRemaining == total); - BEAST_EXPECT(beforeState.periodicPayment > 0); - - // pay all but the last payment - { - NumberRoundModeGuard const mg{Number::RoundingMode::Upward}; - Number const payment = beforeState.periodicPayment * (total - 1); - XRPAmount const payFee{baseFee * ((total - 1) / kLoanPaymentsPerFeeIncrement + 1)}; - STAmount const paymentAmount = - roundToScale(STAmount{broker.asset, payment}, beforeState.loanScale); - auto loanPayTx = env.json(pay(borrower, keylet.key, paymentAmount), Fee(payFee)); - env(loanPayTx, Ter(tesSUCCESS)); - env.close(); - } - - // The loan is on the last payment - auto const afterState = getCurrentState(env, broker, keylet); - BEAST_EXPECT(afterState.paymentRemaining == 1); - BEAST_EXPECT(afterState.nextPaymentDate == kMaxTime - grace); - BEAST_EXPECT(afterState.previousPaymentDate == kMaxTime - grace - interval); - } - } - - void - testRequireAuth() - { - testcase("Require Auth - Implicit Pseudo-account authorization"); - using namespace jtx; - using namespace loan; - Account const lender{"lender"}; - Account const issuer{"issuer"}; - Account const borrower{"borrower"}; - Env env(*this); - - env.fund(XRP(100'000), issuer, lender, borrower); - env.close(); - - auto asset = MPTTester({ - .env = env, - .issuer = issuer, - .holders = {lender, borrower}, - .flags = kMptDexFlags | tfMPTRequireAuth | tfMPTCanClawback | tfMPTCanLock, - .authHolder = true, - }); - - env(pay(issuer, lender, asset(5'000'000))); - BrokerInfo brokerInfo{createVaultAndBroker(env, asset, lender)}; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - STAmount const debtMaximumRequest = brokerInfo.asset(1'000).value(); - - auto forUnauthAuth = [&](auto&& doTx) { - for (auto const flag : {tfMPTUnauthorize, 0u}) - { - asset.authorize({.account = issuer, .holder = borrower, .flags = flag}); - env.close(); - doTx(flag == 0); - env.close(); - } - }; - - // Can't create a loan if the borrower is not authorized - forUnauthAuth([&](bool authorized) { - auto const err = !authorized ? Ter(tecNO_AUTH) : Ter(tesSUCCESS); - env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - loanSetFee, - err); - }); - - static constexpr std::uint32_t kLoanSequence = 1; - auto const loanKeylet = keylet::loan(brokerInfo.brokerID, kLoanSequence); - - // Can't loan pay if the borrower is not authorized - forUnauthAuth([&](bool authorized) { - auto const err = !authorized ? Ter(tecNO_AUTH) : Ter(tesSUCCESS); - env(pay(borrower, loanKeylet.key, debtMaximumRequest), err); - }); - } - - void - testLendingCanTradeDisabledNoImpact() - { - testcase("Lending: CanTrade disabled has no impact"); - using namespace jtx; - using namespace loan; - using namespace loan_broker; - - Env env(*this, all_); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env.close(); - - MPTTester mpt( - {.env = env, - .issuer = issuer, - .holders = {lender, borrower}, - .flags = tfMPTCanTransfer | tfMPTCanLock, - .mutableFlags = tmfMPTCanEnableCanTrade}); - PrettyAsset const asset = mpt.issuanceID(); - env(pay(issuer, lender, asset(10'000'000))); - env(pay(issuer, borrower, asset(100'000))); - env.close(); - - auto const broker = createVaultAndBroker(env, asset, lender); - - // CanTrade is not set - env(offer(lender, XRP(1), asset(10)), Ter{tecNO_PERMISSION}); - env.close(); - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - - // New cover deposits still work. - env(coverDeposit(lender, broker.brokerID, asset(100))); - env.close(); - - // New loan issuance still works. - env(loan::set(borrower, broker.brokerID, 1'000), - Sig(sfCounterpartySignature, lender), - loanSetFee); - env.close(); - auto const loanKeylet = keylet::loan(broker.brokerID, 1); - BEAST_EXPECT(env.le(loanKeylet)); - - // Repayment still works. - env(pay(borrower, loanKeylet.key, asset(1'000))); - env.close(); - - // Cover withdrawal still works. - env(coverWithdraw(lender, broker.brokerID, asset(100))); - env.close(); - - // Enable CanTrade and verify the DEX path is restored. - mpt.set({.mutableFlags = tmfMPTSetCanTrade}); - env.close(); - - env(offer(lender, XRP(1), asset(10))); - env.close(); - } - -#if LOAN_TODO - void - testLoanPayLateFullPaymentBypassesPenalties(FeatureBitset features) - { - testcase("LoanPay full payment skips late penalties"); - using namespace jtx; - using namespace loan; - using namespace std::chrono_literals; - - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env.close(); - - PrettyAsset const asset = issuer[iouCurrency]; - env(trust(lender, asset(100'000'000))); - env(trust(borrower, asset(100'000'000))); - env(pay(issuer, lender, asset(50'000'000))); - env(pay(issuer, borrower, asset(5'000'000))); - env.close(); - - BrokerInfo broker{createVaultAndBroker(env, asset, lender)}; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - - auto const brokerPreLoan = env.le(keylet::loanBroker(broker.brokerID)); - if (BEAST_EXPECT(brokerPreLoan); !brokerPreLoan.has_value()) - return; - - auto const loanSequence = brokerPreLoan->at(sfLoanSequence); - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); - - Number const principal = asset(1'000).value(); - Number const serviceFee = asset(2).value(); - Number const lateFee = asset(5).value(); - Number const closeFee = asset(4).value(); - - env(set(borrower, broker.brokerID, principal), - Sig(sfCounterpartySignature, lender), - kLoanServiceFee(serviceFee), - kLatePaymentFee(lateFee), - kClosePaymentFee(closeFee), - kInterestRate(percentageToTenthBips(12)), - kLateInterestRate(percentageToTenthBips(24) / 10), - kCloseInterestRate(percentageToTenthBips(5)), - kPaymentTotal(12), - kPaymentInterval(600), - kGracePeriod(0), - Fee(loanSetFee)); - env.close(); - - auto state1 = getCurrentState(env, broker, loanKeylet); - if (!BEAST_EXPECT(state1.paymentRemaining > 1)) - return; - - using d = NetClock::duration; - using tp = NetClock::time_point; - auto const overdueClose = tp{d{state1.nextPaymentDate + state1.paymentInterval}}; - env.close(overdueClose); - - auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - auto const loanSle = env.le(loanKeylet); - if (!BEAST_EXPECT(brokerSle && loanSle)) - return; - - auto state = getCurrentState(env, broker, loanKeylet); - - TenthBips16 const managementFeeRate{brokerSle->at(sfManagementFeeRate)}; - TenthBips32 const interestRateValue{loanSle->at(sfInterestRate)}; - TenthBips32 const lateInterestRateValue{loanSle->at(sfLateInterestRate)}; - TenthBips32 const closeInterestRateValue{loanSle->at(sfCloseInterestRate)}; - - Number const closePaymentFeeRounded = - roundToAsset(broker.asset, loanSle->at(sfClosePaymentFee), state.loanScale); - Number const latePaymentFeeRounded = - roundToAsset(broker.asset, loanSle->at(sfLatePaymentFee), state.loanScale); - - auto const roundedLoanState = constructLoanState( - state.totalValue, state.principalOutstanding, state.managementFeeOutstanding); - Number const totalInterestOutstanding = roundedLoanState.interestDue; - - auto const periodicRate = loanPeriodicRate(interestRateValue, state.paymentInterval); - auto const rawLoanState = computeTheoreticalLoanState( - env.current()->rules(), - state.periodicPayment, - periodicRate, - state.paymentRemaining, - managementFeeRate); - - auto const parentCloseTime = env.current()->parentCloseTime(); - auto const startDateSeconds = - static_cast(state.startDate.time_since_epoch().count()); - - Number const fullPaymentInterest = computeFullPaymentInterest( - rawLoanState.principalOutstanding, - periodicRate, - parentCloseTime, - state.paymentInterval, - state.previousPaymentDate, - startDateSeconds, - closeInterestRateValue); - - Number const roundedFullInterestAmount = - roundToAsset(broker.asset, fullPaymentInterest, state.loanScale); - Number const roundedFullManagementFee = computeManagementFee( - broker.asset, roundedFullInterestAmount, managementFeeRate, state.loanScale); - Number const roundedFullInterest = roundedFullInterestAmount - roundedFullManagementFee; - - Number const trackedValueDelta = - state.principalOutstanding + totalInterestOutstanding + state.managementFeeOutstanding; - Number const untrackedManagementFee = - closePaymentFeeRounded + roundedFullManagementFee - state.managementFeeOutstanding; - Number const untrackedInterest = roundedFullInterest - totalInterestOutstanding; - - Number const baseFullDue = trackedValueDelta + untrackedInterest + untrackedManagementFee; - BEAST_EXPECT(baseFullDue == roundToAsset(broker.asset, baseFullDue, state.loanScale)); - - auto const overdueSeconds = - parentCloseTime.time_since_epoch().count() - state.nextPaymentDate; - if (!BEAST_EXPECT(overdueSeconds > 0)) - return; - - Number const overdueRate = loanPeriodicRate(lateInterestRateValue, overdueSeconds); - Number const lateInterestRaw = state.principalOutstanding * overdueRate; - Number const lateInterestRounded = - roundToAsset(broker.asset, lateInterestRaw, state.loanScale); - Number const lateManagementFeeRounded = computeManagementFee( - broker.asset, lateInterestRounded, managementFeeRate, state.loanScale); - Number const penaltyDue = - lateInterestRounded + lateManagementFeeRounded + latePaymentFeeRounded; - BEAST_EXPECT(penaltyDue > Number{}); - - auto const balanceBefore = env.balance(borrower, broker.asset).number(); - - STAmount const paymentAmount{broker.asset.raw(), baseFullDue}; - env(pay(borrower, loanKeylet.key, paymentAmount, tfLoanFullPayment)); - env.close(); - - if (auto const meta = env.meta(); BEAST_EXPECT(meta)) - BEAST_EXPECT(meta->at(sfTransactionResult) == tesSUCCESS); - - auto const balanceAfter = env.balance(borrower, broker.asset).number(); - Number const actualPaid = balanceBefore - balanceAfter; - BEAST_EXPECT(actualPaid == baseFullDue); - - Number const expectedWithPenalty = baseFullDue + penaltyDue; - BEAST_EXPECT(expectedWithPenalty > actualPaid); - BEAST_EXPECT(expectedWithPenalty - actualPaid == penaltyDue); - } - - void - testLoanCoverMinimumRoundingExploit(FeatureBitset features) - { - auto testLoanCoverMinimumRoundingExploit = [&, this](Number const& principalRequest) { - testcase << "LoanBrokerCoverClawback drains cover via rounding" - << " principalRequested=" << to_string(principalRequest); - - using namespace jtx; - using namespace loan; - using namespace loan_broker; - - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000'000), issuer, lender, borrower); - env.close(); - - env(fset(issuer, asfAllowTrustLineClawback)); - env.close(); - - PrettyAsset const asset = issuer[iouCurrency]; - env(trust(lender, asset(2'000'0000))); - env(trust(borrower, asset(2'000'0000))); - env.close(); - - env(pay(issuer, lender, asset(2'000'0000))); - env.close(); - - BrokerParameters brokerParams{.debtMax = 0, .coverRateMin = TenthBips32{10'000}}; - BrokerInfo broker{createVaultAndBroker(env, asset, lender, brokerParams)}; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - auto createTx = env.jt( - set(borrower, broker.brokerID, principalRequest), - Sig(sfCounterpartySignature, lender), - loanSetFee, - kPaymentInterval(600), - kPaymentTotal(1), - kGracePeriod(60)); - env(createTx); - env.close(); - - auto const brokerBefore = env.le(keylet::loanBroker(broker.brokerID)); - BEAST_EXPECT(brokerBefore); - if (!brokerBefore) - return; - - Number const debtOutstanding = brokerBefore->at(sfDebtTotal); - Number const coverAvailableBefore = brokerBefore->at(sfCoverAvailable); - - BEAST_EXPECT(debtOutstanding > Number{}); - BEAST_EXPECT(coverAvailableBefore > Number{}); - - log << "debt=" << to_string(debtOutstanding) - << " cover_available=" << to_string(coverAvailableBefore); - - env(coverClawback(issuer, 0), loanBrokerID(broker.brokerID)); - env.close(); - - auto const brokerAfter = env.le(keylet::loanBroker(broker.brokerID)); - BEAST_EXPECT(brokerAfter); - if (!brokerAfter) - return; - - Number const debtAfter = brokerAfter->at(sfDebtTotal); - // the debt has not changed - BEAST_EXPECT(debtAfter == debtOutstanding); - - Number const coverAvailableAfter = brokerAfter->at(sfCoverAvailable); - - // since the cover rate min != 0, the cover available should not - // be zero - BEAST_EXPECT(coverAvailableAfter != Number{}); - }; - - // Call the lambda with different principal values - testLoanCoverMinimumRoundingExploit(Number{1, -30}); // 1e-30 units - testLoanCoverMinimumRoundingExploit(Number{1, -20}); // 1e-20 units - testLoanCoverMinimumRoundingExploit(Number{1, -10}); // 1e-10 units - testLoanCoverMinimumRoundingExploit(Number{1, 1}); // 1e-10 units - } -#endif - - void - testPoCUnsignedUnderflowOnFullPayAfterEarlyPeriodic(FeatureBitset features) - { - // --- PoC Summary ---------------------------------------------------- - // Scenario: Borrower makes one periodic payment early (before next due) - // so doPayment sets sfPreviousPaymentDueDate to the (future) - // sfNextPaymentDueDate and advances sfNextPaymentDueDate by one - // interval. Borrower then immediately performs a full-payment - // (tfLoanFullPayment). Why it matters: Full-payment interest accrual - // uses - // delta = now - max(prevPaymentDate, startDate) - // with an unsigned clock representation (uint32). If prevPaymentDate is - // in the future, the subtraction underflows to a very large positive - // number. This inflates roundedFullInterest and total full-close due, - // and LoanPay applies the inflated valueChange to the vault - // (sfAssetsTotal), increasing NAV. - // -------------------------------------------------------------------- - testcase("PoC: Unsigned-underflow full-pay accrual after early periodic"); - - using namespace jtx; - using namespace loan; - using namespace std::chrono_literals; - - Env env{*this, features}; - - Account const lender{"poc_lender4"}; - Account const borrower{"poc_borrower4"}; - env.fund(XRP(3'000'000), lender, borrower); - env.close(); - - PrettyAsset const asset{xrpIssue(), 1'000'000}; - BrokerParameters const brokerParams{}; - auto const broker = createVaultAndBroker(env, asset, lender, brokerParams); - - // Create a 3-payment loan so full-payment path is enabled after 1 - // periodic payment. - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest = asset(1000).value(); - auto const originationFee = asset(0).value(); - auto const serviceFee = asset(1).value(); - auto const serviceFeePA = asset(1); - auto const lateFee = asset(0).value(); - auto const closeFee = asset(0).value(); - auto const interest = percentageToTenthBips(12); - auto const lateInterest = percentageToTenthBips(12) / 10; - auto const closeInterest = percentageToTenthBips(12) / 10; - auto const overpaymentInterest = percentageToTenthBips(12) / 10; - auto const total = 3u; - auto const interval = 600u; - auto const grace = 60u; - - auto createJtx = env.jt( - set(borrower, broker.brokerID, principalRequest, 0), - Sig(sfCounterpartySignature, lender), - kLoanOriginationFee(originationFee), - kLoanServiceFee(serviceFee), - kLatePaymentFee(lateFee), - kClosePaymentFee(closeFee), - kOverpaymentFee(percentageToTenthBips(5) / 10), - kInterestRate(interest), - kLateInterestRate(lateInterest), - kCloseInterestRate(closeInterest), - kOverpaymentInterestRate(overpaymentInterest), - kPaymentTotal(total), - kPaymentInterval(interval), - kGracePeriod(grace), - Fee(loanSetFee)); - - auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - BEAST_EXPECT(brokerSle); - auto const loanSequence = brokerSle ? brokerSle->at(sfLoanSequence) : 0; - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); - - env(createJtx); - env.close(); - - // Compute a regular periodic due and pay it early (before next due). - auto state = getCurrentState(env, broker, loanKeylet); - Number const periodicRate = loanPeriodicRate(state.interestRate, state.paymentInterval); - auto const components = xrpl::detail::computePaymentComponents( - env.current()->rules(), - asset.raw(), - state.loanScale, - state.totalValue, - state.principalOutstanding, - state.managementFeeOutstanding, - state.periodicPayment, - periodicRate, - state.paymentRemaining, - brokerParams.managementFeeRate); - STAmount const regularDue{asset, components.trackedValueDelta + serviceFeePA.number()}; - // now < nextDue immediately after creation, so this is an early pay. - env(pay(borrower, loanKeylet.key, regularDue)); - env.close(); - - // Immediately attempt a full payoff. Compute the exact full-payment - // due to ensure the tx applies. - auto after = getCurrentState(env, broker, loanKeylet); - auto const loanSle = env.le(loanKeylet); - BEAST_EXPECT(loanSle); - auto const brokerSle2 = env.le(keylet::loanBroker(broker.brokerID)); - BEAST_EXPECT(brokerSle2); - - auto const closePaymentFee = loanSle ? loanSle->at(sfClosePaymentFee) : Number{}; - auto const closeInterestRate = - loanSle ? TenthBips32{loanSle->at(sfCloseInterestRate)} : TenthBips32{}; - auto const managementFeeRate = - brokerSle2 ? TenthBips16{brokerSle2->at(sfManagementFeeRate)} : TenthBips16{}; - - Number const periodicRate2 = loanPeriodicRate(after.interestRate, after.paymentInterval); - // Accrued + prepayment-penalty interest based on current periodic - // schedule - auto const fullPaymentInterest = computeFullPaymentInterest( - xrpl::detail::loanPrincipalFromPeriodicPayment( - env.current()->rules(), - after.periodicPayment, - periodicRate2, - after.paymentRemaining), - periodicRate2, - env.current()->parentCloseTime(), - after.paymentInterval, - after.previousPaymentDate, - static_cast(after.startDate.time_since_epoch().count()), - closeInterestRate); - - // Round to asset scale and split interest/fee parts - auto const roundedInterest = - roundToAsset(asset.raw(), fullPaymentInterest, after.loanScale); - Number const roundedFullMgmtFee = - computeManagementFee(asset.raw(), roundedInterest, managementFeeRate, after.loanScale); - Number const roundedFullInterest = roundedInterest - roundedFullMgmtFee; - - // Show both signed and unsigned deltas to highlight the underflow. - auto const nowSecs = - static_cast(env.current()->parentCloseTime().time_since_epoch().count()); - auto const startSecs = - static_cast(after.startDate.time_since_epoch().count()); - auto const lastPaymentDate = std::max(after.previousPaymentDate, startSecs); - auto const signedDelta = - static_cast(nowSecs) - static_cast(lastPaymentDate); - auto const unsignedDelta = static_cast(nowSecs - lastPaymentDate); - log << "PoC window: prev=" << after.previousPaymentDate << " start=" << startSecs - << " now=" << nowSecs << " signedDelta=" << signedDelta - << " unsignedDelta=" << unsignedDelta << std::endl; - - // Reference (clamped) computation: emulate a non-negative accrual - // window by clamping prevPaymentDate to 'now' for the full-pay path. - auto const prevClamped = std::min(after.previousPaymentDate, nowSecs); - auto const fullPaymentInterestClamped = computeFullPaymentInterest( - xrpl::detail::loanPrincipalFromPeriodicPayment( - env.current()->rules(), - after.periodicPayment, - periodicRate2, - after.paymentRemaining), - periodicRate2, - env.current()->parentCloseTime(), - after.paymentInterval, - prevClamped, - startSecs, - closeInterestRate); - auto const roundedInterestClamped = - roundToAsset(asset.raw(), fullPaymentInterestClamped, after.loanScale); - Number const roundedFullMgmtFeeClamped = computeManagementFee( - asset.raw(), roundedInterestClamped, managementFeeRate, after.loanScale); - Number const roundedFullInterestClamped = - roundedInterestClamped - roundedFullMgmtFeeClamped; - STAmount const fullDueClamped{ - asset, - after.principalOutstanding + roundedFullInterestClamped + roundedFullMgmtFeeClamped + - closePaymentFee}; - - // Collect vault NAV before closing payment - auto const vaultId2 = brokerSle2 ? brokerSle2->at(sfVaultID) : uint256{}; - auto const vaultKey2 = keylet::vault(vaultId2); - auto const vaultBefore = env.le(vaultKey2); - BEAST_EXPECT(vaultBefore); - Number const assetsTotalBefore = vaultBefore ? vaultBefore->at(sfAssetsTotal) : Number{}; - - STAmount const fullDue{ - asset, - after.principalOutstanding + roundedFullInterest + roundedFullMgmtFee + - closePaymentFee}; - - log << "PoC payoff: principalOutstanding=" << after.principalOutstanding - << " roundedFullInterest=" << roundedFullInterest - << " roundedFullMgmtFee=" << roundedFullMgmtFee << " closeFee=" << closePaymentFee - << " fullDue=" << to_string(fullDue.getJson()) << std::endl; - log << "PoC reference (clamped): roundedFullInterestClamped=" << roundedFullInterestClamped - << " roundedFullMgmtFeeClamped=" << roundedFullMgmtFeeClamped - << " fullDueClamped=" << to_string(fullDueClamped.getJson()) << std::endl; - - env(pay(borrower, loanKeylet.key, fullDue), Txflags(tfLoanFullPayment)); - env.close(); - - // Sanity: underflow present (unsigned delta very large relative to - // interval) - BEAST_EXPECT(unsignedDelta > after.paymentInterval); - - // Compare vault NAV before/after the full close - auto const vaultAfter = env.le(vaultKey2); - BEAST_EXPECT(vaultAfter); - if (vaultAfter) - { - auto const assetsTotalAfter = vaultAfter->at(sfAssetsTotal); - log << "PoC NAV: assetsTotalBefore=" << assetsTotalBefore - << " assetsTotalAfter=" << assetsTotalAfter - << " delta=" << (assetsTotalAfter - assetsTotalBefore) << std::endl; - - // Value-based proof: underflowed window yields a payoff larger than - // the clamped (non-underflow) reference. - BEAST_EXPECT(fullDue == fullDueClamped); - if (fullDue > fullDueClamped) - log << "PoC delta: overcharge (fullDue > clamped)" << std::endl; - } - - // Loan should be paid off - auto const finalLoan = env.le(loanKeylet); - BEAST_EXPECT(finalLoan); - if (finalLoan) - { - BEAST_EXPECT(finalLoan->at(sfPaymentRemaining) == 0); - BEAST_EXPECT(finalLoan->at(sfPrincipalOutstanding) == 0); - } - } - - void - testDustManipulation(FeatureBitset features) - { - testcase("Dust manipulation"); - - using namespace jtx; - using namespace std::chrono_literals; - Env env{*this, features}; - - // Setup: Create accounts - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - Account const victim{"victim"}; - - env.fund(XRP(1'000'000'00), issuer, lender, borrower, victim); - env.close(); - - // Step 1: Create vault with IOU asset - auto asset = issuer["USD"]; - env(trust(lender, asset(100000))); - env(trust(borrower, asset(100000))); - env(trust(victim, asset(100000))); - env(pay(issuer, lender, asset(50000))); - env(pay(issuer, borrower, asset(50000))); - env(pay(issuer, victim, asset(50000))); - env.close(); - - BrokerParameters const brokerParams{ - .vaultDeposit = 10000, - .debtMax = Number{0}, - .coverRateMin = TenthBips32{1000}, - .coverRateLiquidation = TenthBips32{2500}}; - - auto broker = createVaultAndBroker(env, asset, lender, brokerParams); - - auto const loanKeyletOpt = [&]() -> std::optional { - auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); - if (!BEAST_EXPECT(brokerSle)) - return std::nullopt; - - // Broker has no loans - BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); - - // The loan keylet is based on the LoanSequence of the - // _LOAN_BROKER_ object. - auto const loanSequence = brokerSle->at(sfLoanSequence); - return keylet::loan(broker.brokerID, loanSequence); - }(); - if (!loanKeyletOpt) - return; - - auto const& vaultKeylet = broker.vaultKeylet(); - - { - auto const vaultSle = env.le(vaultKeylet); - Number const assetsTotal = vaultSle->at(sfAssetsTotal); - Number const assetsAvail = vaultSle->at(sfAssetsAvailable); - - log << "Before loan creation:" << std::endl; - log << " AssetsTotal: " << assetsTotal << std::endl; - log << " AssetsAvailable: " << assetsAvail << std::endl; - log << " Difference: " << (assetsTotal - assetsAvail) << std::endl; - - // before the loan the assets total and available should be equal - BEAST_EXPECT(assetsAvail == assetsTotal); - BEAST_EXPECT(assetsAvail == broker.asset(brokerParams.vaultDeposit).number()); - } - - Keylet const& loanKeylet = *loanKeyletOpt; - - LoanParameters const loanParams{ - .account = lender, - .counter = borrower, - .principalRequest = Number{100}, - .interest = TenthBips32{1922}, - .payTotal = 5816, - .payInterval = 86400 * 6, - .gracePd = 86400 * 5, - }; - - env(loanParams(env, broker)); - env.close(); - - // Wait for loan to be late enough to default - env.close(std::chrono::seconds(86400 * 40)); // 40 days - - { - auto const vaultSle = env.le(vaultKeylet); - Number const assetsTotal = vaultSle->at(sfAssetsTotal); - Number const assetsAvail = vaultSle->at(sfAssetsAvailable); - - log << "After loan creation:" << std::endl; - log << " AssetsTotal: " << assetsTotal << std::endl; - log << " AssetsAvailable: " << assetsAvail << std::endl; - log << " Difference: " << (assetsTotal - assetsAvail) << std::endl; - - auto const loanSle = env.le(loanKeylet); - if (!BEAST_EXPECT(loanSle)) - return; - auto const state = constructLoanState(loanSle); - - log << "Loan state:" << std::endl; - log << " ValueOutstanding: " << state.valueOutstanding << std::endl; - log << " PrincipalOutstanding: " << state.principalOutstanding << std::endl; - log << " InterestOutstanding: " << state.interestOutstanding() << std::endl; - log << " InterestDue: " << state.interestDue << std::endl; - log << " FeeDue: " << state.managementFeeDue << std::endl; - - // after loan creation the assets total and available should - // reflect the value of the loan - BEAST_EXPECT(assetsAvail < assetsTotal); - BEAST_EXPECT( - assetsAvail == - broker.asset(brokerParams.vaultDeposit - loanParams.principalRequest).number()); - BEAST_EXPECT( - assetsTotal == - broker.asset(brokerParams.vaultDeposit + state.interestDue).number()); - } - - // Step 7: Trigger default (dust adjustment will occur) - env(jtx::loan::manage(lender, loanKeylet.key, tfLoanDefault)); - env.close(); - - // Step 8: Verify phantom assets created - { - auto const vaultSle2 = env.le(vaultKeylet); - Number const assetsTotal2 = vaultSle2->at(sfAssetsTotal); - Number const assetsAvail2 = vaultSle2->at(sfAssetsAvailable); - - log << "After default:" << std::endl; - log << " AssetsTotal: " << assetsTotal2 << std::endl; - log << " AssetsAvailable: " << assetsAvail2 << std::endl; - log << " Difference: " << (assetsTotal2 - assetsAvail2) << std::endl; - - // after a default the assets total and available should be equal - BEAST_EXPECT(assetsAvail2 == assetsTotal2); - } - } - - void - testRIPD3831(FeatureBitset features) - { - using namespace jtx; - - testcase("RIPD-3831"); - - Account const issuer("issuer"); - Account const lender("lender"); - Account const borrower("borrower"); - - BrokerParameters const brokerParams{ - .vaultDeposit = 100000, - .debtMax = 0, - .coverRateMin = TenthBips32{0}, - // .managementFeeRate = TenthBips16{5919}, - .coverRateLiquidation = TenthBips32{0}}; - LoanParameters const loanParams{ - .account = lender, - .counter = borrower, - .principalRequest = Number{200'000, -6}, - .lateFee = Number{200, -6}, - .interest = TenthBips32{50'000}, - .payTotal = 10, - .payInterval = 150}; - - auto const assetType = AssetType::XRP; - - Env env{*this, features}; - - auto loanResult = - createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); - - if (BEAST_EXPECT(loanResult); !loanResult.has_value()) - return; - - auto broker = std::get(*loanResult); - auto loanKeylet = std::get(*loanResult); - - using tp = NetClock::time_point; - using d = NetClock::duration; - - auto state = getCurrentState(env, broker, loanKeylet); - if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan)) - { - env.close(tp{d{loan->at(sfNextPaymentDueDate) + loan->at(sfGracePeriod) + 1}}); - } - - topUpBorrower(env, broker, issuer, borrower, state, loanParams.serviceFee); - - using namespace jtx::loan; - - auto jv = pay(borrower, loanKeylet.key, drops(XRPAmount(state.totalValue))); - - { - auto const submitParam = to_string(jv); - auto const jr = env.rpc("submit", borrower.name(), submitParam); - - BEAST_EXPECT(jr.isMember(jss::result)); - auto const jResult = jr[jss::result]; - } - - env.close(); - - // Make sure the system keeps responding - env(noop(borrower)); - env.close(); - env(noop(issuer)); - env.close(); - env(noop(lender)); - env.close(); - } - - void - testRIPD3459(FeatureBitset features) - { - testcase("RIPD-3459 - LoanBroker incorrect debt total"); - - using namespace jtx; - - Account const issuer("issuer"); - Account const lender("lender"); - Account const borrower("borrower"); - - BrokerParameters const brokerParams{ - .vaultDeposit = 200'000, - .debtMax = 0, - .coverRateMin = TenthBips32{0}, - .managementFeeRate = TenthBips16{500}, - .coverRateLiquidation = TenthBips32{0}}; - LoanParameters const loanParams{ - .account = lender, - .counter = borrower, - .principalRequest = Number{100'000, -4}, - .interest = TenthBips32{100'000}, - .payTotal = 10}; - - auto const assetType = AssetType::MPT; - - Env env{*this, features}; - - auto loanResult = - createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); - - if (BEAST_EXPECT(loanResult); !loanResult.has_value()) - return; - - auto broker = std::get(*loanResult); - auto loanKeylet = std::get(*loanResult); - auto pseudoAcct = std::get(*loanResult); - - VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); - - if (auto const brokerSle = env.le(broker.brokerKeylet()); BEAST_EXPECT(brokerSle)) - { - if (auto const loanSle = env.le(loanKeylet); BEAST_EXPECT(loanSle)) - { - BEAST_EXPECT(brokerSle->at(sfDebtTotal) == loanSle->at(sfTotalValueOutstanding)); - } - } - - makeLoanPayments( - env, - broker, - loanParams, - loanKeylet, - verifyLoanStatus, - issuer, - lender, - borrower, - PaymentParameters{.showStepBalances = true}); - - if (auto const brokerSle = env.le(broker.brokerKeylet()); BEAST_EXPECT(brokerSle)) - { - if (auto const loanSle = env.le(loanKeylet); BEAST_EXPECT(loanSle)) - { - BEAST_EXPECT(brokerSle->at(sfDebtTotal) == loanSle->at(sfTotalValueOutstanding)); - BEAST_EXPECT(brokerSle->at(sfDebtTotal) == beast::kZero); - } - } - } - - void - testRIPD3901() - { - testcase("Crash with tfLoanOverpayment"); - using namespace jtx; - using namespace loan; - Account const lender{"lender"}; - Account const issuer{"issuer"}; - Account const borrower{"borrower"}; - Account const depositor{"depositor"}; - auto const txFee = Fee(XRP(100)); - - Env env(*this); - Vault const vault(env); - - env.fund(XRP(10'000), lender, issuer, borrower, depositor); - env.close(); - - auto [tx, vaultKeyLet] = vault.create({.owner = lender, .asset = xrpIssue()}); - env(tx, txFee); - env.close(); - - env(vault.deposit({.depositor = depositor, .id = vaultKeyLet.key, .amount = XRP(1'000)}), - txFee); - env.close(); - - auto const brokerKeyLet = keylet::loanBroker(lender.id(), env.seq(lender)); - - env(loan_broker::set(lender, vaultKeyLet.key), txFee); - env.close(); - - // BrokerInfo brokerInfo{xrpIssue(), keylet, vaultKeyLet, {}}; - - STAmount const debtMaximumRequest = XRPAmount(200'000); - - env(set(borrower, brokerKeyLet.key, debtMaximumRequest), - Sig(sfCounterpartySignature, lender), - kInterestRate(TenthBips32(50'000)), - kPaymentTotal(2), - kPaymentInterval(150), - Txflags(tfLoanOverpayment), - txFee); - env.close(); - - std::uint32_t const loanSequence = 1; - auto const loanKeylet = keylet::loan(brokerKeyLet.key, loanSequence); - - if (auto loan = env.le(loanKeylet); env.test.BEAST_EXPECT(loan)) - { - env(loan::pay(borrower, loanKeylet.key, XRPAmount(150'001)), - Txflags(tfLoanOverpayment), - txFee); - env.close(); - } - } - - void - testRoundingAllowsUndercoverage(FeatureBitset features) - { - testcase("Minimum cover rounding allows undercoverage (XRP)"); - - using namespace jtx; - using namespace loan_broker; - - Env env{*this, features}; - - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(200'000), lender, borrower); - env.close(); - - // Vault with XRP asset - Vault const vault{env}; - auto [vaultCreate, vaultKeylet] = vault.create({.owner = lender, .asset = xrpIssue()}); - env(vaultCreate); - env.close(); - BEAST_EXPECT(env.le(vaultKeylet)); - - // Seed the vault with XRP so it can fund the loan principal - PrettyAsset const xrpAsset{xrpIssue(), 1}; - - BrokerParameters const brokerParams{ - .vaultDeposit = 1'000, - .debtMax = Number{0}, - .coverRateMin = TenthBips32{10'000}, - .coverDeposit = 82, - }; - - auto const brokerInfo = createVaultAndBroker(env, xrpAsset, lender, brokerParams); - // Create a loan with principal 804 XRP and 0% interest (so - // DebtTotal increases by exactly 804) - env(loan::set(borrower, brokerInfo.brokerID, xrpAsset(804).value()), - loan::kInterestRate(TenthBips32(0)), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 2)); - BEAST_EXPECT(env.ter() == tesSUCCESS); - env.close(); - - // Verify DebtTotal is exactly 804 - if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); - BEAST_EXPECT(brokerSle)) - { - log << *brokerSle << std::endl; - BEAST_EXPECT(brokerSle->at(sfDebtTotal) == Number(804)); - } - - // Attempt to withdraw 2 XRP to self, leaving 80 XRP CoverAvailable. - // The minimum is 80.4 XRP, which rounds up to 81 XRP, so this fails. - env(coverWithdraw(lender, brokerInfo.brokerID, xrpAsset(2).value()), - Ter(tecINSUFFICIENT_FUNDS)); - BEAST_EXPECT(env.ter() == tecINSUFFICIENT_FUNDS); - env.close(); - - // Attempt to withdraw 1 XRP to self, leaving 81 XRP CoverAvailable. - // because that leaves sufficient cover, this succeeds - env(coverWithdraw(lender, brokerInfo.brokerID, xrpAsset(1).value())); - BEAST_EXPECT(env.ter() == tesSUCCESS); - env.close(); - - // Validate CoverAvailable == 80 XRP and DebtTotal remains 804 - if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); - BEAST_EXPECT(brokerSle)) - { - log << *brokerSle << std::endl; - BEAST_EXPECT(brokerSle->at(sfCoverAvailable) == xrpAsset(81).value()); - BEAST_EXPECT(brokerSle->at(sfDebtTotal) == Number(804)); - - // Also demonstrate that the true minimum (804 * 10%) exceeds 80 - auto const theoreticalMin = tenthBipsOfValue(Number(804), TenthBips32(10'000)); - log << "Theoretical min cover: " << theoreticalMin << std::endl; - BEAST_EXPECT(Number(804, -1) == theoreticalMin); - } - } - - void - testRIPD3902(FeatureBitset features) - { - testcase("RIPD-3902 - 1 IOU loan payments"); - - using namespace jtx; - - Account const issuer("issuer"); - Account const lender("lender"); - Account const borrower("borrower"); - - BrokerParameters const brokerParams{ - .vaultDeposit = 10, - .debtMax = 0, - .coverRateMin = TenthBips32{0}, - .managementFeeRate = TenthBips16{0}, - .coverRateLiquidation = TenthBips32{0}}; - LoanParameters const loanParams{ - .account = lender, - .counter = borrower, - .principalRequest = Number{1, 0}, - .interest = TenthBips32{100'000}, - .payTotal = 5, - .payInterval = 150, - .gracePd = 60}; - - auto const assetType = AssetType::IOU; - - Env env{*this, features}; - - auto loanResult = - createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); - - if (BEAST_EXPECT(loanResult); !loanResult.has_value()) - return; - - auto broker = std::get(*loanResult); - auto loanKeylet = std::get(*loanResult); - auto pseudoAcct = std::get(*loanResult); - - VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); - - makeLoanPayments( - env, - broker, - loanParams, - loanKeylet, - verifyLoanStatus, - issuer, - lender, - borrower, - PaymentParameters{.showStepBalances = true}); - } - - void - testBorrowerIsBroker() - { - testcase("Test Borrower is Broker"); - using namespace jtx; - using namespace loan; - Account const broker{"broker"}; - Account const issuer{"issuer"}; - Account const borrower{"borrower"}; - Account const depositor{"depositor"}; - - auto testLoanAsset = [&](auto&& getMaxDebt, auto const& borrower) { - Env env(*this); - Vault const vault(env); - - if (borrower == broker) - { - env.fund(XRP(10'000), broker, issuer, depositor); - } - else - { - env.fund(XRP(10'000), broker, borrower, issuer, depositor); - } - env.close(); - - auto const xrpFee = XRP(100); - auto const txFee = Fee(xrpFee); - - STAmount const debtMaximumRequest = getMaxDebt(env); - - auto const& asset = debtMaximumRequest.asset(); - auto const initialVault = asset(debtMaximumRequest * 100); - - auto [tx, vaultKeylet] = vault.create({.owner = broker, .asset = asset}); - env(tx, txFee); - env.close(); - - env(vault.deposit( - {.depositor = depositor, .id = vaultKeylet.key, .amount = initialVault}), - txFee); - env.close(); - - auto const brokerKeylet = keylet::loanBroker(broker.id(), env.seq(broker)); - - env(loan_broker::set(broker, vaultKeylet.key), txFee); - env.close(); - - auto const serviceFee = 101; - - env(set(broker, brokerKeylet.key, debtMaximumRequest), - kCounterparty(borrower), - Sig(sfCounterpartySignature, borrower), - kLoanServiceFee(serviceFee), - kPaymentTotal(10), - txFee); - env.close(); - - std::uint32_t const loanSequence = 1; - auto const loanKeylet = keylet::loan(brokerKeylet.key, loanSequence); - - auto const brokerBalanceBefore = env.balance(broker, asset); - - if (auto const loanSle = env.le(loanKeylet); env.test.BEAST_EXPECT(loanSle)) - { - auto const payment = loanSle->at(sfPeriodicPayment); - auto const totalPayment = payment + serviceFee; - env(loan::pay(borrower, loanKeylet.key, asset(totalPayment)), txFee); - env.close(); - if (auto const vaultSle = env.le(vaultKeylet); BEAST_EXPECT(vaultSle)) - { - auto const expected = [&]() { - // The service fee is transferred to the broker if - // a borrower is not the broker - if (borrower != broker) - return brokerBalanceBefore.number() + serviceFee; - // Since a borrower is the broker, the payment is - // transferred to the Vault from the broker but not - // the service fee. - // If the asset is XRP then the broker pays the txFee. - if (asset.native()) - return brokerBalanceBefore.number() - payment - xrpFee.number(); - return brokerBalanceBefore.number() - payment; - }(); - BEAST_EXPECT(env.balance(broker, asset).value() == asset(expected).value()); - } - } - }; - // Test when a borrower is the broker and is not to verify correct - // service fee transfer in both cases. - for (auto const& borrowerAcct : {broker, borrower}) - { - testLoanAsset( - [&](Env&) -> STAmount { return STAmount{XRPAmount{200'000}}; }, borrowerAcct); - testLoanAsset( - [&](Env& env) -> STAmount { - auto const iou = issuer["USD"]; - env(trust(broker, iou(1'000'000'000))); - env(trust(depositor, iou(1'000'000'000))); - env(pay(issuer, broker, iou(100'000'000))); - env(pay(issuer, depositor, iou(100'000'000))); - env.close(); - return iou(200'000); - }, - borrowerAcct); - testLoanAsset( - [&](Env& env) -> STAmount { - MPTTester const mpt( - {.env = env, - .issuer = issuer, - .holders = {broker, depositor}, - .pay = 100'000'000}); - return mpt(200'000); - }, - borrowerAcct); - } - } - - void - testIssuerIsBorrower(FeatureBitset features) - { - testcase("RIPD-4096 - Issuer as borrower"); - - using namespace jtx; - - Account const issuer("issuer"); - Account const lender("lender"); - - BrokerParameters const brokerParams{ - .vaultDeposit = 100'000, - .debtMax = 0, - .coverRateMin = TenthBips32{0}, - .managementFeeRate = TenthBips16{0}, - .coverRateLiquidation = TenthBips32{0}}; - LoanParameters const loanParams{ - .account = lender, .counter = issuer, .principalRequest = Number{10000}}; - - auto const assetType = AssetType::IOU; - - Env env{*this, features}; - - auto loanResult = - createLoan(env, assetType, brokerParams, loanParams, issuer, lender, issuer); - - if (BEAST_EXPECT(loanResult); !loanResult.has_value()) - return; - - auto broker = std::get(*loanResult); - auto loanKeylet = std::get(*loanResult); - auto pseudoAcct = std::get(*loanResult); - - VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); - - makeLoanPayments( - env, - broker, - loanParams, - loanKeylet, - verifyLoanStatus, - issuer, - lender, - issuer, - PaymentParameters{.showStepBalances = true}); - } - - void - testLimitExceeded() - { - testcase("RIPD-4125 - overpayment"); - - using namespace jtx; - - Account const issuer("issuer"); - Account const lender("lender"); - Account const borrower("borrower"); - - BrokerParameters const brokerParams{ - .vaultDeposit = 100'000, - .debtMax = 0, - .coverRateMin = TenthBips32{0}, - .managementFeeRate = TenthBips16{0}, - .coverRateLiquidation = TenthBips32{0}}; - LoanParameters const loanParams{ - .account = lender, - .counter = borrower, - .principalRequest = Number{200000, -6}, - .interest = TenthBips32{50000}, - .payTotal = 3, - .payInterval = 200, - .gracePd = 60, - .flags = tfLoanOverpayment, - }; - - auto const assetType = AssetType::XRP; - - Env env(*this, makeConfig(), all_, nullptr, beast::Severity::Warning); - - auto loanResult = - createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); - - if (BEAST_EXPECT(loanResult); !loanResult.has_value()) - return; - - auto broker = std::get(*loanResult); - auto loanKeylet = std::get(*loanResult); - auto pseudoAcct = std::get(*loanResult); - - VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); - - auto const state = getCurrentState(env, broker, loanKeylet); - - env(loan::pay( - borrower, - loanKeylet.key, - STAmount{broker.asset, state.periodicPayment * 3 / 2 + 1}, - tfLoanOverpayment)); - env.close(); - - PaymentParameters const paymentParams{ - .showStepBalances = false, - .validateBalances = true, - }; - - makeLoanPayments( - env, - broker, - loanParams, - loanKeylet, - verifyLoanStatus, - issuer, - lender, - borrower, - paymentParams); - } - - void - testOverpaymentManagementFee(FeatureBitset features) - { - testcase("testOverpaymentManagementFee"); - - using namespace jtx; - using namespace loan; - - Env env{*this, features}; - - Account const lender{"lender"}, borrower{"borrower"}; - - env.fund(XRP(10'000'000), lender, borrower); - env.close(); - - PrettyAsset const asset{xrpIssue(), 1000}; - - auto const result = createVaultAndBroker( - env, - asset, - lender, - { - .vaultDeposit = asset(100'000).value(), - .managementFeeRate = TenthBips16(10'000), - }); - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - - auto const loanKeylet = keylet::loan( - result.brokerKeylet().key, (env.le(result.brokerKeylet()))->at(sfLoanSequence)); - env(loan::set( - borrower, result.brokerKeylet().key, asset(10'000).value(), tfLoanOverpayment), - Sig(sfCounterpartySignature, lender), - loan::kPaymentInterval(86400 * 30), - loan::kPaymentTotal(3), - loan::kOverpaymentInterestRate(TenthBips32(percentageToTenthBips(20))), - loanSetFee); - - // From calculator - auto const expectedOverpaymentManagementFee = Number{33333, 0}; - auto const loanBrokerBalanceBefore = env.balance(lender); - - auto const loanPayFee = Fee(env.current()->fees().base * 2); - env(pay(borrower, loanKeylet.key, asset(5'000).value(), tfLoanOverpayment), loanPayFee); - env.close(); - - BEAST_EXPECTS( - env.balance(lender) - loanBrokerBalanceBefore == expectedOverpaymentManagementFee, - "overpayment management fee missmatch; expected:" + - to_string(expectedOverpaymentManagementFee) + - " got: " + to_string(env.balance(lender) - loanBrokerBalanceBefore)); - } - - void - testLoanPayBrokerOwnerMissingTrustline(FeatureBitset features) - { - testcase << "LoanPay Broker Owner Missing Trustline (PoC)"; - using namespace jtx; - using namespace loan; - Account const issuer("issuer"); - Account const borrower("borrower"); - Account const broker("broker"); - auto const iou = issuer["IOU"]; - Env env(*this, features); - env.fund(XRP(20'000), issuer, broker, borrower); - env.close(); - // Set up trustlines and fund accounts - env(trust(broker, iou(20'000'000))); - env(trust(borrower, iou(20'000'000))); - env(pay(issuer, broker, iou(10'000'000))); - env(pay(issuer, borrower, iou(1'000))); - env.close(); - // Create vault and broker - auto const brokerInfo = createVaultAndBroker(env, iou, broker); - // Create a loan first (this creates debt) - auto const keylet = keylet::loan(brokerInfo.brokerID, 1); - env(set(borrower, brokerInfo.brokerID, 10'000), - Sig(sfCounterpartySignature, broker), - kLoanServiceFee(iou(100).value()), - kPaymentInterval(100), - Fee(XRP(100))); - env.close(); - // Ensure broker has sufficient cover so brokerPayee == brokerOwner - // We need coverAvailable >= (debtTotal * coverRateMinimum) - // Deposit enough cover to ensure the fee goes to broker owner - // The default coverRateMinimum is 10%, so for a 10,000 loan we need - // at least 1,000 cover. Default cover is 1,000, so we add more to be - // safe. - auto const additionalCover = iou(50'000).value(); - env(loan_broker::coverDeposit(broker, brokerInfo.brokerID, STAmount{iou, additionalCover})); - env.close(); - // Verify broker owner has a trustline - auto const brokerTrustline = keylet::trustLine(broker, iou); - BEAST_EXPECT(env.le(brokerTrustline) != nullptr); - // Broker owner deletes their trustline - // First, pay any positive balance to issuer to zero it out - auto const brokerBalance = env.balance(broker, iou); - env(pay(broker, issuer, brokerBalance)); - env.close(); - // Remove the trustline by setting limit to 0 - env(trust(broker, iou(0))); - env.close(); - // Verify trustline is deleted - BEAST_EXPECT(env.le(brokerTrustline) == nullptr); - // Now borrower tries to make a payment - // We should get a tesSUCCESS instead of a tecNO_LINE. - env(pay(borrower, keylet.key, iou(10'100)), Fee(XRP(100)), Ter(tesSUCCESS)); - env.close(); - // Verify trustline is still deleted - BEAST_EXPECT(env.le(brokerTrustline) == nullptr); - // Verify the service fee went to the broker pseudo-account - if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); - BEAST_EXPECT(brokerSle)) - { - Account const pseudo("pseudo-account", brokerSle->at(sfAccount)); - auto const balance = env.balance(pseudo, iou); - // 1,000 default + 50,000 extra + 100 service fee from LoanPay - BEAST_EXPECTS(balance == iou(51'100), to_string(json::Value(balance))); - } - } - - void - testLoanPayBrokerOwnerUnauthorizedMPT(FeatureBitset features) - { - testcase << "LoanPay Broker Owner MPT unauthorized"; - using namespace jtx; - using namespace loan; - - Account const issuer("issuer"); - Account const borrower("borrower"); - Account const broker("broker"); - - Env env{*this, features}; - env.fund(XRP(20'000), issuer, broker, borrower); - env.close(); - - MPTTester mptt{env, issuer, kMptInitNoFund}; - mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); - - PrettyAsset const mpt{mptt.issuanceID()}; - - // Authorize broker and borrower - mptt.authorize({.account = broker}); - mptt.authorize({.account = borrower}); - - env.close(); - - // Fund accounts - env(pay(issuer, broker, mpt(10'000'000))); - env(pay(issuer, borrower, mpt(1'000))); - env.close(); - - // Create vault and broker - auto const brokerInfo = createVaultAndBroker(env, mpt, broker); - // Create a loan first (this creates debt) - auto const keylet = keylet::loan(brokerInfo.brokerID, 1); - env(set(borrower, brokerInfo.brokerID, 10'000), - Sig(sfCounterpartySignature, broker), - kLoanServiceFee(mpt(100).value()), - kPaymentInterval(100), - Fee(XRP(100))); - env.close(); - // Ensure broker has sufficient cover so brokerPayee == brokerOwner - // We need coverAvailable >= (debtTotal * coverRateMinimum) - // Deposit enough cover to ensure the fee goes to broker owner - // The default coverRateMinimum is 10%, so for a 10,000 loan we need - // at least 1,000 cover. Default cover is 1,000, so we add more to be - // safe. - auto const additionalCover = mpt(50'000).value(); - env(loan_broker::coverDeposit(broker, brokerInfo.brokerID, STAmount{mpt, additionalCover})); - env.close(); - // Verify broker owner is authorized - auto const brokerMpt = keylet::mptoken(mptt.issuanceID(), broker); - BEAST_EXPECT(env.le(brokerMpt) != nullptr); - // Broker owner unauthorizes. - // First, pay any positive balance to issuer to zero it out - auto const brokerBalance = env.balance(broker, mpt); - env(pay(broker, issuer, brokerBalance)); - env.close(); - // Then, unauthorize the MPT. - mptt.authorize({.account = broker, .flags = tfMPTUnauthorize}); - env.close(); - // Verify the MPT is unauthorized. - BEAST_EXPECT(env.le(brokerMpt) == nullptr); - // Now borrower tries to make a payment - // We should get a tesSUCCESS instead of a tecNO_AUTH. - auto const borrowerBalance = env.balance(borrower, mpt); - env(pay(borrower, keylet.key, mpt(10'100)), Fee(XRP(100)), Ter(tesSUCCESS)); - env.close(); - // Verify the MPT is still unauthorized. - BEAST_EXPECT(env.le(brokerMpt) == nullptr); - // Verify the service fee went to the broker pseudo-account - if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); - BEAST_EXPECT(brokerSle)) - { - Account const pseudo("pseudo-account", brokerSle->at(sfAccount)); - auto const balance = env.balance(pseudo, mpt); - // 1,000 default + 50,000 extra + 100 service fee from LoanPay - BEAST_EXPECTS(balance == mpt(51'100), to_string(json::Value(balance))); - } - } - - void - testLoanPayBrokerOwnerNoPermissionedDomainMPT(FeatureBitset features) - { - testcase << "LoanPay Broker Owner without permissioned domain of the MPT"; - using namespace jtx; - using namespace loan; - - Account const issuer("issuer"); - Account const borrower("borrower"); - Account const broker("broker"); - - Env env{*this, features}; - env.fund(XRP(20'000), issuer, broker, borrower); - env.close(); - - auto credType = "credential1"; - - pdomain::Credentials const credentials1 = {{.issuer = issuer, .credType = credType}}; - env(pdomain::setTx(issuer, credentials1)); - env.close(); - - auto domainID = pdomain::getNewDomain(env.meta()); - - env(credentials::create(broker, issuer, credType)); - env(credentials::accept(broker, issuer, credType)); - env.close(); - - env(credentials::create(borrower, issuer, credType)); - env(credentials::accept(borrower, issuer, credType)); - env.close(); - - MPTTester mptt{env, issuer, kMptInitNoFund}; - mptt.create({ - .flags = tfMPTCanClawback | tfMPTRequireAuth | tfMPTCanTransfer | tfMPTCanLock, - .domainID = domainID, - }); - - PrettyAsset const mpt{mptt.issuanceID()}; - - // Authorize broker and borrower - mptt.authorize({.account = broker}); - mptt.authorize({.account = borrower}); - - env.close(); - - // Fund accounts - env(pay(issuer, broker, mpt(10'000'000))); - env(pay(issuer, borrower, mpt(1'000))); - env.close(); - - // Create vault and broker - auto const brokerInfo = createVaultAndBroker(env, mpt, broker); - // Create a loan first (this creates debt) - auto const keylet = keylet::loan(brokerInfo.brokerID, 1); - env(set(borrower, brokerInfo.brokerID, 10'000), - Sig(sfCounterpartySignature, broker), - kLoanServiceFee(mpt(100).value()), - kPaymentInterval(100), - Fee(XRP(100))); - env.close(); - // Ensure broker has sufficient cover so brokerPayee == brokerOwner - // We need coverAvailable >= (debtTotal * coverRateMinimum) - // Deposit enough cover to ensure the fee goes to broker owner - // The default coverRateMinimum is 10%, so for a 10,000 loan we need - // at least 1,000 cover. Default cover is 1,000, so we add more to be - // safe. - auto const additionalCover = mpt(50'000).value(); - env(loan_broker::coverDeposit(broker, brokerInfo.brokerID, STAmount{mpt, additionalCover})); - env.close(); - // Verify broker owner is authorized - auto const brokerMpt = keylet::mptoken(mptt.issuanceID(), broker); - BEAST_EXPECT(env.le(brokerMpt) != nullptr); - // Remove the credentials for the Broker owner. - // First, pay any positive balance to issuer to zero it out - auto const brokerBalance = env.balance(broker, mpt); - env(pay(broker, issuer, brokerBalance)); - env.close(); - - env(credentials::deleteCred(broker, broker, issuer, credType)); - env.close(); - - // Make sure the broker is not authorized to hold the MPT after we - // deleted the credentials - env(pay(issuer, broker, mpt(1'000)), Ter(tecNO_AUTH)); - - // Now borrower tries to make a payment - // We should get a tesSUCCESS instead of a tecNO_AUTH. - auto const borrowerBalance = env.balance(borrower, mpt); - env(pay(borrower, keylet.key, mpt(10'100)), Fee(XRP(100)), Ter(tesSUCCESS)); - env.close(); - // Verify broker is still not authorized - env(pay(issuer, broker, mpt(1'000)), Ter(tecNO_AUTH)); - // Verify the service fee went to the broker pseudo-account - if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); - BEAST_EXPECT(brokerSle)) - { - Account const pseudo("pseudo-account", brokerSle->at(sfAccount)); - auto const balance = env.balance(pseudo, mpt); - // 1,000 default + 50,000 extra + 100 service fee from LoanPay - BEAST_EXPECTS(balance == mpt(51'100), to_string(json::Value(balance))); - } - } - - void - testLoanSetBrokerOwnerNoPermissionedDomainMPT(FeatureBitset features) - { - testcase << "LoanSet Broker Owner without permissioned domain of the MPT"; - using namespace jtx; - using namespace loan; - - Account const issuer("issuer"); - Account const borrower("borrower"); - Account const broker("broker"); - - Env env{*this, features}; - env.fund(XRP(20'000), issuer, broker, borrower); - env.close(); - - auto credType = "credential1"; - - pdomain::Credentials const credentials1{{.issuer = issuer, .credType = credType}}; - env(pdomain::setTx(issuer, credentials1)); - env.close(); - - auto domainID = pdomain::getNewDomain(env.meta()); - - // Add credentials for the broker and borrower - env(credentials::create(broker, issuer, credType)); - env(credentials::accept(broker, issuer, credType)); - env.close(); - - env(credentials::create(borrower, issuer, credType)); - env(credentials::accept(borrower, issuer, credType)); - env.close(); - - MPTTester mptt{env, issuer, kMptInitNoFund}; - mptt.create({ - .flags = tfMPTCanClawback | tfMPTRequireAuth | tfMPTCanTransfer | tfMPTCanLock, - .domainID = domainID, - }); - - PrettyAsset const mpt{mptt.issuanceID()}; - - // Authorize broker and borrower - mptt.authorize({.account = broker}); - mptt.authorize({.account = borrower}); - env.close(); - - // Fund accounts - env(pay(issuer, broker, mpt(10'000'000))); - env(pay(issuer, borrower, mpt(1'000))); - env.close(); - - // Create vault and broker - auto const brokerInfo = createVaultAndBroker(env, mpt, broker); - - // Remove the credentials for the Broker owner. - // Clear the balance first. - auto const brokerBalance = env.balance(broker, mpt); - env(pay(broker, issuer, brokerBalance)); - env.close(); - // Delete the credentials - env(credentials::deleteCred(broker, broker, issuer, credType)); - env.close(); - - // Create a loan, this should fail for tecNO_AUTH - env(set(borrower, brokerInfo.brokerID, 10'000), - Sig(sfCounterpartySignature, broker), - kLoanServiceFee(mpt(100).value()), - kPaymentInterval(100), - Fee(XRP(100)), - Ter(tecNO_AUTH)); - env.close(); - } - - void - testSequentialFLCDepletion(FeatureBitset features) - { - testcase << "First-Loss Capital Depletion on Sequential Defaults"; - - using namespace jtx; - using namespace loan; - using namespace loan_broker; - - Env env{*this, features}; - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrowerA{"borrowerA"}; - Account const borrowerB{"borrowerB"}; - - env.fund(XRP(1'000'000), issuer, lender, borrowerA, borrowerB); - env.close(); - - PrettyAsset const asset = xrpIssue(); - auto const vaultDepositAmount = - asset(200'000); // Enough for 2 x 50k loans plus interest/fees - - auto const brokerInfo = createVaultAndBroker( - env, - asset, - lender, - { - .vaultDeposit = vaultDepositAmount.value(), - .debtMax = 0, - .coverRateMin = TenthBips32(20000), // 20% - .coverDeposit = 21'000, - .managementFeeRate = TenthBips16(100), // 0.1% - .coverRateLiquidation = TenthBips32(100000), - }); - auto const brokerKeylet = brokerInfo.brokerKeylet(); - - // Create two identical loans: each 50,000 XRP principal (scaled down to - // avoid funding issues) Total DebtTotal will be ~100,000 XRP (principal - // + interest) Formula will calculate cover as: 100% × (20% × 100,000) = - // 20,000 XRP So we need FLC = 20,000 XRP to be fully consumed by first - // default - auto const principalAmount = Number(50'000); - auto const loanPaymentInterval = 2592000; // 30 days - auto const loanGracePeriod = 604800; // 7 days - - // Create Loan A - auto loanATx = env.jt( - set(borrowerA, brokerKeylet.key, principalAmount), - Sig(sfCounterpartySignature, lender), - kInterestRate(TenthBips32(500)), // 5% - kPaymentTotal(12), - loan::kPaymentInterval(loanPaymentInterval), - loan::kGracePeriod(loanGracePeriod), - Fee(XRP(10))); // Sufficient fee for multi-sig transaction - env(loanATx); - env.close(); - - auto const loanAKeylet = keylet::loan(brokerKeylet.key, 1); - - // Create Loan B - auto loanBTx = env.jt( - set(borrowerB, brokerKeylet.key, principalAmount), - Sig(sfCounterpartySignature, lender), - kInterestRate(TenthBips32(500)), // 5% - kPaymentTotal(12), - loan::kPaymentInterval(loanPaymentInterval), - loan::kGracePeriod(loanGracePeriod), - Fee(XRP(10))); // Sufficient fee for multi-sig transaction - env(loanBTx); - env.close(); - - auto const loanBKeylet = keylet::loan(brokerKeylet.key, 2); - - auto loanASle = env.le(loanAKeylet); - if (!BEAST_EXPECT(loanASle)) - return; - - // Advance time past grace period for both loans to be defaultable - auto const loanANextDue = loanASle->at(sfNextPaymentDueDate); - auto const loanAGrace = loanASle->at(sfGracePeriod); - env.close(std::chrono::seconds{loanANextDue + loanAGrace + 60}); - - env(manage(lender, loanAKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); - env.close(); - - // Verify Loan A is defaulted - loanASle = env.le(loanAKeylet); - if (!BEAST_EXPECT(loanASle)) - return; - BEAST_EXPECT(loanASle->isFlag(lsfLoanDefault)); - BEAST_EXPECT(loanASle->at(sfPaymentRemaining) == 0); - - // Check broker state after first default (from committed ledger) - auto brokerSle = env.le(brokerKeylet); - if (!BEAST_EXPECT(brokerSle)) - return; - auto const afterFirstDebtTotal = brokerSle->at(sfDebtTotal); - auto const afterFirstCoverAvailable = brokerSle->at(sfCoverAvailable); - - // DebtTotal should have decreased by Loan A's debt - BEAST_EXPECT(afterFirstDebtTotal == 50'134); - - // CoverAvailable should have decreased significantly - BEAST_EXPECT(afterFirstCoverAvailable == 946); - - env(manage(lender, loanBKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); - - brokerSle = env.le(brokerKeylet); - if (!BEAST_EXPECT(brokerSle)) - return; - auto const afterSecondDebtTotal = brokerSle->at(sfDebtTotal); - auto const afterSecondCoverAvailable = brokerSle->at(sfCoverAvailable); - - BEAST_EXPECT(afterSecondDebtTotal == 0); - - BEAST_EXPECT(afterSecondCoverAvailable == 0); - } - - void - testYieldTheftRounding(std::uint32_t flags) - { - testcase("Rounding manipulation does not permit yield theft"); - using namespace jtx; - using namespace loan; - - // 1. Setup Environment - Env env(*this, all_); - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1000), issuer, lender, borrower); - env.close(); - - // 2. Asset Selection - PrettyAsset const iou = issuer["USD"]; - env(trust(lender, iou(100'000'000))); - env(trust(borrower, iou(100'000'000))); - env(pay(issuer, lender, iou(100'000'000))); - env(pay(issuer, borrower, iou(100'000'000))); - env.close(); - - // 3. Create Vault and Broker with High Debt Limit (100M) - auto const brokerInfo = createVaultAndBroker( - env, - iou, - lender, - { - .vaultDeposit = 5'000'000, - .debtMax = Number{100'000'000}, - .coverDeposit = 500'000, - }); - auto const [currentSeq, vaultKeylet] = [&]() { - auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); - if (!BEAST_EXPECT(brokerSle)) - return std::make_tuple(0u, keylet::unchecked(beast::kZero)); - auto const currentSeq = brokerSle->at(sfLoanSequence); - auto const vaultKeylet = keylet::vault(brokerSle->at(sfVaultID)); - return std::make_tuple(currentSeq, vaultKeylet); - }(); - - // 4. Loan Parameters (Attack Vector) - Number const principal = 1'000'000; - TenthBips32 const interestRate = TenthBips32{1}; // 0.001% - std::uint32_t const paymentInterval = 86400; - std::uint32_t const paymentTotal = 3650; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - env(set(borrower, brokerInfo.brokerID, iou(principal).value(), flags), - Sig(sfCounterpartySignature, lender), - loan::kInterestRate(interestRate), - loan::kPaymentInterval(paymentInterval), - loan::kPaymentTotal(paymentTotal), - Fee(loanSetFee)); - env.close(); - - // --- RETRIEVE OBJECTS & SETUP ATTACK --- - - auto borrowerBalance = [&]() { return env.balance(borrower, iou); }; - auto const borrowerScale = static_cast(borrowerBalance()).exponent(); - - auto const loanKeylet = keylet::loan(brokerInfo.brokerID, currentSeq); - auto const maybePeriodicPayment = [&]() -> std::optional { - auto const loanSle = env.le(loanKeylet); - if (!BEAST_EXPECT(loanSle)) - return std::nullopt; - // Construct Payment - return STAmount{iou, loanSle->at(sfPeriodicPayment)}; - }(); - if (!maybePeriodicPayment) - return; - auto const periodicPayment = *maybePeriodicPayment; - auto const roundedPayment = - roundToScale(periodicPayment, borrowerScale, Number::RoundingMode::Upward); - - // ATTACK: Add dust buffer (1e-9) to force 'excess' logic execution - STAmount const paymentBuffer{iou, Number(1, -9)}; - STAmount const attackPayment = periodicPayment + paymentBuffer; - - auto const maybeInitialVaultAssets = [&]() -> std::optional { - auto const vault = env.le(vaultKeylet); - if (!BEAST_EXPECT(vault)) - return std::nullopt; - return vault->at(sfAssetsTotal); - }(); - if (!maybeInitialVaultAssets) - return; - auto const initialVaultAssets = *maybeInitialVaultAssets; - - // 5. Execution Loop - int yieldTheftCount = 0; - auto previousAssetsTotal = initialVaultAssets; - - for (int i = 0; i < 100; ++i) - { - auto const balanceBefore = borrowerBalance(); - env(pay(borrower, loanKeylet.key, attackPayment, flags)); - env.close(); - auto const borrowerDelta = balanceBefore - borrowerBalance(); - BEAST_EXPECT(borrowerDelta.signum() == roundedPayment.signum()); - - auto const loanSle = env.le(loanKeylet); - if (!BEAST_EXPECT(loanSle)) - break; - auto const updatedPayment = STAmount{iou, loanSle->at(sfPeriodicPayment)}; - BEAST_EXPECT( - (roundToScale(updatedPayment, borrowerScale, Number::RoundingMode::Upward) == - roundedPayment)); - BEAST_EXPECT( - (updatedPayment == periodicPayment) || - (flags == tfLoanOverpayment && i >= 2 && updatedPayment < periodicPayment)); - - auto const currentVaultSle = env.le(vaultKeylet); - if (!BEAST_EXPECT(currentVaultSle)) - break; - - auto const currentAssetsTotal = currentVaultSle->at(sfAssetsTotal); - auto const delta = currentAssetsTotal - previousAssetsTotal; - - BEAST_EXPECT( - (delta == beast::kZero && borrowerDelta <= roundedPayment) || - (delta > beast::kZero && borrowerDelta > roundedPayment)); - - // If tx succeeded but Assets Total didn't change, interest was - // stolen. - if (delta == beast::kZero && borrowerDelta > roundedPayment) - { - yieldTheftCount++; - } - - previousAssetsTotal = currentAssetsTotal; - } - - BEAST_EXPECTS(yieldTheftCount == 0, std::to_string(yieldTheftCount)); - } - - // Tests that vault withdrawals work correctly when the vault has unrealized - // loss from an impaired loan, ensuring the invariant check properly - // accounts for the loss. - void - testWithdrawReflectsUnrealizedLoss(FeatureBitset features) - { - using namespace jtx; - using namespace loan; - using namespace std::chrono_literals; - - testcase("Vault withdraw reflects sfLossUnrealized"); - - // Test constants - static constexpr std::int64_t kInitialFunding = 1'000'000; - static constexpr std::int64_t kLenderInitialIou = 5'000'000; - static constexpr std::int64_t kDepositorInitialIou = 1'000'000; - static constexpr std::int64_t kBorrowerInitialIou = 100'000; - static constexpr std::int64_t kDepositAmount = 5'000; - static constexpr std::int64_t kPrincipalAmount = 99; - static constexpr std::uint64_t kExpectedSharesPerDepositor = 5'000'000'000; - static constexpr std::uint32_t kLocalPaymentInterval = 600; - static constexpr std::uint32_t kLocalPaymentTotal = 2; - - Env env{*this, features}; - - // Setup accounts - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const depositorA{"lpA"}; - Account const depositorB{"lpB"}; - Account const borrower{"borrowerA"}; - - env.fund(XRP(kInitialFunding), issuer, lender, depositorA, depositorB, borrower); - env.close(); - - // Setup trust lines - PrettyAsset const iouAsset = issuer[iouCurrency_]; - env(trust(lender, iouAsset(10'000'000))); - env(trust(depositorA, iouAsset(10'000'000))); - env(trust(depositorB, iouAsset(10'000'000))); - env(trust(borrower, iouAsset(10'000'000))); - env.close(); - - // Fund accounts with IOUs - env(pay(issuer, lender, iouAsset(kLenderInitialIou))); - env(pay(issuer, depositorA, iouAsset(kDepositorInitialIou))); - env(pay(issuer, depositorB, iouAsset(kDepositorInitialIou))); - env(pay(issuer, borrower, iouAsset(kBorrowerInitialIou))); - env.close(); - - // Create vault and broker, then add deposits from two depositors - auto const broker = createVaultAndBroker(env, iouAsset, lender); - Vault v{env}; - - env(v.deposit({ - .depositor = depositorA, - .id = broker.vaultKeylet().key, - .amount = iouAsset(kDepositAmount), - }), - Ter(tesSUCCESS)); - env(v.deposit({ - .depositor = depositorB, - .id = broker.vaultKeylet().key, - .amount = iouAsset(kDepositAmount), - }), - Ter(tesSUCCESS)); - env.close(); - - // Create a loan - auto const sleBroker = env.le(keylet::loanBroker(broker.brokerID)); - if (!BEAST_EXPECT(sleBroker)) - return; - - auto const loanKeylet = keylet::loan(broker.brokerID, sleBroker->at(sfLoanSequence)); - - env(set(borrower, broker.brokerID, kPrincipalAmount), - Sig(sfCounterpartySignature, lender), - kPaymentTotal(kLocalPaymentTotal), - kPaymentInterval(kLocalPaymentInterval), - Fee(env.current()->fees().base * 2), - Ter(tesSUCCESS)); - env.close(); - - // Impair the loan to create unrealized loss - env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS)); - env.close(); - - // Verify unrealized loss is recorded in the vault - auto const vaultAfterImpair = env.le(broker.vaultKeylet()); - if (!BEAST_EXPECT(vaultAfterImpair)) - return; - - BEAST_EXPECT( - vaultAfterImpair->at(sfLossUnrealized) == broker.asset(kPrincipalAmount).value()); - - // Helper to get share balance for a depositor - auto const shareAsset = vaultAfterImpair->at(sfShareMPTID); - auto const getShareBalance = [&](Account const& depositor) -> std::uint64_t { - auto const token = env.le(keylet::mptoken(shareAsset, depositor.id())); - return token ? token->getFieldU64(sfMPTAmount) : 0; - }; - - // Verify both depositors have equal shares - auto const sharesLpA = getShareBalance(depositorA); - auto const sharesLpB = getShareBalance(depositorB); - BEAST_EXPECT(sharesLpA == kExpectedSharesPerDepositor); - BEAST_EXPECT(sharesLpB == kExpectedSharesPerDepositor); - BEAST_EXPECT(sharesLpA == sharesLpB); - - // Helper to attempt withdrawal - auto const attemptWithdrawShares = [&](Account const& depositor, - std::uint64_t shareAmount, - TER expected) { - STAmount const shareAmt{MPTIssue{shareAsset}, Number(shareAmount)}; - env(v.withdraw( - {.depositor = depositor, .id = broker.vaultKeylet().key, .amount = shareAmt}), - Ter(expected)); - env.close(); - }; - - // Regression test: Both depositors should successfully withdraw despite - // unrealized loss. Previously failed with invariant violation: - // "withdrawal must change vault and destination balance by equal - // amount". This was caused by sharesToAssetsWithdraw rounding down, - // creating a mismatch where vaultDeltaAssets * -1 != destinationDelta - // when unrealized loss exists. - attemptWithdrawShares(depositorA, sharesLpA, tesSUCCESS); - attemptWithdrawShares(depositorB, sharesLpB, tesSUCCESS); - } - - // A residual overpayment can reduce the stored principal by one scale-unit - // *less* than computeOverpaymentComponents predicts, firing the - // "principal change agrees" XRPL_ASSERT_PARTS in doOverpayment: - // - // trackedPrincipalDelta == principalOutstanding - newPrincipalOutstanding - // - // tryOverpayment re-amortizes the loan at the reduced principal, then - // re-derives the theoretical principal from the new periodic payment via - // (P * paymentFactor) / paymentFactor. That round-trip is not exact in - // Number's 19-digit arithmetic; a positive residual pushes the recomputed - // principal a hair above the exact grid point `oldPrincipal - delta`, and - // the Upward rounding in tryOverpayment then bumps it a full scale-unit - // higher. The principal therefore drops by `delta - 1 unit`, not `delta`. - // - // Concrete case (isolated, at the tryOverpayment level): - // A 100 USD loan at the minimum non-zero rate, 3 payments, loanScale -10. - // After one regular payment (principalOutstanding 66.6666666674) a residual overpayment of - // 0.049999998 yields trackedPrincipalDelta 0.048999998 but only reduces the principal by - // 0.0489999979 (newPrincipal 66.6176666695) — short by 1e-10. - // - // With fixCleanup3_2_0, tryOverpayment pins the new principal to the exact, - // on-grid reduction (oldPrincipal - trackedPrincipalDelta) instead of the - // lossy (P*factor)/factor round-trip, so the assertion holds and the - // overpayment applies cleanly. The three "principal change agrees" / - // "interest paid agrees" / "principal payment matches" assertions are - // gated behind the same amendment, so without it they are disabled (the - // server does not abort) and the loan keeps the pre-amendment computation. - // - // The test runs the same scenario under both amendment settings and checks - // the stored principal against a ground-truth value derived independently of - // the loan-state computation under test. - void - testBugOverpaymentPrincipalChange() - { - testcase("bug: doOverpayment asserts 'principal change agrees'"); - - using namespace jtx; - using namespace loan; - using namespace xrpl::detail; - - struct Params - { - TenthBips32 interestRate; - TenthBips16 managementFeeRate; - std::uint32_t paymentTotal; - std::uint32_t paymentInterval; - std::int64_t principal; - Number overpayment; - TenthBips32 overpaymentInterestRate; - TenthBips32 overpaymentFeeRate; - std::optional vaultScale; - }; - - struct Result - { - Number principalOutstanding; // stored principal after the LoanPay - Number expectedNewPrincipal; // ground truth, independent of the fix - Number managementFeeChange; // managementFeeOutstanding after - before - Number unit; // one scale-unit at the loan scale - }; - - auto runScenario = [this](FeatureBitset features, Params const& p) -> Result { - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"vaultOwner"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env(fset(issuer, asfDefaultRipple)); - env.close(); - - PrettyAsset const iouAsset = issuer["USD"]; - Asset const asset = iouAsset.raw(); - STAmount const iouLimit{asset, Number{9'999'999'999'999'999LL}}; - env(trust(lender, iouLimit)); - env(trust(borrower, iouLimit)); - env(pay(issuer, lender, iouAsset(1'000'000))); - env(pay(issuer, borrower, iouAsset(1'000'000))); - env.close(); - - auto const broker = createVaultAndBroker( - env, - iouAsset, - lender, - {.vaultDeposit = 900'000, - .debtMax = 0, - .managementFeeRate = p.managementFeeRate, - .vaultScale = p.vaultScale}); - - auto const brokerSle = env.le(broker.brokerKeylet()); - BEAST_EXPECT(brokerSle); - auto const loanSequence = brokerSle ? brokerSle->at(sfLoanSequence) : 0; - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); - - env(set(borrower, broker.brokerID, Number{p.principal}, tfLoanOverpayment), - Sig(sfCounterpartySignature, lender), - kInterestRate(p.interestRate), - kPaymentTotal(p.paymentTotal), - kPaymentInterval(p.paymentInterval), - kGracePeriod(p.paymentInterval), - kOverpaymentFee(p.overpaymentFeeRate), - kOverpaymentInterestRate(p.overpaymentInterestRate), - Fee(env.current()->fees().base * 2), - Ter(tesSUCCESS)); - env.close(); - - // The single LoanPay below makes one regular payment (the overpayment - // is smaller than one period) and leaves the residual as an - // overpayment. - auto const s = getCurrentState(env, broker, loanKeylet); - auto const periodicRate = loanPeriodicRate(s.interestRate, s.paymentInterval); - auto const onePeriod = computePaymentComponents( - env.current()->rules(), - asset, - s.loanScale, - s.totalValue, - s.principalOutstanding, - s.managementFeeOutstanding, - s.periodicPayment, - periodicRate, - s.paymentRemaining, - p.managementFeeRate); - - // Ground truth: the stored principal must drop by exactly the regular - // payment's principal portion plus the overpayment's principal - // portion. computeOverpaymentComponents depends only on the - // overpayment amount and rates (not on the loan-state computation - // under test), so it is an independent oracle. Both components are - // computed under the same rules as the env so the payment factor - // matches. - auto const overpaymentComponents = computeOverpaymentComponents( - env.current()->rules(), - asset, - s.loanScale, - p.overpayment, - p.overpaymentInterestRate, - p.overpaymentFeeRate, - p.managementFeeRate); - Number const expectedNewPrincipal = s.principalOutstanding - - onePeriod.trackedPrincipalDelta - overpaymentComponents.trackedPrincipalDelta; - - Number const managementFeeBefore = s.managementFeeOutstanding; - - STAmount const payAmount{asset, onePeriod.trackedValueDelta + p.overpayment}; - env(pay(borrower, loanKeylet.key, payAmount), - Txflags(tfLoanOverpayment), - Ter(tesSUCCESS)); - env.close(); - - auto const loanSle = env.le(loanKeylet); - BEAST_EXPECT(loanSle); - - return Result{ - .principalOutstanding = loanSle ? Number{loanSle->at(sfPrincipalOutstanding)} : 0, - .expectedNewPrincipal = expectedNewPrincipal, - .managementFeeChange = - (loanSle ? Number{loanSle->at(sfManagementFeeOutstanding)} : Number{0}) - - managementFeeBefore, - .unit = Number{1, s.loanScale}}; - }; - - // Scenario 1: the original near-zero-rate principal reproduction - // (loanScale -10, no management fee). 0.049999998 is smaller than one - // period, so it stays a residual overpayment. - Params const principalCase{ - .interestRate = TenthBips32{1}, - .managementFeeRate = TenthBips16{0}, - .paymentTotal = 3, - .paymentInterval = 60, - .principal = 100, - .overpayment = Number{49999998, -9}, - .overpaymentInterestRate = TenthBips32{1000}, - .overpaymentFeeRate = TenthBips32{1000}, - .vaultScale = 1}; - - // With fixCleanup3_2_0 the stored principal lands exactly on the - // ground-truth grid point: it is reduced by exactly the overpayment's - // principal portion. This is the key correctness check: if the principal - // pin were removed (even with the assertions still gated off), the lossy - // (P * factor) / factor round-trip would leave the principal one - // scale-unit high and this would fail. - Result const fixed = runScenario(all_, principalCase); - BEAST_EXPECTS( - fixed.principalOutstanding == fixed.expectedNewPrincipal, - "fixed principal " + to_string(fixed.principalOutstanding) + " != expected " + - to_string(fixed.expectedNewPrincipal)); - - // Without the amendment the loan amortizes with the catastrophically - // cancelling near-zero payment factor, so its schedule (and ground truth) - // differ from the fixed case; the gated assertions keep the server from - // aborting and the overpayment still lands exactly on that schedule. - Result const legacy = runScenario(all_ - fixCleanup3_2_0, principalCase); - BEAST_EXPECTS( - legacy.principalOutstanding == legacy.expectedNewPrincipal, - "legacy principal " + to_string(legacy.principalOutstanding) + " != expected " + - to_string(legacy.expectedNewPrincipal)); - - // Scenario 2: a normal-rate loan with a 10% management fee. At a normal - // rate the payment factor is identical across the amendment, so toggling - // fixCleanup3_2_0 isolates the fix. This overpayment (found by search) - // lands on a state where both the principal and the management fee differ - // by one scale-unit between the fixed and legacy paths. - Params const feeCase{ - .interestRate = TenthBips32{10000}, - .managementFeeRate = TenthBips16{10000}, - .paymentTotal = 6, - .paymentInterval = 30u * 24 * 60 * 60, - .principal = 1000, - .overpayment = Number{214367363, -10}, - .overpaymentInterestRate = TenthBips32{0}, - .overpaymentFeeRate = TenthBips32{0}, - .vaultScale = std::nullopt}; - - Result const feeFixed = runScenario(all_, feeCase); - Result const feeLegacy = runScenario(all_ - fixCleanup3_2_0, feeCase); - - // With the fix the principal is the exact reduction; without it the lossy - // (P * factor) / factor round-trip leaves it one scale-unit high. - BEAST_EXPECTS( - feeFixed.principalOutstanding == feeFixed.expectedNewPrincipal, - "fee-case fixed principal " + to_string(feeFixed.principalOutstanding) + - " != expected " + to_string(feeFixed.expectedNewPrincipal)); - BEAST_EXPECTS( - feeLegacy.principalOutstanding == feeLegacy.expectedNewPrincipal + feeLegacy.unit, - "fee-case legacy principal " + to_string(feeLegacy.principalOutstanding) + - " != expected " + to_string(feeLegacy.expectedNewPrincipal + feeLegacy.unit)); - - // Management fee: the overpayment re-amortizes a fee-bearing loan, so the management fee - // outstanding drops. - // - // Unlike the principal that is already at the correct precision, the re-amortized - // management fee is tenthBipsOfValue of the new schedule's gross interest, which depends - // on the recomputed periodic payment. So the expected change below is a pinned constant - // captured from a passing run a magic value only because there is nothing simpler to - // compare against. - // - // At the integration level, toggling the amendment also changes the regular payment's - // rounding so a fixed-vs-legacy comparison cannot isolate the overpayment management-fee - // fix. - BEAST_EXPECT(feeFixed.managementFeeChange == feeLegacy.managementFeeChange); - BEAST_EXPECTS( - (feeFixed.managementFeeChange == Number{-8219709543, -10}), - "fee-case mgmt fee change " + to_string(feeFixed.managementFeeChange)); - } - - // A LoanSet with InterestRate = 1 (0.001% annualized, the minimum non-zero - // rate). At such a near-zero rate the closed-form payment factor - // (1 + r)^n - 1 cancels catastrophically. - // - // Without fixCleanup3_2_0 the resulting amortization is degenerate and the - // LoanSet is rejected with tecPRECISION_LOSS (no loan created). With the - // amendment, computePowerMinusOneHybrid uses a numerically-stable series - // expansion, so the loan is created and the scheduled payments - // (2 * periodicPayment) cover the principal — no economic underpayment - // (yield theft). - // - // The test runs the same LoanSet under both amendment settings and pins the - // exact outcome for each. - void - testLoanSetNearZeroInterestRateSucceeds() - { - testcase("LoanSet near-zero interest rate covers principal"); - - using namespace jtx; - using namespace loan; - - Number const principalRequested{1000}; - - struct Result - { - TER ter = tesSUCCESS; - bool created = false; - std::int32_t loanScale = 0; - Number principal; - Number totalValue; - Number managementFee; - Number periodicPayment; - }; - - auto runScenario = [&](FeatureBitset features, TER expectedTer) -> Result { - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"vaultOwner"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env(fset(issuer, asfDefaultRipple)); - env.close(); - - PrettyAsset const iouAsset = issuer["USD"]; - STAmount const iouLimit{iouAsset.raw(), Number{9'999'999'999'999'999LL}}; - env(trust(lender, iouLimit)); - env(trust(borrower, iouLimit)); - env(pay(issuer, lender, iouAsset(1'000'000))); - env(pay(issuer, borrower, iouAsset(1'000'000))); - env.close(); - - auto const broker = createVaultAndBroker( - env, - iouAsset, - lender, - {.vaultDeposit = 100'000, .debtMax = 0, .managementFeeRate = TenthBips16{0}}); - - auto const brokerSle = env.le(broker.brokerKeylet()); - BEAST_EXPECT(brokerSle); - auto const loanSequence = brokerSle ? brokerSle->at(sfLoanSequence) : 0; - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); - - env(set(borrower, broker.brokerID, principalRequested), - Sig(sfCounterpartySignature, lender), - kInterestRate(TenthBips32{1}), - kPaymentTotal(2), - kPaymentInterval(400), - Fee(env.current()->fees().base * 2), - Ter(expectedTer)); - env.close(); - - Result r; - r.ter = env.ter(); - if (auto const loanSle = env.le(loanKeylet)) - { - r.created = true; - r.loanScale = loanSle->at(sfLoanScale); - r.principal = loanSle->at(sfPrincipalOutstanding); - r.totalValue = loanSle->at(sfTotalValueOutstanding); - r.managementFee = loanSle->at(sfManagementFeeOutstanding); - r.periodicPayment = loanSle->at(sfPeriodicPayment); - } - return r; - }; - - Result const fixed = runScenario(all_, tesSUCCESS); - Result const legacy = runScenario(all_ - fixCleanup3_2_0, tecPRECISION_LOSS); - - // Without the amendment, the catastrophically-cancelling closed-form - // payment factor produces a degenerate amortization that fails - // checkLoanGuards: the LoanSet is rejected with tecPRECISION_LOSS and no - // loan is created. - BEAST_EXPECT(legacy.ter == tecPRECISION_LOSS); - BEAST_EXPECT(!legacy.created); - - // With the amendment the stable series expansion produces a valid loan - // at loanScale -10. - BEAST_EXPECT(fixed.ter == tesSUCCESS); - BEAST_EXPECT(fixed.created); - BEAST_EXPECT(fixed.loanScale == -10); - BEAST_EXPECT(fixed.principal == principalRequested); - BEAST_EXPECT((fixed.totalValue == Number{10000000001903, -10})); - BEAST_EXPECT(fixed.managementFee == beast::kZero); - - // Periodic payment from the numerically-stable series expansion, and the - // scheduled total (2 * periodicPayment) which exceeds the 1000 principal - // — no economic underpayment / yield theft. - BEAST_EXPECT((fixed.periodicPayment == Number{5000000000951293762, -16})); - BEAST_EXPECT((fixed.periodicPayment * 2 == Number{1000000000190258752, -15})); - BEAST_EXPECT(fixed.periodicPayment * 2 > principalRequested); - } - - // An overpayment whose residual amount has more precision than loanScale - // fires the isRounded(asset, overpayment, loanScale) assertion in - // computeOverpaymentComponents (and a downstream "interest paid agrees" - // assertion in doOverpayment). fixCleanup3_2_0 rounds the residual down - // to loanScale before passing it in. The pre-amendment path can't be - // tested here because the assertion fires in Debug builds and aborts - // the test process — see the PR description for context. - void - testBugOverpayUnroundedAmount() - { - testcase("bug: computeOverpaymentComponents isRounded assertion"); - - using namespace jtx; - using namespace loan; - Env env(*this, all_); - - Account const issuer{"issuer"}; - Account const lender{"vaultOwner"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env(fset(issuer, asfDefaultRipple)); - env.close(); - - PrettyAsset const iouAsset = issuer["USD"]; - STAmount const iouLimit{iouAsset.raw(), Number{9'999'999'999'999'999LL}}; - env(trust(lender, iouLimit)); - env(trust(borrower, iouLimit)); - env(pay(issuer, lender, iouAsset(1'000'000))); - env(pay(issuer, borrower, iouAsset(1'000'000))); - env.close(); - - auto const broker = createVaultAndBroker( - env, - iouAsset, - lender, - {.vaultDeposit = 100'000, - .debtMax = 5000, - .managementFeeRate = TenthBips16{1000}, - .vaultScale = 1}); - - auto const sleBroker = env.le(broker.brokerKeylet()); - if (!BEAST_EXPECT(sleBroker)) - return; - auto const loanSequence = sleBroker->at(sfLoanSequence); - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); - - using namespace loan; - env(set(borrower, broker.brokerID, Number{1000}, tfLoanOverpayment), - Sig(sfCounterpartySignature, lender), - kInterestRate(TenthBips32{10000}), - kPaymentTotal(12), - kPaymentInterval(60), - kGracePeriod(60), - kOverpaymentFee(TenthBips32{1000}), - kOverpaymentInterestRate(TenthBips32{1000}), - Fee(env.current()->fees().base * 2), - Ter(tesSUCCESS)); - env.close(); - - // periodic * 1.5 at 15-sig-digit precision: 125.000154585042. This - // has too many digits to round cleanly to loanScale=-10, so the - // overpayment residual fails the isRounded check. - STAmount const payAmount{iouAsset.raw(), Number{125'000'154'585'042LL, -12}}; - env(pay(borrower, loanKeylet.key, payAmount), Txflags(tfLoanOverpayment), Ter(tesSUCCESS)); - env.close(); - } - - // Regression for the dual-rounding fix at coarse (integer-MPT) scale. - // - // Loan: P=1, r=50% (50000 tenth-bips), n=3, yearly interval. The - // amortization schedule produces a fractional principal - // (~0.47) which under round-to-nearest collapses to 0 in a single - // step, causing `doPayment`'s strict `>` assertion on principal to - // fire mid-loan. With fixCleanup3_2_0 enabled, principal is rounded - // upward (sticks at 1 across the first two periods) and only clears - // in the final payment. - // - // The test pays one period at a time across three LoanPay - // transactions and verifies the loan completes (paymentRemaining=0) - // with totals matching the loan's economics (1 principal + 2 interest). - void - testIntegerScalePrincipalSticks(FeatureBitset features) - { - // Without fixCleanup3_2_0, this behavior will abort the server, so - // don't run without it. - if (!features[fixCleanup3_2_0]) - return; - - testcase("edge: integer MPT principal stuck mid-loan completes via final"); - - using namespace jtx; - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(100'000), issuer, lender, borrower); - env.close(); - - MPTTester mptt{env, issuer, kMptInitNoFund}; - mptt.create({.maxAmt = 100'000, .flags = tfMPTCanTransfer}); - PrettyAsset const asset{mptt.issuanceID()}; - - mptt.authorize({.account = lender}); - mptt.authorize({.account = borrower}); - - env(pay(issuer, lender, asset(10'000))); - env(pay(issuer, borrower, asset(10'000))); - env.close(); - - Vault const vault{env}; - auto [vaultTx, vaultKeylet] = vault.create({.owner = lender, .asset = asset}); - env(vaultTx); - env.close(); - - env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = asset(5'000)})); - env.close(); - - auto const brokerKeylet = keylet::loanBroker(lender.id(), env.seq(lender)); - env(loan_broker::set(lender, vaultKeylet.key), - loan_broker::kDebtMaximum(Number{100}), - Fee(env.current()->fees().base * 2)); - env.close(); - - auto const brokerStateBefore = env.le(brokerKeylet); - if (!BEAST_EXPECT(brokerStateBefore)) - return; - auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - auto const loanKeylet = keylet::loan(brokerKeylet.key, loanSequence); - - env(loan::set(borrower, brokerKeylet.key, Number{1}), - Sig(sfCounterpartySignature, lender), - loan::kInterestRate(TenthBips32{50'000}), - loan::kPaymentTotal(3), - loan::kPaymentInterval(31'536'000), - Fee(env.current()->fees().base * 2)); - env.close(); - - auto const borrowerStart = env.balance(borrower, asset).value(); - - // Three separate periodic payments of 1 each. Expected per-period - // evolution at integer MPT scale (TVO = PO + interestDue + - // managementFeeDue): - // start: PO=1, TVO=3, paymentRemaining=3 - // after pay #1: PO=1, TVO=2, paymentRemaining=2 (principal sticks) - // after pay #2: PO=1, TVO=1, paymentRemaining=1 (principal sticks) - // after pay #3: PO=0, TVO=0, paymentRemaining=0 (final clears) - std::array const expectedPO{Number{1}, Number{1}, Number{0}}; - std::array const expectedTVO{Number{2}, Number{1}, Number{0}}; - std::array const expectedRemaining{2, 1, 0}; - - for (int i = 0; i < 3; ++i) - { - env(loan::pay(borrower, loanKeylet.key, asset(1)), Ter(tesSUCCESS)); - env.close(); - - auto const sle = env.le(loanKeylet); - if (!BEAST_EXPECT(sle)) - return; - BEAST_EXPECT(sle->at(sfPrincipalOutstanding) == expectedPO[i]); - BEAST_EXPECT(sle->at(sfTotalValueOutstanding) == expectedTVO[i]); - BEAST_EXPECT(sle->at(sfPaymentRemaining) == expectedRemaining[i]); - } - - // Borrower paid 3 total regardless of fee split (1 principal + 2 - // interest+fee, matching loan economics). - auto const borrowerEnd = env.balance(borrower, asset).value(); - BEAST_EXPECT(borrowerStart - borrowerEnd == asset(3).value()); - } - - // A near-zero interest rate on a 100 USD loan - // produces total interest of ~6 units at loanScale -9. Numerical error - // in the amortization formula pushes the theoretical principal above - // the theoretical value, producing a negative theoretical interest. - // The payment delta then exceeds the actual outstanding interest, - // violating XRPL_ASSERT_PARTS in computePaymentComponents. - void - testBugInterestDueDeltaCrash() - { - testcase("bug: LoanPay asserts 'interest due delta' on near-zero rate"); - - using namespace jtx; - using namespace std::chrono_literals; - Env env(*this, all_); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env.close(); - env(fset(issuer, asfDefaultRipple)); - env.close(); - - PrettyAsset const iouAsset = issuer["USD"]; - env(trust(lender, iouAsset(1'000'000'000))); - env(trust(borrower, iouAsset(1'000'000'000))); - env(pay(issuer, lender, iouAsset(5'000'000))); - env(pay(issuer, borrower, iouAsset(5'000'000))); - env.close(); - - BrokerParameters const brokerParams{ - .vaultDeposit = 1'000'000, - .debtMax = 1'000'000, - .coverRateMin = TenthBips32{0}, - .coverDeposit = 0, - .managementFeeRate = TenthBips16{0}, - .coverRateLiquidation = TenthBips32{0}}; - - BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender, brokerParams)}; - - using namespace loan; - - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest{100}; - - auto createJson = env.json( - set(borrower, broker.brokerID, principalRequest), - Fee(loanSetFee), - Json(sfCounterpartySignature, json::ValueType::Object)); - - createJson["InterestRate"] = 1; // minimum non-zero rate - createJson["PaymentTotal"] = 3; - createJson["PaymentInterval"] = 600; - - auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID)); - auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - auto const keylet = keylet::loan(broker.brokerID, loanSequence); - - createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); - env(createJson, Ter(tesSUCCESS)); - env.close(); - - // For principal=100, n=3 the amortization schedule produces a - // periodic payment ≈ 33.33 USD. We pay 35 USD, which is more than - // one period's worth — enough for the LoanPay path to enter - // computePaymentComponents and reach the assertion that fires - // when the bug is present. With the fix, the tx applies cleanly. - env(pay(borrower, keylet.key, iouAsset(35)), Ter(tesSUCCESS)); - env.close(); - } - - // Integration test: full lifecycle of a $1B loan in the bug regime. - // Verifies that the vault collects the economically-correct interest - // income and that conservation holds at the trust-line level. - // - // Pre-fix (closed-form `power(1+r, n) - 1`): vault collected only - // ~$0.058 per $1B due to cancellation of `(1+r)^n - 1` at r*n ~ 5.7e-10. - // Post-fix (hybrid binomial path): vault collects ~$0.38 per $1B, - // matching the value computed independently with arbitrary-precision - // Decimal arithmetic. - void - testFullLifecycleVaultPnLNearZeroRate() - { - testcase("integration: full loan lifecycle, vault interest at near-zero rate"); - - using namespace jtx; - using namespace jtx::loan; - using namespace std::chrono_literals; - Env env(*this, all_); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000), issuer, lender, borrower); - env.close(); - env(fset(issuer, asfDefaultRipple)); - env.close(); - - PrettyAsset const iouAsset = issuer["USD"]; - STAmount const trustLimit{iouAsset.raw(), Number{1, 17}}; - env(trust(lender, trustLimit)); - env(trust(borrower, trustLimit)); - env.close(); - env(pay(issuer, lender, iouAsset(5'000'000'000LL))); - env(pay(issuer, borrower, iouAsset(5'000'000'000LL))); - env.close(); - - auto usdBalance = [&](Account const& a) { - return env.balance(a, iouAsset.raw().get()).value(); - }; - STAmount const borrowerStartBal = usdBalance(borrower); - - BrokerParameters const brokerParams{ - .vaultDeposit = Number{2, 9}, - .debtMax = Number{0}, - .coverRateMin = TenthBips32{0}, - .coverDeposit = 0, - .managementFeeRate = TenthBips16{0}, - .coverRateLiquidation = TenthBips32{0}}; - BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender, brokerParams)}; - - auto const vaultBefore = env.le(broker.vaultKeylet()); - BEAST_EXPECT(vaultBefore); - Number const vaultAvailableBefore = vaultBefore->at(sfAssetsAvailable); - - // Loan: $1B principal, 3 payments, 600s interval, rate=1 TenthBips32. - auto const loanSetFee = Fee(env.current()->fees().base * 2); - Number const principalRequest{1, 9}; - auto createJson = env.json( - set(borrower, broker.brokerID, principalRequest), - Fee(loanSetFee), - Json(sfCounterpartySignature, json::ValueType::Object)); - createJson["InterestRate"] = 1; - createJson["PaymentTotal"] = 3; - createJson["PaymentInterval"] = 600; - - auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID)); - auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); - createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); - env(createJson, Ter(tesSUCCESS)); - env.close(); - - auto const loanSle = env.le(loanKeylet); - BEAST_EXPECT(loanSle); - Number const expectedTotalInterest = - loanSle->at(sfTotalValueOutstanding) - loanSle->at(sfPrincipalOutstanding); - - env(pay(borrower, loanKeylet.key, iouAsset(1'500'000'000LL)), Ter(tesSUCCESS)); - env.close(); - - auto const vaultAfter = env.le(broker.vaultKeylet()); - Number const vaultAvailableAfter = vaultAfter->at(sfAssetsAvailable); - Number const vaultGain = vaultAvailableAfter - vaultAvailableBefore; - - STAmount const borrowerEndBal = usdBalance(borrower); - STAmount const borrowerNetOut = borrowerStartBal - borrowerEndBal; - - // Self-consistency: vault gained exactly the expected interest - // computed at LoanSet, and the borrower's outflow matches. - BEAST_EXPECT(vaultGain == expectedTotalInterest); - BEAST_EXPECT(Number(borrowerNetOut) == expectedTotalInterest); - - // Mathematical correctness: the total interest for this loan - // configuration is 0.38051750382930729983, calculated - // independently using 50-digit Decimal arithmetic (no - // cancellation possible at that precision). At Number's 19-digit - // mantissa this rounds to 0.38051750382930729 — the literal - // below. The vault's actual gain must agree to within - // sub-microcent precision. - Number const decimalReference{38051750382930729LL, -17}; - Number const tolerance{1, -6}; // 1e-6 USD = sub-microcent - Number const error = abs(vaultGain - decimalReference); - BEAST_EXPECTS( - error < tolerance, - "vault gain " + to_string(vaultGain) + " differs from Decimal reference " + - to_string(decimalReference) + " by " + to_string(error) + " — exceeds tolerance " + - to_string(tolerance)); - } - - // Verify that LoanPay, LoanBrokerCoverWithdraw, and LoanSet all use the - // same vault-scale minimum cover when fixCleanup3_2_0 is enabled. - // Before the amendment, each transactor computed its minimum cover at a - // different precision (loanScale, debtScale, or the raw unrounded - // tenthBipsOfValue), which could lead to inconsistent decisions for the - // same broker state. After the amendment all three use - // minimumBrokerCover at vaultScale. - void - testMinimumBrokerCoverConsistency(FeatureBitset features) - { - using namespace jtx; - using namespace loan; - using namespace loan_broker; - - bool const withAmendment = features[fixCleanup3_2_0]; - - struct Ctx - { - jtx::Account issuer; - jtx::Account lender; - jtx::Account borrower; - jtx::PrettyAsset iou; - BrokerInfo broker; - BrokerParameters brokerParams; - }; - - // Shared setup, parametrized by vaultDeposit (the only varying setup - // field across the three scenarios). Each call runs in its own Env - // so multiple invocations within one scenario cannot interfere. - // The caller is responsible for invoking testcase(...) before the - // first runTest call of each scenario. - auto runTest = [&](Number vaultDeposit, auto&& body) { - Env env(*this, features); - - Account const issuer{"issuer"}; - Account const lender{"lender"}; - Account const borrower{"borrower"}; - - env.fund(XRP(1'000'000'000), issuer, lender, borrower); - env.close(); - - // Enable clawback on the issuer *before* any trust lines exist - // (asfAllowTrustLineClawback requires an empty owner directory). - env(fset(issuer, asfAllowTrustLineClawback)); - env.close(); - - PrettyAsset const iou = issuer[iouCurrency_]; - env(trust(lender, iou(1'000'000'000))); - env(trust(borrower, iou(1'000'000'000))); - env.close(); - env(pay(issuer, lender, iou(100'000'000))); - env(pay(issuer, borrower, iou(100'000'000))); - env.close(); - - // 13.37% — non-round rate produces a messier minimum. - BrokerParameters const brokerParams{ - .vaultDeposit = vaultDeposit, - .debtMax = 0, - .coverRateMin = TenthBips32{13'370}, - .coverDeposit = 5'000, - .managementFeeRate = TenthBips16{500}}; - - BrokerInfo const broker = createVaultAndBroker(env, iou, lender, brokerParams); - - body( - env, - Ctx{.issuer = issuer, - .lender = lender, - .borrower = borrower, - .iou = iou, - .broker = broker, - .brokerParams = brokerParams}); - }; - - // Scenario 1 — LoanPay - // - // Verify that LoanPay's minimum cover check uses vault scale (not - // loan scale). Before the amendment, different loans could produce - // different fee routing decisions for the same broker-level state. - // Small vault deposit => vaultScale = -12. - testcase("LoanPay minimum cover scale consistency"); - { - struct LoanKeylets - { - Keylet tiny; - Keylet big; - }; - - // Create the tiny + big loans and reduce cover via clawback so - // that subsequent LoanPay calls hit the minimum-cover boundary. - // Used by the two pay-and-check sub-tests below so each can run - // in its own Env. - auto setupLoansAndClawback = [&](Env& env, Ctx const& c) -> std::optional { - Asset const asset{c.iou}; - - // Create the TINY loan first (while vaultScale is still - // small). principal 0.01, 0% interest, 1 payment => - // loanScale = vaultScale. - auto const brokerSle1 = env.le(keylet::loanBroker(c.broker.brokerID)); - if (!BEAST_EXPECT(brokerSle1)) - return std::nullopt; - auto const tinyLoanSeq = brokerSle1->at(sfLoanSequence); - auto const tinyLoanKeylet = keylet::loan(c.broker.brokerID, tinyLoanSeq); - - env(set(c.borrower, c.broker.brokerID, Number{1, -2}), - Sig(sfCounterpartySignature, c.lender), - kInterestRate(TenthBips32{0}), - kPaymentTotal(1), - kPaymentInterval(86400 * 365), - Fee(XRP(10))); - env.close(); - - // Create the BIG loan second. 100% annual interest over 20 - // payments pushes totalValueOutstanding high enough that - // loanScale > vaultScale. - auto const brokerSle2 = env.le(keylet::loanBroker(c.broker.brokerID)); - if (!BEAST_EXPECT(brokerSle2)) - return std::nullopt; - auto const bigLoanSeq = brokerSle2->at(sfLoanSequence); - auto const bigLoanKeylet = keylet::loan(c.broker.brokerID, bigLoanSeq); - - env(set(c.borrower, c.broker.brokerID, Number{500}), - Sig(sfCounterpartySignature, c.lender), - kInterestRate(TenthBips32{100'000}), - kPaymentTotal(20), - kPaymentInterval(86400 * 365), - Fee(XRP(10))); - env.close(); - - // The tiny loan's scale is frozen at the vault's pre-big-loan - // scale, so it is strictly smaller than the big loan's. - // After the big loan is created the vault absorbs its value, - // pushing vaultScale up to match bigLoanScale. - auto const tinyLoanSle = env.le(tinyLoanKeylet); - auto const bigLoanSle = env.le(bigLoanKeylet); - auto const vaultSle = env.le(keylet::vault(c.broker.vaultID)); - if (!BEAST_EXPECT(tinyLoanSle) || !BEAST_EXPECT(bigLoanSle) || - !BEAST_EXPECT(vaultSle)) - return std::nullopt; - if (!BEAST_EXPECT(tinyLoanSle->at(sfLoanScale) == -12) || - !BEAST_EXPECT(bigLoanSle->at(sfLoanScale) == -11) || - !BEAST_EXPECT(getAssetsTotalScale(vaultSle) == -11)) - return std::nullopt; - - // Use issuer clawback to reduce cover to the minimum the - // clawback transactor allows. Compute the amount as - // initialCover - expectedCoverAfter so we exercise the exact - // clawback rather than relying on the transactor to clip - // down. - // - // Before the amendment the clawback minimum is the - // *unrounded* tenthBipsOfValue — strictly less than the - // rounded-at-vaultScale minimum LoanPay uses for the big - // loan. After the amendment both clawback and LoanPay use - // the same rounded minimum (via minimumBrokerCover), so - // cover lands exactly at that threshold. - Number const expectedCoverAfter = withAmendment ? Number{1330651855688460000, -15} - : Number{1330651855688458000, -15}; - Number const clawbackAmount = - Number{c.brokerParams.coverDeposit} - expectedCoverAfter; - - env(coverClawback(c.issuer), - kLoanBrokerId(c.broker.brokerID), - kAmount(STAmount{asset, clawbackAmount})); - env.close(); - - auto const brokerSle = env.le(keylet::loanBroker(c.broker.brokerID)); - if (!BEAST_EXPECT(brokerSle) || - !BEAST_EXPECT(brokerSle->at(sfCoverAvailable) == expectedCoverAfter)) - return std::nullopt; - - return LoanKeylets{.tiny = tinyLoanKeylet, .big = bigLoanKeylet}; - }; - - // Pay one loan and report whether the fee went to the broker's - // pseudo account (the fallback when cover < minimum) rather - // than to the owner. - auto feeGoesToPseudo = [&](Env& env, Ctx const& c, Keylet const& loanKeylet) -> bool { - Asset const asset{c.iou}; - auto const brokerSle = env.le(keylet::loanBroker(c.broker.brokerID)); - if (!BEAST_EXPECT(brokerSle)) - return false; - auto const pseudoAcct = Account("pseudo", brokerSle->at(sfAccount)); - auto const pseudoBefore = env.balance(pseudoAcct, c.iou); - - auto const payLoan = env.le(loanKeylet); - if (!BEAST_EXPECT(payLoan)) - return false; - auto const periodicPayment = payLoan->at(sfPeriodicPayment); - auto const serviceFee = payLoan->at(sfLoanServiceFee); - std::int32_t const loanScale = payLoan->at(sfLoanScale); - - auto const payment = roundPeriodicPayment(asset, periodicPayment, loanScale); - auto const payAmt = STAmount{asset, payment + serviceFee}; - - env(loan::pay(c.borrower, loanKeylet.key, payAmt), Fee(XRP(10))); - env.close(); - - auto const pseudoAfter = env.balance(pseudoAcct, c.iou); - return pseudoAfter.number() > pseudoBefore.number(); - }; - - // Pay the BIG loan in its own Env so its outcome cannot affect - // the TINY-loan check. With the fix, LoanPay and clawback use - // the same vaultScale minimum (cover == minAtVaultScale => - // fee to owner). Without the fix, LoanPay uses bigLoanScale=-11, - // rounds up to a larger minimum than what clawback used => - // cover < min => fee to pseudo. - runTest(/*vaultDeposit=*/1'000, [&](Env& env, Ctx const& c) { - auto const loans = setupLoansAndClawback(env, c); - if (!loans) - return; - BEAST_EXPECT(feeGoesToPseudo(env, c, loans->big) == !withAmendment); - }); - - // Pay the TINY loan in its own Env. Fee goes to the owner - // either way: - // - With the fix: LoanPay uses vaultScale=-11 (same as - // clawback) => owner. - // - Without the fix: LoanPay uses tinyLoanScale=-12, rounds - // up at -12 (a no-op) => min == cover => owner. - runTest(/*vaultDeposit=*/1'000, [&](Env& env, Ctx const& c) { - auto const loans = setupLoansAndClawback(env, c); - if (!loans) - return; - BEAST_EXPECT(!feeGoesToPseudo(env, c, loans->tiny)); - }); - } - - // Scenario 2 — LoanBrokerCoverWithdraw - // - // Verify that CoverWithdraw's minimum cover check uses vault scale - // (not scale(debtTotal, asset)). Before the amendment, CoverWithdraw - // used: - // roundToAsset(asset, tenthBipsOfValue(debt, rate), scale(debt, asset)) - // which could disagree with LoanPay's minimum (which used loanScale). - // - // Use a large vault deposit so that vaultScale (from AssetsTotal) is - // strictly larger than debtScale (from DebtTotal). With - // vaultDeposit = 100,000: after the big loan - // AssetsTotal ≈ 109,500 → vaultScale = -10 - // DebtTotal ≈ 10,000 → debtScale = -11 - // The one-order-of-magnitude gap makes roundToAsset at -10 truncate - // more aggressively than at -11, exposing the bug. - testcase("CoverWithdraw minimum cover scale consistency"); - runTest( - /*vaultDeposit=*/100'000, [&](Env& env, Ctx const& c) { - Asset const asset{c.iou}; - - // Create only the big loan to push DebtTotal up to ~10,000 - // while AssetsTotal stays around 109,500 (dominated by the - // large vault deposit). - env(set(c.borrower, c.broker.brokerID, Number{500}), - Sig(sfCounterpartySignature, c.lender), - kInterestRate(TenthBips32{100'000}), - kPaymentTotal(20), - kPaymentInterval(86400 * 365), - Fee(XRP(10))); - env.close(); - - // Read broker state and compute both old and new minimums. - auto const brokerSle = env.le(keylet::loanBroker(c.broker.brokerID)); - auto const vaultSle = env.le(keylet::vault(c.broker.vaultID)); - if (!BEAST_EXPECT(brokerSle) || !BEAST_EXPECT(vaultSle)) - return; - - auto const coverAvail = brokerSle->at(sfCoverAvailable); - auto const debtTotal = brokerSle->at(sfDebtTotal); - auto const vaultScale = getAssetsTotalScale(vaultSle); - auto const debtScale = scale(debtTotal, asset); - - // Sanity: debt scale differs from vault scale for this setup. - BEAST_EXPECT(debtScale < vaultScale); - - auto const oldMin = [&]() { - NumberRoundModeGuard const mg(Number::RoundingMode::Upward); - return roundToAsset( - asset, - tenthBipsOfValue(debtTotal, TenthBips32{c.brokerParams.coverRateMin}), - debtScale); - }(); - auto const newMin = minimumBrokerCover( - debtTotal, TenthBips32{c.brokerParams.coverRateMin}, vaultSle); - - // The new (vaultScale) minimum must be strictly larger than - // the old (debtScale) minimum — that is the gap the amendment - // closes. - Number const expectedNewMin{1330650518688500000, -15}; - Number const expectedOldMin{1330650518688472000, -15}; - BEAST_EXPECT(newMin == expectedNewMin); - BEAST_EXPECT(oldMin == expectedOldMin); - - // Try to withdraw so that remaining cover lands between the - // two minimums: oldMin < target < newMin. - auto const target = oldMin + (newMin - oldMin) / 2; - auto const withdrawAmount = STAmount{asset, coverAvail - target}; - - if (withAmendment) - { - // CoverWithdraw now uses vaultScale: target < newMin - // => FAILS. - env(coverWithdraw(c.lender, c.broker.brokerID, withdrawAmount), - Ter(tecINSUFFICIENT_FUNDS)); - } - else - { - // Old CoverWithdraw uses debtScale: target > oldMin - // => SUCCEEDS. - env(coverWithdraw(c.lender, c.broker.brokerID, withdrawAmount)); - } - env.close(); - }); - - // Scenario 3 — LoanSet - // - // Verify that LoanSet's minimum cover check uses vault scale (not the - // raw unrounded tenthBipsOfValue). Before the amendment, LoanSet - // used tenthBipsOfValue(newDebtTotal, coverRateMinimum) (no - // roundToAsset), while clawback/withdraw used different formulas. - // After the amendment all use minimumBrokerCover at vaultScale, and - // rounding at a coarser scale can absorb a tiny debt increase — - // allowing a loan that would otherwise be rejected. - testcase("LoanSet minimum cover scale consistency"); - runTest( - /*vaultDeposit=*/1'000, [&](Env& env, Ctx const& c) { - // Create the tiny loan (scale -12) AND the big loan (scale - // -11). Both loans are needed so that DebtTotal has a full - // 16-digit mantissa — a "messy" value where roundToAsset at - // vaultScale actually truncates digits and produces a - // different result from the raw tenthBipsOfValue. With only - // the big loan, DebtTotal has ~4 significant digits and - // rounding at scale -11 is a no-op, masking the amendment's - // effect. - env(set(c.borrower, c.broker.brokerID, Number{1, -2}), - Sig(sfCounterpartySignature, c.lender), - kInterestRate(TenthBips32{0}), - kPaymentTotal(1), - kPaymentInterval(86400 * 365), - Fee(XRP(10))); - env.close(); - - env(set(c.borrower, c.broker.brokerID, Number{500}), - Sig(sfCounterpartySignature, c.lender), - kInterestRate(TenthBips32{100'000}), - kPaymentTotal(20), - kPaymentInterval(86400 * 365), - Fee(XRP(10))); - env.close(); - - // Clawback to reduce cover to the clawback transactor's - // minimum. Pass the exact amount rather than relying on the - // transactor to clip down; the setup matches Scenario 1 so - // the same residual-cover values apply. - Number const expectedCoverAfter = withAmendment ? Number{1330651855688460000, -15} - : Number{1330651855688458000, -15}; - Number const clawbackAmount = - Number{c.brokerParams.coverDeposit} - expectedCoverAfter; - env(coverClawback(c.issuer), - kLoanBrokerId(c.broker.brokerID), - kAmount(c.iou(clawbackAmount))); - env.close(); - - // Verify scales. - auto const vaultSle = env.le(keylet::vault(c.broker.vaultID)); - if (!BEAST_EXPECT(vaultSle)) - return; - auto const vaultScale = getAssetsTotalScale(vaultSle); - BEAST_EXPECT(vaultScale == -11); - - // Now try to create a tiny additional loan. Principal is - // 1e-11 (the smallest value that survives the precision - // check at loanScale = vaultScale = -11), with 0% interest - // and 1 payment. - // - // The tiny debt increase adds ~1.337e-12 to the unrounded - // minimum. - // - Without the amendment: the old LoanSet formula rounds - // up during tenthBipsOfValue (16-digit Number - // normalisation), pushing the minimum past the cover left - // by clawback => tecINSUFFICIENT_FUNDS. - // - With the amendment: minimumBrokerCover rounds at - // vaultScale=-11, which absorbs the tiny increase — the - // rounded minimum stays the same => tesSUCCESS. - auto const tinyPrincipal = Number{1, -11}; - - if (withAmendment) - { - env(set(c.borrower, c.broker.brokerID, tinyPrincipal), - Sig(sfCounterpartySignature, c.lender), - kInterestRate(TenthBips32{0}), - kPaymentTotal(1), - kPaymentInterval(86400 * 365), - Fee(XRP(10))); - } - else - { - env(set(c.borrower, c.broker.brokerID, tinyPrincipal), - Sig(sfCounterpartySignature, c.lender), - kInterestRate(TenthBips32{0}), - kPaymentTotal(1), - kPaymentInterval(86400 * 365), - Fee(XRP(10)), - Ter(tecINSUFFICIENT_FUNDS)); - } - env.close(); - }); - } - - // LendingProtocolV1_1 ("cash-basis" accounting) dedicated coverage. - // - // Existing tests never enable featureLendingProtocolV1_1 (see `all_` - // above), so these are the only tests in this file that exercise the - // amendment. They are called once, directly, from - // runAmendmentIndependent() -- not looped through - // runAmendmentSensitive()/amendmentCombinations(), since doing so would - // require re-deriving whole-life-specific expected values for ~15 - // unrelated regression tests. - - // 1. LoanSet origination: Vault.AssetsTotal/LoanBroker.DebtTotal deltas, - // and the AssetsMaximum/DebtMaximum guards (which always check against - // principal + interestDue, regardless of the amendment). - void - testCashBasisLoanSetOrigination() - { - testcase("cash-basis: LoanSet origination"); - - using namespace jtx; - using namespace loan; - - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - BrokerParameters const brokerParams{ - .vaultDeposit = 100'000, - .debtMax = 0, - .coverRateMin = TenthBips32{0}, - .coverDeposit = 0, - .managementFeeRate = TenthBips16{0}, - .coverRateLiquidation = TenthBips32{0}}; - - Number const principalRequest{10'000}; - TenthBips32 const interestRate{percentageToTenthBips(10)}; - std::uint32_t const paymentTotal = 2; - std::uint32_t const paymentInterval = 86400; - - // Creates a broker/vault, submits a single LoanSet with a nonzero - // interest rate, and returns the observed Vault.AssetsTotal / - // LoanBroker.DebtTotal deltas plus the loan's own computed - // interestDue and principalOutstanding. - auto runOrigination = [&](FeatureBitset features) { - Env env(*this, features); - - Account const lender{"lender"}; - Account const borrower{"borrower"}; - env.fund(XRP(1'000'000), lender, borrower); - env.close(); - - BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; - - auto const vaultBefore = env.le(broker.vaultKeylet()); - auto const brokerBefore = env.le(broker.brokerKeylet()); - BEAST_EXPECT(vaultBefore && brokerBefore); - Number const assetsTotalBefore = vaultBefore->at(sfAssetsTotal); - Number const debtTotalBefore = brokerBefore->at(sfDebtTotal); - - auto const loanSequence = brokerBefore->at(sfLoanSequence); - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); - - env(set(borrower, broker.brokerID, xrpAsset(principalRequest).value()), - kCounterparty(lender), - kInterestRate(interestRate), - kPaymentTotal(paymentTotal), - kPaymentInterval(paymentInterval), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 2), - Ter(tesSUCCESS)); - env.close(); - - auto const loanSle = env.le(loanKeylet); - BEAST_EXPECT(loanSle); - Number const principalOutstanding = loanSle->at(sfPrincipalOutstanding); - Number const totalValueOutstanding = loanSle->at(sfTotalValueOutstanding); - Number const interestDue = totalValueOutstanding - principalOutstanding; - BEAST_EXPECT(interestDue > beast::kZero); - BEAST_EXPECT(principalOutstanding == xrpAsset(principalRequest).value()); - - auto const vaultAfter = env.le(broker.vaultKeylet()); - auto const brokerAfter = env.le(broker.brokerKeylet()); - BEAST_EXPECT(vaultAfter && brokerAfter); - Number const assetsTotalDelta = - Number(vaultAfter->at(sfAssetsTotal)) - assetsTotalBefore; - Number const debtTotalDelta = Number(brokerAfter->at(sfDebtTotal)) - debtTotalBefore; - - return std::make_tuple( - assetsTotalDelta, debtTotalDelta, interestDue, principalOutstanding); - }; - - Number interestDueCash{}; - Number principalOutstandingCash{}; - { - auto const [assetsTotalDelta, debtTotalDelta, interestDue, principalOutstanding] = - runOrigination(all_ | featureLendingProtocolV1_1); - interestDueCash = interestDue; - principalOutstandingCash = principalOutstanding; - - BEAST_EXPECTS( - assetsTotalDelta == beast::kZero, - "cash-basis origination must not change AssetsTotal; delta=" + - to_string(assetsTotalDelta)); - BEAST_EXPECTS( - debtTotalDelta == principalOutstanding, - "cash-basis origination must add principal-only to DebtTotal; delta=" + - to_string(debtTotalDelta) + " principal=" + to_string(principalOutstanding)); - } - - { - auto const [assetsTotalDelta, debtTotalDelta, interestDue, principalOutstanding] = - runOrigination(all_); - - BEAST_EXPECTS( - assetsTotalDelta == interestDue, - "whole-life origination must add interestDue to AssetsTotal; delta=" + - to_string(assetsTotalDelta) + " interestDue=" + to_string(interestDue)); - BEAST_EXPECTS( - debtTotalDelta == principalOutstanding + interestDue, - "whole-life origination must add principal+interest to DebtTotal; delta=" + - to_string(debtTotalDelta)); - } - - // AssetsMaximum guard checks interestDue headroom only under - // whole-life accounting; DebtMaximum guard also varies by model. - auto runVaultGuard = [&](FeatureBitset features, Number const& slack, TER expected) { - Env env(*this, features); - - Account const lender{"lender"}; - Account const borrower{"borrower"}; - env.fund(XRP(1'000'000), lender, borrower); - env.close(); - - BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; - - auto const vaultSle = env.le(broker.vaultKeylet()); - BEAST_EXPECT(vaultSle); - Number const assetsTotalBefore = vaultSle->at(sfAssetsTotal); - - Vault const vault{env}; - auto tx = vault.set({.owner = lender, .id = broker.vaultID}); - tx[sfAssetsMaximum] = assetsTotalBefore + slack; - env(tx); - env.close(); - - env(set(borrower, broker.brokerID, xrpAsset(principalRequest).value()), - kCounterparty(lender), - kInterestRate(interestRate), - kPaymentTotal(paymentTotal), - kPaymentInterval(paymentInterval), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 2), - Ter(expected)); - env.close(); - }; - - auto runBrokerGuard = [&](FeatureBitset features, Number const& debtMaximum, TER expected) { - Env env(*this, features); - - Account const lender{"lender"}; - Account const borrower{"borrower"}; - env.fund(XRP(1'000'000), lender, borrower); - env.close(); - - BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; - - env(loan_broker::set(lender, broker.vaultID), - loan_broker::kLoanBrokerId(broker.brokerID), - loan_broker::kDebtMaximum(debtMaximum), - Fee(env.current()->fees().base * 2)); - env.close(); - - env(set(borrower, broker.brokerID, xrpAsset(principalRequest).value()), - kCounterparty(lender), - kInterestRate(interestRate), - kPaymentTotal(paymentTotal), - kPaymentInterval(paymentInterval), - Sig(sfCounterpartySignature, lender), - Fee(env.current()->fees().base * 2), - Ter(expected)); - env.close(); - }; - - Number const oneDrop = xrpAsset(1).value(); - { - testcase("whole-life: LoanSet AssetsMaximum guard checks interestDue headroom"); - // Guard rejects when there's not quite enough headroom for the - // interest. - runVaultGuard(all_, interestDueCash - oneDrop, tecLIMIT_EXCEEDED); - // Guard accepts at the exact boundary. - runVaultGuard(all_, interestDueCash, tesSUCCESS); - } - - { - testcase("cash-basis: LoanSet AssetsMaximum guard ignores interestDue headroom"); - // Even far less headroom than interestDue still succeeds, since - // cash-basis origination never adds interest to AssetsTotal. - runVaultGuard(all_ | featureLendingProtocolV1_1, oneDrop, tesSUCCESS); - } - - // DebtMaximum guard: cash-basis projects principal-only DebtTotal; - // whole-life projects principal + interestDue. - for (auto const cashBasis : {true, false}) - { - testcase( - std::string("LoanSet DebtMaximum guard (") + - (cashBasis ? "cash-basis)" : "whole-life)")); - auto const features = cashBasis ? all_ | featureLendingProtocolV1_1 : all_; - Number const newDebtTotal = - principalOutstandingCash + (cashBasis ? Number{} : interestDueCash); - runBrokerGuard(features, newDebtTotal - oneDrop, tecLIMIT_EXCEEDED); - runBrokerGuard(features, newDebtTotal, tesSUCCESS); - } - } - - // 2. LoanPay: regular, late, overpayment, and full-payment types. - // Assert Vault.AssetsTotal/LoanBroker.DebtTotal deltas match - // interestPaid/principalPaid under cash-basis, and cross-check the - // amendment-disabled run's deltas against the documented whole-life - // formula (AssetsTotal += valueChange; DebtTotal mirrors the loan's own - // TotalValueOutstanding delta exactly, since whole-life debt recognition - // tracks total loan value). - void - testCashBasisLoanPay() - { - using namespace jtx; - using namespace loan; - using namespace std::chrono_literals; - using tp = NetClock::time_point; - - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - BrokerParameters const brokerParams{ - .vaultDeposit = 1'000'000, - .debtMax = 0, - .coverRateMin = TenthBips32{0}, - .coverDeposit = 0, - .managementFeeRate = TenthBips16{0}, - .coverRateLiquidation = TenthBips32{0}}; - - Number const principalRequest{12'000}; - TenthBips32 const interestRate{percentageToTenthBips(12)}; - std::uint32_t const paymentTotal = 4; - std::uint32_t const paymentInterval = 600; - std::uint32_t const gracePeriod = 300; - - struct PaymentDeltas - { - Number principalPaid; - Number assetsTotalDelta; - Number debtTotalDelta; - Number totalValueDelta; - }; - - // Sets up a fresh broker + loan, advances time, submits a single - // payment of the given type/amount, and returns the observed deltas. - auto runPayment = [&](FeatureBitset features, - std::uint32_t loanSetFlags, - std::uint32_t payFlags, - std::function const& advanceTime, - std::function const& paymentAmount) { - Env env(*this, features); - - Account const lender{"lender"}; - Account const borrower{"borrower"}; - env.fund(XRP(10'000'000), lender, borrower); - env.close(); - - BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; - - LoanParameters const loanParams{ - .account = borrower, - .counter = lender, - .principalRequest = principalRequest, - .interest = interestRate, - .payTotal = paymentTotal, - .payInterval = paymentInterval, - .gracePd = gracePeriod, - .flags = loanSetFlags, - }; - - auto const brokerBeforeLoan = env.le(broker.brokerKeylet()); - BEAST_EXPECT(brokerBeforeLoan); - auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence); - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); - - env(loanParams(env, broker)); - env.close(); - - LoanState const state = getCurrentState(env, broker, loanKeylet); - - advanceTime(env, state.startDate); - - auto const vaultBefore = env.le(broker.vaultKeylet()); - auto const brokerBefore = env.le(broker.brokerKeylet()); - auto const loanBefore = env.le(loanKeylet); - BEAST_EXPECT(vaultBefore && brokerBefore && loanBefore); - - Number const principalBefore = loanBefore->at(sfPrincipalOutstanding); - Number const totalValueBefore = loanBefore->at(sfTotalValueOutstanding); - Number const assetsTotalBefore = vaultBefore->at(sfAssetsTotal); - Number const debtTotalBefore = brokerBefore->at(sfDebtTotal); - - STAmount const amount = paymentAmount(state); - env(pay(borrower, loanKeylet.key, amount, payFlags), Ter(tesSUCCESS)); - env.close(); - - auto const vaultAfter = env.le(broker.vaultKeylet()); - auto const brokerAfter = env.le(broker.brokerKeylet()); - auto const loanAfter = env.le(loanKeylet); - BEAST_EXPECT(vaultAfter && brokerAfter && loanAfter); - - Number const principalAfter = loanAfter->at(sfPrincipalOutstanding); - Number const totalValueAfter = loanAfter->at(sfTotalValueOutstanding); - Number const assetsTotalAfter = vaultAfter->at(sfAssetsTotal); - Number const debtTotalAfter = brokerAfter->at(sfDebtTotal); - - return PaymentDeltas{ - .principalPaid = principalBefore - principalAfter, - .assetsTotalDelta = assetsTotalAfter - assetsTotalBefore, - .debtTotalDelta = debtTotalAfter - debtTotalBefore, - .totalValueDelta = totalValueAfter - totalValueBefore}; - }; - - // Compares the disabled (whole-life) and enabled (cash-basis) runs - // of the same payment scenario, and asserts the documented - // relationships between them. - auto checkScenario = [&](std::string const& label, - PaymentDeltas const& off, - PaymentDeltas const& on) { - testcase("cash-basis: LoanPay " + label); - - // The loan's own PrincipalOutstanding field is untouched by - // the amendment. - BEAST_EXPECTS( - off.principalPaid == on.principalPaid, - "principalPaid must be amendment-independent; off=" + to_string(off.principalPaid) + - " on=" + to_string(on.principalPaid)); - - // Whole-life structural invariant: DebtTotal (which - // recognizes a loan's full remaining value as debt) must - // change exactly as the loan's own TotalValueOutstanding - // does. - BEAST_EXPECTS( - off.debtTotalDelta == off.totalValueDelta, - "whole-life DebtTotal delta must mirror TotalValueOutstanding delta; " - "debtTotalDelta=" + - to_string(off.debtTotalDelta) + - " totalValueDelta=" + to_string(off.totalValueDelta)); - - // Derive interestPaid from the whole-life run's independent - // ledger deltas: - // assetsTotalDelta_off == valueChange - // debtTotalDelta_off == valueChange - (principalPaid + interestPaid) - // => interestPaid == assetsTotalDelta_off - debtTotalDelta_off - principalPaid - Number const interestPaid = - off.assetsTotalDelta - off.debtTotalDelta - off.principalPaid; - BEAST_EXPECTS( - interestPaid >= beast::kZero, - "derived interestPaid must be non-negative: " + to_string(interestPaid)); - - BEAST_EXPECTS( - on.assetsTotalDelta == interestPaid, - "cash-basis AssetsTotal delta must equal interestPaid; delta=" + - to_string(on.assetsTotalDelta) + " interestPaid=" + to_string(interestPaid)); - BEAST_EXPECTS( - on.debtTotalDelta == -on.principalPaid, - "cash-basis DebtTotal delta must equal -principalPaid; delta=" + - to_string(on.debtTotalDelta) + " principalPaid=" + to_string(on.principalPaid)); - }; - - // ---- Regular, on-time payment ---- - { - auto const noAdvance = [](Env& env, tp const&) { env.close(); }; - auto const regularAmount = [&](LoanState const& state) { - return STAmount{ - xrpAsset, - roundPeriodicPayment(xrpAsset, state.periodicPayment, state.loanScale) * - Number{3, -1} * 5}; // 1.5x, so only a single period is paid - }; - - auto const off = runPayment(all_, 0, 0, noAdvance, regularAmount); - auto const on = - runPayment(all_ | featureLendingProtocolV1_1, 0, 0, noAdvance, regularAmount); - - // Regular, on-time payments never change the loan's value beyond - // normal amortization (production asserts valueChange == 0), so - // AssetsTotal must be unaffected in the whole-life run. - BEAST_EXPECTS( - off.assetsTotalDelta == beast::kZero, - "regular on-time payment must not change AssetsTotal under whole-life; delta=" + - to_string(off.assetsTotalDelta)); - - checkScenario("regular payment", off, on); - } - - // ---- Late payment ---- - { - auto const advancePastDue = [&](Env& env, tp const& startDate) { - env.close(startDate + std::chrono::seconds(paymentInterval + 1)); - }; - auto const lateAmount = [&](LoanState const& state) { - return STAmount{ - xrpAsset, - roundPeriodicPayment(xrpAsset, state.periodicPayment, state.loanScale) * - Number{3}}; // generous; excess is not withdrawn - }; - - auto const off = runPayment(all_, 0, tfLoanLatePayment, advancePastDue, lateAmount); - auto const on = runPayment( - all_ | featureLendingProtocolV1_1, - 0, - tfLoanLatePayment, - advancePastDue, - lateAmount); - - checkScenario("late payment", off, on); - } - - // ---- Overpayment ---- - { - auto const noAdvance = [](Env& env, tp const&) { env.close(); }; - auto const overpayAmount = [&](LoanState const& state) { - // One regular period, plus a generous extra principal - // paydown. - return STAmount{ - xrpAsset, - roundPeriodicPayment(xrpAsset, state.periodicPayment, state.loanScale) + - xrpAsset(2'000).value()}; - }; - - auto const off = - runPayment(all_, tfLoanOverpayment, tfLoanOverpayment, noAdvance, overpayAmount); - auto const on = runPayment( - all_ | featureLendingProtocolV1_1, - tfLoanOverpayment, - tfLoanOverpayment, - noAdvance, - overpayAmount); - - checkScenario("overpayment", off, on); - } - - // ---- Full payment ---- - { - auto const noAdvance = [](Env& env, tp const&) { env.close(); }; - auto const fullAmount = [&](LoanState const&) { - // Generously large: full payment only ever consumes exactly - // what's due (principal + accrued interest; close fee/ - // prepayment penalty are 0 here), excess is not withdrawn. - return STAmount{xrpAsset, xrpAsset(principalRequest).value() * Number{2}}; - }; - - auto const off = runPayment(all_, 0, tfLoanFullPayment, noAdvance, fullAmount); - auto const on = runPayment( - all_ | featureLendingProtocolV1_1, 0, tfLoanFullPayment, noAdvance, fullAmount); - - checkScenario("full payment", off, on); - } - } - - // 3. LoanManage: impair, unimpair, and default. - void - testCashBasisLoanManage() - { - using namespace jtx; - using namespace loan; - using namespace std::chrono_literals; - - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - BrokerParameters const brokerParams{ - .vaultDeposit = 1'000'000, - .debtMax = 0, - .coverRateMin = TenthBips32{percentageToTenthBips(10)}, - .coverDeposit = 5'000, - .managementFeeRate = TenthBips16{0}, - .coverRateLiquidation = TenthBips32{percentageToTenthBips(25)}}; - - Number const principalRequest{10'000}; - TenthBips32 const interestRate{percentageToTenthBips(12)}; - std::uint32_t const paymentTotal = 4; - std::uint32_t const paymentInterval = 600; - std::uint32_t const gracePeriod = 60; - - auto setupLoan = [&](Env& env) { - Account const lender{"lender"}; - Account const borrower{"borrower"}; - env.fund(XRP(10'000'000), lender, borrower); - env.close(); - - BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; - - LoanParameters const loanParams{ - .account = borrower, - .counter = lender, - .principalRequest = principalRequest, - .interest = interestRate, - .payTotal = paymentTotal, - .payInterval = paymentInterval, - .gracePd = gracePeriod, - }; - - auto const brokerBeforeLoan = env.le(broker.brokerKeylet()); - BEAST_EXPECT(brokerBeforeLoan); - auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence); - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); - - env(loanParams(env, broker)); - env.close(); - - return std::make_tuple(broker, loanKeylet, lender, borrower); - }; - - // ---- impair / unimpair ---- - auto runImpairUnimpair = [&](FeatureBitset features) { - Env env(*this, features); - auto const [broker, loanKeylet, lender, borrower] = setupLoan(env); - - auto const loanBefore = env.le(loanKeylet); - BEAST_EXPECT(loanBefore); - Number const principalOutstanding = loanBefore->at(sfPrincipalOutstanding); - Number const totalValueOutstanding = loanBefore->at(sfTotalValueOutstanding); - Number const managementFeeOutstanding = loanBefore->at(sfManagementFeeOutstanding); - - Number const expectedExposure = - env.current()->rules().enabled(featureLendingProtocolV1_1) - ? principalOutstanding - : totalValueOutstanding - managementFeeOutstanding; - - auto const vaultBeforeImpair = env.le(broker.vaultKeylet()); - BEAST_EXPECT(vaultBeforeImpair); - Number const lossBefore = vaultBeforeImpair->at(sfLossUnrealized); - - env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS)); - env.close(); - - auto const vaultAfterImpair = env.le(broker.vaultKeylet()); - BEAST_EXPECT(vaultAfterImpair); - Number const impairDelta = Number(vaultAfterImpair->at(sfLossUnrealized)) - lossBefore; - - env(manage(lender, loanKeylet.key, tfLoanUnimpair), Ter(tesSUCCESS)); - env.close(); - - auto const vaultAfterUnimpair = env.le(broker.vaultKeylet()); - BEAST_EXPECT(vaultAfterUnimpair); - Number const netDelta = Number(vaultAfterUnimpair->at(sfLossUnrealized)) - lossBefore; - - return std::make_tuple(expectedExposure, impairDelta, netDelta); - }; - - for (auto const features : {all_ | featureLendingProtocolV1_1, all_}) - { - testcase( - std::string("cash-basis: LoanManage impair/unimpair (") + - (features[featureLendingProtocolV1_1] ? "enabled)" : "disabled)")); - auto const [expectedExposure, impairDelta, netDelta] = runImpairUnimpair(features); - - BEAST_EXPECTS( - impairDelta == expectedExposure, - "impair must add loanVaultExposure to LossUnrealized; delta=" + - to_string(impairDelta) + " expected=" + to_string(expectedExposure)); - BEAST_EXPECTS( - netDelta == beast::kZero, - "unimpair must be an exact reversal of impair; net=" + to_string(netDelta)); - } - - // ---- impair, then default ---- - auto runDefault = [&](FeatureBitset features) { - Env env(*this, features); - auto const [broker, loanKeylet, lender, borrower] = setupLoan(env); - - auto const loanBeforeImpair = env.le(loanKeylet); - BEAST_EXPECT(loanBeforeImpair); - Number const principalOutstanding = loanBeforeImpair->at(sfPrincipalOutstanding); - Number const totalValueOutstanding = loanBeforeImpair->at(sfTotalValueOutstanding); - Number const managementFeeOutstanding = - loanBeforeImpair->at(sfManagementFeeOutstanding); - - Number const expectedExposure = - env.current()->rules().enabled(featureLendingProtocolV1_1) - ? principalOutstanding - : totalValueOutstanding - managementFeeOutstanding; - - env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS)); - env.close(); - - LoanState const state = getCurrentState(env, broker, loanKeylet); - env.close( - state.startDate + std::chrono::seconds(paymentInterval) + - std::chrono::seconds(gracePeriod) + 60s); - - auto const vaultBefore = env.le(broker.vaultKeylet()); - auto const brokerBefore = env.le(broker.brokerKeylet()); - BEAST_EXPECT(vaultBefore && brokerBefore); - Number const assetsTotalBefore = vaultBefore->at(sfAssetsTotal); - Number const debtTotalBefore = brokerBefore->at(sfDebtTotal); - Number const lossBefore = vaultBefore->at(sfLossUnrealized); - Number const coverAvailableBefore = brokerBefore->at(sfCoverAvailable); - - env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); - env.close(); - - auto const vaultAfter = env.le(broker.vaultKeylet()); - auto const brokerAfter = env.le(broker.brokerKeylet()); - BEAST_EXPECT(vaultAfter && brokerAfter); - Number const assetsTotalDelta = - Number(vaultAfter->at(sfAssetsTotal)) - assetsTotalBefore; - Number const debtTotalDelta = Number(brokerAfter->at(sfDebtTotal)) - debtTotalBefore; - Number const lossDelta = Number(vaultAfter->at(sfLossUnrealized)) - lossBefore; - Number const coverAvailableDelta = - Number(brokerAfter->at(sfCoverAvailable)) - coverAvailableBefore; - - Number const defaultCovered = -coverAvailableDelta; - Number const vaultDefaultAmount = expectedExposure - defaultCovered; - - return std::make_tuple( - expectedExposure, assetsTotalDelta, debtTotalDelta, lossDelta, vaultDefaultAmount); - }; - - for (auto const features : {all_ | featureLendingProtocolV1_1, all_}) - { - testcase( - std::string("cash-basis: LoanManage default (") + - (features[featureLendingProtocolV1_1] ? "enabled)" : "disabled)")); - auto const - [expectedExposure, - assetsTotalDelta, - debtTotalDelta, - lossDelta, - vaultDefaultAmount] = runDefault(features); - - BEAST_EXPECTS( - debtTotalDelta == -expectedExposure, - "default must reduce DebtTotal by the unified default amount; delta=" + - to_string(debtTotalDelta) + " expected=" + to_string(expectedExposure)); - BEAST_EXPECTS( - lossDelta == -expectedExposure, - "default must reverse the earlier impair's LossUnrealized exactly; delta=" + - to_string(lossDelta) + " expected=" + to_string(expectedExposure)); - BEAST_EXPECTS( - assetsTotalDelta == -vaultDefaultAmount, - "default must reduce AssetsTotal by (defaultAmount - defaultCovered); delta=" + - to_string(assetsTotalDelta) + " expected=" + to_string(-vaultDefaultAmount)); - } - } - - // 3b. LEVersion regression: a Vault created before featureLendingProtocolV1_1 - // activates (LEVersion absent) must keep whole-life (accrual) accounting - // forever, even after the amendment is later enabled -- the switch is - // per-Vault (LEVersion == VaultVersion::CashBasis), not a single global amendment - // flag. - void - testLegacyVaultKeepsAccrualAfterAmendmentEnabled() - { - testcase("LEVersion: legacy vault keeps accrual after amendment enabled"); - - using namespace jtx; - using namespace loan; - using namespace std::chrono_literals; - - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - BrokerParameters const brokerParams{ - .vaultDeposit = 1'000'000, - .debtMax = 0, - .coverRateMin = TenthBips32{percentageToTenthBips(10)}, - .coverDeposit = 5'000, - .managementFeeRate = TenthBips16{0}, - .coverRateLiquidation = TenthBips32{percentageToTenthBips(25)}}; - - Number const principalRequest{10'000}; - TenthBips32 const interestRate{percentageToTenthBips(12)}; - std::uint32_t const paymentTotal = 4; - std::uint32_t const paymentInterval = 600; - std::uint32_t const gracePeriod = 60; - - // Amendment disabled at Vault creation time: LEVersion stays absent. - Env env(*this, all_); - - Account const lender{"lender"}; - Account const borrower{"borrower"}; - env.fund(XRP(10'000'000), lender, borrower); - env.close(); - - BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; - - { - auto const vaultSle = env.le(broker.vaultKeylet()); - BEAST_EXPECT(vaultSle); - BEAST_EXPECT(!vaultSle->isFieldPresent(sfLEVersion)); - } - - // Now enable the amendment -- production dispatch must still treat - // this specific Vault as accrual-basis, since its LEVersion is - // (and remains) absent. - env.enableFeature(featureLendingProtocolV1_1); - env.close(); - - LoanParameters const loanParams{ - .account = borrower, - .counter = lender, - .principalRequest = principalRequest, - .interest = interestRate, - .payTotal = paymentTotal, - .payInterval = paymentInterval, - .gracePd = gracePeriod, - }; - - auto const brokerBeforeLoan = env.le(broker.brokerKeylet()); - BEAST_EXPECT(brokerBeforeLoan); - auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence); - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); - - // ---- LoanSet origination: whole-life formulas expected ---- - auto const vaultBeforeSet = env.le(broker.vaultKeylet()); - auto const brokerBeforeSet = env.le(broker.brokerKeylet()); - BEAST_EXPECT(vaultBeforeSet && brokerBeforeSet); - Number const assetsTotalBeforeSet = vaultBeforeSet->at(sfAssetsTotal); - Number const debtTotalBeforeSet = brokerBeforeSet->at(sfDebtTotal); - - env(loanParams(env, broker)); - env.close(); - - auto const loanAfterSet = env.le(loanKeylet); - BEAST_EXPECT(loanAfterSet); - Number const principalOutstanding = loanAfterSet->at(sfPrincipalOutstanding); - Number const totalValueOutstanding = loanAfterSet->at(sfTotalValueOutstanding); - Number const interestDue = totalValueOutstanding - principalOutstanding; - BEAST_EXPECT(interestDue > beast::kZero); - - auto const vaultAfterSet = env.le(broker.vaultKeylet()); - auto const brokerAfterSet = env.le(broker.brokerKeylet()); - BEAST_EXPECT(vaultAfterSet && brokerAfterSet); - Number const assetsTotalDeltaSet = - Number(vaultAfterSet->at(sfAssetsTotal)) - assetsTotalBeforeSet; - Number const debtTotalDeltaSet = - Number(brokerAfterSet->at(sfDebtTotal)) - debtTotalBeforeSet; - - BEAST_EXPECTS( - assetsTotalDeltaSet == interestDue, - "legacy vault origination must still add interestDue to AssetsTotal; delta=" + - to_string(assetsTotalDeltaSet) + " interestDue=" + to_string(interestDue)); - BEAST_EXPECTS( - debtTotalDeltaSet == principalOutstanding + interestDue, - "legacy vault origination must still add principal+interest to DebtTotal; delta=" + - to_string(debtTotalDeltaSet)); - - LoanState const state = getCurrentState(env, broker, loanKeylet); - env.close(); - - // ---- LoanPay: whole-life formulas expected ---- - auto const vaultBeforePay = env.le(broker.vaultKeylet()); - auto const brokerBeforePay = env.le(broker.brokerKeylet()); - auto const loanBeforePay = env.le(loanKeylet); - BEAST_EXPECT(vaultBeforePay && brokerBeforePay && loanBeforePay); - Number const totalValueBeforePay = loanBeforePay->at(sfTotalValueOutstanding); - Number const assetsTotalBeforePay = vaultBeforePay->at(sfAssetsTotal); - Number const debtTotalBeforePay = brokerBeforePay->at(sfDebtTotal); - - STAmount const paymentAmount{ - xrpAsset, roundPeriodicPayment(xrpAsset, state.periodicPayment, state.loanScale)}; - env(pay(borrower, loanKeylet.key, paymentAmount), Ter(tesSUCCESS)); - env.close(); - - auto const vaultAfterPay = env.le(broker.vaultKeylet()); - auto const brokerAfterPay = env.le(broker.brokerKeylet()); - auto const loanAfterPay = env.le(loanKeylet); - BEAST_EXPECT(vaultAfterPay && brokerAfterPay && loanAfterPay); - Number const totalValueAfterPay = loanAfterPay->at(sfTotalValueOutstanding); - Number const assetsTotalDeltaPay = - Number(vaultAfterPay->at(sfAssetsTotal)) - assetsTotalBeforePay; - Number const debtTotalDeltaPay = - Number(brokerAfterPay->at(sfDebtTotal)) - debtTotalBeforePay; - Number const totalValueDeltaPay = totalValueAfterPay - totalValueBeforePay; - - // A regular, on-time payment has valueChange == 0, so whole-life - // AssetsTotal is untouched and DebtTotal mirrors TotalValueOutstanding. - BEAST_EXPECTS( - assetsTotalDeltaPay == beast::kZero, - "legacy vault regular payment must not change AssetsTotal; delta=" + - to_string(assetsTotalDeltaPay)); - BEAST_EXPECTS( - debtTotalDeltaPay == totalValueDeltaPay, - "legacy vault DebtTotal delta must mirror TotalValueOutstanding delta; " - "debtTotalDelta=" + - to_string(debtTotalDeltaPay) + " totalValueDelta=" + to_string(totalValueDeltaPay)); - - // ---- LoanManage: impair, then default -- whole-life exposure expected ---- - auto const loanBeforeImpair = env.le(loanKeylet); - BEAST_EXPECT(loanBeforeImpair); - Number const totalValueBeforeImpair = loanBeforeImpair->at(sfTotalValueOutstanding); - Number const managementFeeBeforeImpair = loanBeforeImpair->at(sfManagementFeeOutstanding); - Number const expectedExposure = totalValueBeforeImpair - managementFeeBeforeImpair; - - env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS)); - env.close(); - - LoanState const stateAtImpair = getCurrentState(env, broker, loanKeylet); - env.close( - stateAtImpair.startDate + std::chrono::seconds(paymentInterval) + - std::chrono::seconds(gracePeriod) + 60s); - - auto const vaultBeforeDefault = env.le(broker.vaultKeylet()); - auto const brokerBeforeDefault = env.le(broker.brokerKeylet()); - BEAST_EXPECT(vaultBeforeDefault && brokerBeforeDefault); - Number const debtTotalBeforeDefault = brokerBeforeDefault->at(sfDebtTotal); - Number const lossBeforeDefault = vaultBeforeDefault->at(sfLossUnrealized); - - env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); - env.close(); - - auto const vaultAfterDefault = env.le(broker.vaultKeylet()); - auto const brokerAfterDefault = env.le(broker.brokerKeylet()); - BEAST_EXPECT(vaultAfterDefault && brokerAfterDefault); - Number const debtTotalDeltaDefault = - Number(brokerAfterDefault->at(sfDebtTotal)) - debtTotalBeforeDefault; - Number const lossDeltaDefault = - Number(vaultAfterDefault->at(sfLossUnrealized)) - lossBeforeDefault; - - BEAST_EXPECTS( - debtTotalDeltaDefault == -expectedExposure, - "legacy vault default must reduce DebtTotal by whole-life exposure; delta=" + - to_string(debtTotalDeltaDefault) + " expected=" + to_string(expectedExposure)); - BEAST_EXPECTS( - lossDeltaDefault == -expectedExposure, - "legacy vault default must reverse the earlier impair's LossUnrealized exactly; " - "delta=" + - to_string(lossDeltaDefault) + " expected=" + to_string(expectedExposure)); - - // Confirm the Vault's LEVersion truly never got set, throughout. - { - auto const vaultSle = env.le(broker.vaultKeylet()); - BEAST_EXPECT(vaultSle); - BEAST_EXPECT(!vaultSle->isFieldPresent(sfLEVersion)); - BEAST_EXPECT(getVaultVersion(vaultSle) == VaultVersion::Legacy); - } - } - - // 4. End-to-end trajectory: LoanSet -> 2 LoanPays -> LoanManage(default), - // entirely under the amendment, with independently hand-computed - // expected AssetsTotal/DebtTotal/LossUnrealized/CoverAvailable values at - // each step. 0% interest keeps the arithmetic exact and tractable; the - // divergence from whole-life accounting is already covered directly by - // testCashBasisLoanSetOrigination/LoanPay/LoanManage above, so this test - // focuses purely on an independent, from-scratch trajectory check. - void - testCashBasisEndToEndTrajectory() - { - testcase("cash-basis: end-to-end trajectory"); - - using namespace jtx; - using namespace loan; - using namespace std::chrono_literals; - - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - BrokerParameters const brokerParams{ - .vaultDeposit = 100'000, .managementFeeRate = TenthBips16{0}}; - - Env env(*this, all_ | featureLendingProtocolV1_1); - - Account const lender{"lender"}; - Account const borrower{"borrower"}; - env.fund(XRP(10'000'000), lender, borrower); - env.close(); - - BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; - - // Hand computation (all values in XRP, drops == 1e-6 XRP): - // Vault: AssetsTotal starts at 100'000 (the deposit). - // Broker: DebtTotal starts at 0, CoverAvailable starts at 1'000 - // (BrokerParameters::defaults().coverDeposit). - auto const vaultKeylet = broker.vaultKeylet(); - auto const brokerKeylet = broker.brokerKeylet(); - - // All the "human XRP unit" constants below (e.g. `100'000`) are - // converted to raw native (drops) values via xrpAsset(...), since - // that's how the ledger fields are actually denominated. - auto const checkVaultBroker = [&](Number const& assetsTotalUnits, - Number const& debtTotalUnits, - Number const& lossUnrealizedUnits, - Number const& coverAvailableUnits, - char const* step) { - Number const assetsTotal = xrpAsset(assetsTotalUnits).value(); - Number const debtTotal = xrpAsset(debtTotalUnits).value(); - Number const lossUnrealized = xrpAsset(lossUnrealizedUnits).value(); - Number const coverAvailable = xrpAsset(coverAvailableUnits).value(); - - auto const vaultSle = env.le(vaultKeylet); - auto const brokerSle = env.le(brokerKeylet); - BEAST_EXPECT(vaultSle && brokerSle); - BEAST_EXPECTS( - vaultSle->at(sfAssetsTotal) == assetsTotal, - std::string(step) + ": AssetsTotal expected " + to_string(assetsTotal) + " got " + - to_string(Number(vaultSle->at(sfAssetsTotal)))); - BEAST_EXPECTS( - brokerSle->at(sfDebtTotal) == debtTotal, - std::string(step) + ": DebtTotal expected " + to_string(debtTotal) + " got " + - to_string(Number(brokerSle->at(sfDebtTotal)))); - BEAST_EXPECTS( - vaultSle->at(sfLossUnrealized) == lossUnrealized, - std::string(step) + ": LossUnrealized expected " + to_string(lossUnrealized) + - " got " + to_string(Number(vaultSle->at(sfLossUnrealized)))); - BEAST_EXPECTS( - brokerSle->at(sfCoverAvailable) == coverAvailable, - std::string(step) + ": CoverAvailable expected " + to_string(coverAvailable) + - " got " + to_string(Number(brokerSle->at(sfCoverAvailable)))); - }; - - checkVaultBroker(100'000, 0, 0, 1'000, "before LoanSet"); - - // Loan: principal=1200, 0% interest, 12 payments of 100 each, no fees. - Number const principalRequest{1'200}; - std::uint32_t const paymentTotal = 12; - std::uint32_t const paymentInterval = 600; - std::uint32_t const gracePeriod = 60; - - auto const brokerBeforeLoan = env.le(brokerKeylet); - BEAST_EXPECT(brokerBeforeLoan); - auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence); - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); - - LoanParameters const loanParams{ - .account = borrower, - .counter = lender, - .principalRequest = principalRequest, - .interest = TenthBips32{0}, - .payTotal = paymentTotal, - .payInterval = paymentInterval, - .gracePd = gracePeriod, - }; - env(loanParams(env, broker)); - env.close(); - - // Origination (cash-basis): AssetsTotal += 0, DebtTotal += principal. - checkVaultBroker(100'000, 1'200, 0, 1'000, "after LoanSet"); - - LoanState const state = getCurrentState(env, broker, loanKeylet); - BEAST_EXPECT(state.periodicPayment == xrpAsset(100).value()); - - // Payment 1: principalPaid=100, interestPaid=0. - // AssetsTotal += 0; DebtTotal -= 100. - env(pay(borrower, loanKeylet.key, xrpAsset(100).value()), Ter(tesSUCCESS)); - env.close(); - checkVaultBroker(100'000, 1'100, 0, 1'000, "after payment 1"); - - // Payment 2: same as above. - env(pay(borrower, loanKeylet.key, xrpAsset(100).value()), Ter(tesSUCCESS)); - env.close(); - checkVaultBroker(100'000, 1'000, 0, 1'000, "after payment 2"); - - // Default (no impair): principalOutstanding remaining is 1'000. - // totalDefaultAmount (cash-basis) = PrincipalOutstanding = 1'000. - // minimumCover = DebtTotal(1'000) * coverRateMin(10%) = 100. - // covered = min(minimumCover * coverRateLiquidation(25%), totalDefaultAmount) - // = min(25, 1'000) = 25. - // defaultCovered = min(covered, CoverAvailable(1'000)) = 25. - // vaultDefaultAmount = 1'000 - 25 = 975. - // DebtTotal -= 1'000 -> 0. CoverAvailable -= 25 -> 975. - // AssetsTotal -= 975 -> 99'025. LossUnrealized unaffected (never impaired). - auto const loanBeforeDefault = env.le(loanKeylet); - BEAST_EXPECT(loanBeforeDefault); - BEAST_EXPECT( - Number(loanBeforeDefault->at(sfPrincipalOutstanding)) == xrpAsset(1'000).value()); - - env.close(state.startDate + std::chrono::seconds((3 * paymentInterval) + gracePeriod) + 1s); - - env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); - env.close(); - - checkVaultBroker(99'025, 0, 0, 975, "after LoanManage(default)"); - } - - void - runAmendmentIndependent() - { - testDisabled(); - testInvalidLoanSet(); - testInvalidLoanDelete(); - testInvalidLoanManage(); - testInvalidLoanPay(); - testIssuerLoan(); - testServiceFeeOnBrokerDeepFreeze(); - testRequireAuth(); - testRIPD3901(); - testBorrowerIsBroker(); - testLimitExceeded(); - testLendingCanTradeDisabledNoImpact(); - testBugOverpaymentPrincipalChange(); - testBugOverpayUnroundedAmount(); - - for (auto const flags : {0u, tfLoanOverpayment}) - testYieldTheftRounding(flags); - testBugInterestDueDeltaCrash(); - testFullLifecycleVaultPnLNearZeroRate(); - testLoanSetNearZeroInterestRateSucceeds(); - - testCashBasisLoanSetOrigination(); - testCashBasisLoanPay(); - testCashBasisLoanManage(); - testLegacyVaultKeepsAccrualAfterAmendmentEnabled(); - testCashBasisEndToEndTrajectory(); - } - - // Tests run under each entry in amendmentCombinations(). - void - runAmendmentSensitive(FeatureBitset features) - { -#if LOAN_TODO - testLoanPayLateFullPaymentBypassesPenalties(features); - testLoanCoverMinimumRoundingExploit(features); -#endif - // Lifecycle - testLifecycle(features); - testLoanSet(features); - testDosLoanPay(features); - testSelfLoan(features); - - // Payment paths - testWithdrawReflectsUnrealizedLoss(features); - testPoCUnsignedUnderflowOnFullPayAfterEarlyPeriodic(features); - testBatchBypassCounterparty(features); - testLoanNextPaymentDueDateOverflow(features); - testSequentialFLCDepletion(features); - - // Invariants - testLoanPayComputePeriodicPaymentValidRateInvariant(features); - testAccountSendMptMinAmountInvariant(features); - testLoanPayDebtDecreaseInvariant(features); - testWrongMaxDebtBehavior(features); - testLoanPayComputePeriodicPaymentValidTotalInterestInvariant(features); - testLoanPayComputePeriodicPaymentValidTotalPrincipalPaidInvariant(features); - testLoanPayComputePeriodicPaymentValidTotalInterestPaidInvariant(features); - - // RPC - testRPC(features); - - // Edge / rounding - testDustManipulation(features); - testRoundingAllowsUndercoverage(features); - testOverpaymentManagementFee(features); - testIssuerIsBorrower(features); - testIntegerScalePrincipalSticks(features); - testMinimumBrokerCoverConsistency(features); - - // RIPD regressions - testRIPD3831(features); - testRIPD3459(features); - testRIPD3902(features); - - // Broker-owner permissions - testLoanPayBrokerOwnerMissingTrustline(features); - testLoanPayBrokerOwnerUnauthorizedMPT(features); - testLoanPayBrokerOwnerNoPermissionedDomainMPT(features); - testLoanSetBrokerOwnerNoPermissionedDomainMPT(features); - } - -public: - void - run() override - { - runAmendmentIndependent(); - for (auto const& features : jtx::amendmentCombinations( - {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) - runAmendmentSensitive(features); - } -}; - -class LoanBatch_test : public Loan_test -{ -protected: - beast::xor_shift_engine engine_; - - std::uniform_int_distribution<> assetDist_{0, 2}; - std::uniform_int_distribution principalDist_{100'000, 1'000'000'000}; - std::uniform_int_distribution interestRateDist_{0, 10000}; - std::uniform_int_distribution<> paymentTotalDist_{12, 10000}; - std::uniform_int_distribution<> paymentIntervalDist_{60, 3600 * 24 * 30}; - std::uniform_int_distribution managementFeeRateDist_{0, 10'000}; - std::uniform_int_distribution<> serviceFeeDist_{0, 20}; - /* - # Generate parameters that are more likely to be valid - principal = Decimal(str(rand.randint(100000, - 100'000'000))).quantize(ROUND_TARGET) - - interest_rate = Decimal(rand.randint(1, 10000)) / - Decimal(100000) - - payment_total = rand.randint(12, 10000) - - payment_interval = Decimal(str(rand.randint(60, 2629746))) - - interest_fee = Decimal(rand.randint(0, 100000)) / - Decimal(100000) -*/ - - void - testRandomLoan() - { - using namespace jtx; - - Account const issuer("issuer"); - Account const lender("lender"); - Account const borrower("borrower"); - - // Determine all the random parameters at once - auto const assetType = static_cast(assetDist_(engine_)); - auto const principalRequest = principalDist_(engine_); - TenthBips16 const managementFeeRate{managementFeeRateDist_(engine_)}; - auto const serviceFee = serviceFeeDist_(engine_); - TenthBips32 interest{interestRateDist_(engine_)}; - auto const payTotal = paymentTotalDist_(engine_); - auto const payInterval = paymentIntervalDist_(engine_); - - BrokerParameters const brokerParams{ - .vaultDeposit = principalRequest * 10, - .debtMax = 0, - .coverRateMin = TenthBips32{0}, - .managementFeeRate = managementFeeRate}; - LoanParameters const loanParams{ - .account = lender, - .counter = borrower, - .principalRequest = principalRequest, - .serviceFee = serviceFee, - .interest = interest, - .payTotal = payTotal, - .payInterval = payInterval, - }; - - runLoan(assetType, brokerParams, loanParams, all_); - } - -public: - void - run() override - { - auto const numIterations = [s = arg()]() -> int { - int const defaultNum = 5; - if (s.empty()) - return defaultNum; - try - { - std::size_t pos = 0; - auto const r = stoi(s, &pos); - if (pos != s.size()) - return defaultNum; - return r; - } - catch (...) - { - return defaultNum; - } - }(); - - using namespace jtx; - - auto const updateInterval = std::min(numIterations / 5, 100); - - for (int i = 0; i < numIterations; ++i) - { - if (i % updateInterval == 0) - testcase << "Random Loan Test iteration " << (i + 1) << "/" << numIterations; - testRandomLoan(); - } - } -}; - -class LoanArbitrary_test : public LoanBatch_test -{ - void - run() override - { - using namespace jtx; - - BrokerParameters const brokerParams{ - .vaultDeposit = 10000, - .debtMax = 0, - .coverRateMin = TenthBips32{0}, - .managementFeeRate = TenthBips16{0}, - .coverRateLiquidation = TenthBips32{0}}; - LoanParameters const loanParams{ - .account = Account("lender"), - .counter = Account("borrower"), - .principalRequest = Number{200000, -6}, - .interest = TenthBips32{50000}, - .payTotal = 2, - .payInterval = 200}; - - runLoan(AssetType::XRP, brokerParams, loanParams, all_); - } -}; - -BEAST_DEFINE_TESTSUITE(Loan, tx, xrpl); -BEAST_DEFINE_TESTSUITE_MANUAL(LoanBatch, tx, xrpl); -BEAST_DEFINE_TESTSUITE_MANUAL(LoanArbitrary, tx, xrpl); - -} // namespace xrpl::test diff --git a/src/test/app/LendingHelpers_test.cpp b/src/test/app/lending/LendingHelpers_test.cpp similarity index 100% rename from src/test/app/LendingHelpers_test.cpp rename to src/test/app/lending/LendingHelpers_test.cpp diff --git a/src/test/app/LoanBroker_test.cpp b/src/test/app/lending/LoanBroker_test.cpp similarity index 99% rename from src/test/app/LoanBroker_test.cpp rename to src/test/app/lending/LoanBroker_test.cpp index ee398bfc3b..a610dbe931 100644 --- a/src/test/app/LoanBroker_test.cpp +++ b/src/test/app/lending/LoanBroker_test.cpp @@ -290,7 +290,7 @@ class LoanBroker_test : public beast::unit_test::Suite { auto const amount = vault.asset(n); BEAST_EXPECT(broker->at(sfCoverAvailable) == amount.number()); - env.require(Balance(pseudoAccount, amount)); + env.require(jtx::Balance(pseudoAccount, amount)); } }; @@ -537,8 +537,8 @@ class LoanBroker_test : public beast::unit_test::Suite auto const expectedBalance = aliceBalance + coverFunds - (aliceBalance.value().native() ? STAmount(env.current()->fees().base.value()) : vault.asset(0)); - env.require(Balance(alice, expectedBalance)); - env.require(Balance(pseudoAccount, vault.asset(kNone))); + env.require(jtx::Balance(alice, expectedBalance)); + env.require(jtx::Balance(pseudoAccount, vault.asset(kNone))); } } diff --git a/src/test/app/lending/LoanCashBasis_test.cpp b/src/test/app/lending/LoanCashBasis_test.cpp new file mode 100644 index 0000000000..c1334180c1 --- /dev/null +++ b/src/test/app/lending/LoanCashBasis_test.cpp @@ -0,0 +1,1013 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace xrpl::test { + +// LendingProtocolV1_1 ("cash-basis" accounting) dedicated coverage. +// +// Existing tests never enable featureLendingProtocolV1_1 (see `all_` +// above), so these are the only tests in this file that exercise the +// amendment. They are called once, directly, from +// runAmendmentIndependent() -- not looped through +// runAmendmentSensitive()/amendmentCombinations(), since doing so would +// require re-deriving whole-life-specific expected values for ~15 +// unrelated regression tests. +class LoanCashBasis_test : public LoanTestBase +{ +private: + // 1. LoanSet origination: Vault.AssetsTotal/LoanBroker.DebtTotal deltas, + // and the AssetsMaximum/DebtMaximum guards (which always check against + // principal + interestDue, regardless of the amendment). + void + testCashBasisLoanSetOrigination() + { + testcase("cash-basis: LoanSet origination"); + + using namespace jtx; + using namespace loan; + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + BrokerParameters const brokerParams{ + .vaultDeposit = 100'000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .coverDeposit = 0, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + + Number const principalRequest{10'000}; + TenthBips32 const interestRate{percentageToTenthBips(10)}; + std::uint32_t const paymentTotal = 2; + std::uint32_t const paymentInterval = 86400; + + // Creates a broker/vault, submits a single LoanSet with a nonzero + // interest rate, and returns the observed Vault.AssetsTotal / + // LoanBroker.DebtTotal deltas plus the loan's own computed + // interestDue and principalOutstanding. + auto runOrigination = [&](FeatureBitset features) { + Env env(*this, features); + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + env.fund(XRP(1'000'000), lender, borrower); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + auto const vaultBefore = env.le(broker.vaultKeylet()); + auto const brokerBefore = env.le(broker.brokerKeylet()); + BEAST_EXPECT(vaultBefore && brokerBefore); + Number const assetsTotalBefore = vaultBefore->at(sfAssetsTotal); + Number const debtTotalBefore = brokerBefore->at(sfDebtTotal); + + auto const loanSequence = brokerBefore->at(sfLoanSequence); + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + env(set(borrower, broker.brokerID, xrpAsset(principalRequest).value()), + kCounterparty(lender), + kInterestRate(interestRate), + kPaymentTotal(paymentTotal), + kPaymentInterval(paymentInterval), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 2), + Ter(tesSUCCESS)); + env.close(); + + auto const loanSle = env.le(loanKeylet); + BEAST_EXPECT(loanSle); + Number const principalOutstanding = loanSle->at(sfPrincipalOutstanding); + Number const totalValueOutstanding = loanSle->at(sfTotalValueOutstanding); + Number const interestDue = totalValueOutstanding - principalOutstanding; + BEAST_EXPECT(interestDue > beast::kZero); + BEAST_EXPECT(principalOutstanding == xrpAsset(principalRequest).value()); + + auto const vaultAfter = env.le(broker.vaultKeylet()); + auto const brokerAfter = env.le(broker.brokerKeylet()); + BEAST_EXPECT(vaultAfter && brokerAfter); + Number const assetsTotalDelta = + Number(vaultAfter->at(sfAssetsTotal)) - assetsTotalBefore; + Number const debtTotalDelta = Number(brokerAfter->at(sfDebtTotal)) - debtTotalBefore; + + return std::make_tuple( + assetsTotalDelta, debtTotalDelta, interestDue, principalOutstanding); + }; + + Number interestDueCash{}; + Number principalOutstandingCash{}; + { + auto const [assetsTotalDelta, debtTotalDelta, interestDue, principalOutstanding] = + runOrigination(all_ | featureLendingProtocolV1_1); + interestDueCash = interestDue; + principalOutstandingCash = principalOutstanding; + + BEAST_EXPECTS( + assetsTotalDelta == beast::kZero, + "cash-basis origination must not change AssetsTotal; delta=" + + to_string(assetsTotalDelta)); + BEAST_EXPECTS( + debtTotalDelta == principalOutstanding, + "cash-basis origination must add principal-only to DebtTotal; delta=" + + to_string(debtTotalDelta) + " principal=" + to_string(principalOutstanding)); + } + + { + auto const [assetsTotalDelta, debtTotalDelta, interestDue, principalOutstanding] = + runOrigination(all_); + + BEAST_EXPECTS( + assetsTotalDelta == interestDue, + "whole-life origination must add interestDue to AssetsTotal; delta=" + + to_string(assetsTotalDelta) + " interestDue=" + to_string(interestDue)); + BEAST_EXPECTS( + debtTotalDelta == principalOutstanding + interestDue, + "whole-life origination must add principal+interest to DebtTotal; delta=" + + to_string(debtTotalDelta)); + } + + // AssetsMaximum guard checks interestDue headroom only under + // whole-life accounting; DebtMaximum guard also varies by model. + auto runVaultGuard = [&](FeatureBitset features, Number const& slack, TER expected) { + Env env(*this, features); + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + env.fund(XRP(1'000'000), lender, borrower); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + auto const vaultSle = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultSle); + Number const assetsTotalBefore = vaultSle->at(sfAssetsTotal); + + Vault const vault{env}; + auto tx = vault.set({.owner = lender, .id = broker.vaultID}); + tx[sfAssetsMaximum] = assetsTotalBefore + slack; + env(tx); + env.close(); + + env(set(borrower, broker.brokerID, xrpAsset(principalRequest).value()), + kCounterparty(lender), + kInterestRate(interestRate), + kPaymentTotal(paymentTotal), + kPaymentInterval(paymentInterval), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 2), + Ter(expected)); + env.close(); + }; + + auto runBrokerGuard = [&](FeatureBitset features, Number const& debtMaximum, TER expected) { + Env env(*this, features); + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + env.fund(XRP(1'000'000), lender, borrower); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + env(loan_broker::set(lender, broker.vaultID), + loan_broker::kLoanBrokerId(broker.brokerID), + loan_broker::kDebtMaximum(debtMaximum), + Fee(env.current()->fees().base * 2)); + env.close(); + + env(set(borrower, broker.brokerID, xrpAsset(principalRequest).value()), + kCounterparty(lender), + kInterestRate(interestRate), + kPaymentTotal(paymentTotal), + kPaymentInterval(paymentInterval), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 2), + Ter(expected)); + env.close(); + }; + + Number const oneDrop = xrpAsset(1).value(); + { + testcase("whole-life: LoanSet AssetsMaximum guard checks interestDue headroom"); + // Guard rejects when there's not quite enough headroom for the + // interest. + runVaultGuard(all_, interestDueCash - oneDrop, tecLIMIT_EXCEEDED); + // Guard accepts at the exact boundary. + runVaultGuard(all_, interestDueCash, tesSUCCESS); + } + + { + testcase("cash-basis: LoanSet AssetsMaximum guard ignores interestDue headroom"); + // Even far less headroom than interestDue still succeeds, since + // cash-basis origination never adds interest to AssetsTotal. + runVaultGuard(all_ | featureLendingProtocolV1_1, oneDrop, tesSUCCESS); + } + + // DebtMaximum guard: cash-basis projects principal-only DebtTotal; + // whole-life projects principal + interestDue. + for (auto const cashBasis : {true, false}) + { + testcase( + std::string("LoanSet DebtMaximum guard (") + + (cashBasis ? "cash-basis)" : "whole-life)")); + auto const features = cashBasis ? all_ | featureLendingProtocolV1_1 : all_; + Number const newDebtTotal = + principalOutstandingCash + (cashBasis ? Number{} : interestDueCash); + runBrokerGuard(features, newDebtTotal - oneDrop, tecLIMIT_EXCEEDED); + runBrokerGuard(features, newDebtTotal, tesSUCCESS); + } + } + + // 2. LoanPay: regular, late, overpayment, and full-payment types. + // Assert Vault.AssetsTotal/LoanBroker.DebtTotal deltas match + // interestPaid/principalPaid under cash-basis, and cross-check the + // amendment-disabled run's deltas against the documented whole-life + // formula (AssetsTotal += valueChange; DebtTotal mirrors the loan's own + // TotalValueOutstanding delta exactly, since whole-life debt recognition + // tracks total loan value). + void + testCashBasisLoanPay() + { + using namespace jtx; + using namespace loan; + using namespace std::chrono_literals; + using tp = NetClock::time_point; + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + BrokerParameters const brokerParams{ + .vaultDeposit = 1'000'000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .coverDeposit = 0, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + + Number const principalRequest{12'000}; + TenthBips32 const interestRate{percentageToTenthBips(12)}; + std::uint32_t const paymentTotal = 4; + std::uint32_t const paymentInterval = 600; + std::uint32_t const gracePeriod = 300; + + struct PaymentDeltas + { + Number principalPaid; + Number assetsTotalDelta; + Number debtTotalDelta; + Number totalValueDelta; + }; + + // Sets up a fresh broker + loan, advances time, submits a single + // payment of the given type/amount, and returns the observed deltas. + auto runPayment = [&](FeatureBitset features, + std::uint32_t loanSetFlags, + std::uint32_t payFlags, + std::function const& advanceTime, + std::function const& paymentAmount) { + Env env(*this, features); + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + env.fund(XRP(10'000'000), lender, borrower); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + LoanParameters const loanParams{ + .account = borrower, + .counter = lender, + .principalRequest = principalRequest, + .interest = interestRate, + .payTotal = paymentTotal, + .payInterval = paymentInterval, + .gracePd = gracePeriod, + .flags = loanSetFlags, + }; + + auto const brokerBeforeLoan = env.le(broker.brokerKeylet()); + BEAST_EXPECT(brokerBeforeLoan); + auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence); + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + env(loanParams(env, broker)); + env.close(); + + LoanState const state = getCurrentState(env, broker, loanKeylet); + + advanceTime(env, state.startDate); + + auto const vaultBefore = env.le(broker.vaultKeylet()); + auto const brokerBefore = env.le(broker.brokerKeylet()); + auto const loanBefore = env.le(loanKeylet); + BEAST_EXPECT(vaultBefore && brokerBefore && loanBefore); + + Number const principalBefore = loanBefore->at(sfPrincipalOutstanding); + Number const totalValueBefore = loanBefore->at(sfTotalValueOutstanding); + Number const assetsTotalBefore = vaultBefore->at(sfAssetsTotal); + Number const debtTotalBefore = brokerBefore->at(sfDebtTotal); + + STAmount const amount = paymentAmount(state); + env(pay(borrower, loanKeylet.key, amount, payFlags), Ter(tesSUCCESS)); + env.close(); + + auto const vaultAfter = env.le(broker.vaultKeylet()); + auto const brokerAfter = env.le(broker.brokerKeylet()); + auto const loanAfter = env.le(loanKeylet); + BEAST_EXPECT(vaultAfter && brokerAfter && loanAfter); + + Number const principalAfter = loanAfter->at(sfPrincipalOutstanding); + Number const totalValueAfter = loanAfter->at(sfTotalValueOutstanding); + Number const assetsTotalAfter = vaultAfter->at(sfAssetsTotal); + Number const debtTotalAfter = brokerAfter->at(sfDebtTotal); + + return PaymentDeltas{ + .principalPaid = principalBefore - principalAfter, + .assetsTotalDelta = assetsTotalAfter - assetsTotalBefore, + .debtTotalDelta = debtTotalAfter - debtTotalBefore, + .totalValueDelta = totalValueAfter - totalValueBefore}; + }; + + // Compares the disabled (whole-life) and enabled (cash-basis) runs + // of the same payment scenario, and asserts the documented + // relationships between them. + auto checkScenario = [&](std::string const& label, + PaymentDeltas const& off, + PaymentDeltas const& on) { + testcase("cash-basis: LoanPay " + label); + + // The loan's own PrincipalOutstanding field is untouched by + // the amendment. + BEAST_EXPECTS( + off.principalPaid == on.principalPaid, + "principalPaid must be amendment-independent; off=" + to_string(off.principalPaid) + + " on=" + to_string(on.principalPaid)); + + // Whole-life structural invariant: DebtTotal (which + // recognizes a loan's full remaining value as debt) must + // change exactly as the loan's own TotalValueOutstanding + // does. + BEAST_EXPECTS( + off.debtTotalDelta == off.totalValueDelta, + "whole-life DebtTotal delta must mirror TotalValueOutstanding delta; " + "debtTotalDelta=" + + to_string(off.debtTotalDelta) + + " totalValueDelta=" + to_string(off.totalValueDelta)); + + // Derive interestPaid from the whole-life run's independent + // ledger deltas: + // assetsTotalDelta_off == valueChange + // debtTotalDelta_off == valueChange - (principalPaid + interestPaid) + // => interestPaid == assetsTotalDelta_off - debtTotalDelta_off - principalPaid + Number const interestPaid = + off.assetsTotalDelta - off.debtTotalDelta - off.principalPaid; + BEAST_EXPECTS( + interestPaid >= beast::kZero, + "derived interestPaid must be non-negative: " + to_string(interestPaid)); + + BEAST_EXPECTS( + on.assetsTotalDelta == interestPaid, + "cash-basis AssetsTotal delta must equal interestPaid; delta=" + + to_string(on.assetsTotalDelta) + " interestPaid=" + to_string(interestPaid)); + BEAST_EXPECTS( + on.debtTotalDelta == -on.principalPaid, + "cash-basis DebtTotal delta must equal -principalPaid; delta=" + + to_string(on.debtTotalDelta) + " principalPaid=" + to_string(on.principalPaid)); + }; + + // ---- Regular, on-time payment ---- + { + auto const noAdvance = [](Env& env, tp const&) { env.close(); }; + auto const regularAmount = [&](LoanState const& state) { + return STAmount{ + xrpAsset, + roundPeriodicPayment(xrpAsset, state.periodicPayment, state.loanScale) * + Number{3, -1} * 5}; // 1.5x, so only a single period is paid + }; + + auto const off = runPayment(all_, 0, 0, noAdvance, regularAmount); + auto const on = + runPayment(all_ | featureLendingProtocolV1_1, 0, 0, noAdvance, regularAmount); + + // Regular, on-time payments never change the loan's value beyond + // normal amortization (production asserts valueChange == 0), so + // AssetsTotal must be unaffected in the whole-life run. + BEAST_EXPECTS( + off.assetsTotalDelta == beast::kZero, + "regular on-time payment must not change AssetsTotal under whole-life; delta=" + + to_string(off.assetsTotalDelta)); + + checkScenario("regular payment", off, on); + } + + // ---- Late payment ---- + { + auto const advancePastDue = [&](Env& env, tp const& startDate) { + env.close(startDate + std::chrono::seconds(paymentInterval + 1)); + }; + auto const lateAmount = [&](LoanState const& state) { + return STAmount{ + xrpAsset, + roundPeriodicPayment(xrpAsset, state.periodicPayment, state.loanScale) * + Number{3}}; // generous; excess is not withdrawn + }; + + auto const off = runPayment(all_, 0, tfLoanLatePayment, advancePastDue, lateAmount); + auto const on = runPayment( + all_ | featureLendingProtocolV1_1, + 0, + tfLoanLatePayment, + advancePastDue, + lateAmount); + + checkScenario("late payment", off, on); + } + + // ---- Overpayment ---- + { + auto const noAdvance = [](Env& env, tp const&) { env.close(); }; + auto const overpayAmount = [&](LoanState const& state) { + // One regular period, plus a generous extra principal + // paydown. + return STAmount{ + xrpAsset, + roundPeriodicPayment(xrpAsset, state.periodicPayment, state.loanScale) + + xrpAsset(2'000).value()}; + }; + + auto const off = + runPayment(all_, tfLoanOverpayment, tfLoanOverpayment, noAdvance, overpayAmount); + auto const on = runPayment( + all_ | featureLendingProtocolV1_1, + tfLoanOverpayment, + tfLoanOverpayment, + noAdvance, + overpayAmount); + + checkScenario("overpayment", off, on); + } + + // ---- Full payment ---- + { + auto const noAdvance = [](Env& env, tp const&) { env.close(); }; + auto const fullAmount = [&](LoanState const&) { + // Generously large: full payment only ever consumes exactly + // what's due (principal + accrued interest; close fee/ + // prepayment penalty are 0 here), excess is not withdrawn. + return STAmount{xrpAsset, xrpAsset(principalRequest).value() * Number{2}}; + }; + + auto const off = runPayment(all_, 0, tfLoanFullPayment, noAdvance, fullAmount); + auto const on = runPayment( + all_ | featureLendingProtocolV1_1, 0, tfLoanFullPayment, noAdvance, fullAmount); + + checkScenario("full payment", off, on); + } + } + + // 3. LoanManage: impair, unimpair, and default. + void + testCashBasisLoanManage() + { + using namespace jtx; + using namespace loan; + using namespace std::chrono_literals; + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + BrokerParameters const brokerParams{ + .vaultDeposit = 1'000'000, + .debtMax = 0, + .coverRateMin = TenthBips32{percentageToTenthBips(10)}, + .coverDeposit = 5'000, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{percentageToTenthBips(25)}}; + + Number const principalRequest{10'000}; + TenthBips32 const interestRate{percentageToTenthBips(12)}; + std::uint32_t const paymentTotal = 4; + std::uint32_t const paymentInterval = 600; + std::uint32_t const gracePeriod = 60; + + auto setupLoan = [&](Env& env) { + Account const lender{"lender"}; + Account const borrower{"borrower"}; + env.fund(XRP(10'000'000), lender, borrower); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + LoanParameters const loanParams{ + .account = borrower, + .counter = lender, + .principalRequest = principalRequest, + .interest = interestRate, + .payTotal = paymentTotal, + .payInterval = paymentInterval, + .gracePd = gracePeriod, + }; + + auto const brokerBeforeLoan = env.le(broker.brokerKeylet()); + BEAST_EXPECT(brokerBeforeLoan); + auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence); + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + env(loanParams(env, broker)); + env.close(); + + return std::make_tuple(broker, loanKeylet, lender, borrower); + }; + + // ---- impair / unimpair ---- + auto runImpairUnimpair = [&](FeatureBitset features) { + Env env(*this, features); + auto const [broker, loanKeylet, lender, borrower] = setupLoan(env); + + auto const loanBefore = env.le(loanKeylet); + BEAST_EXPECT(loanBefore); + Number const principalOutstanding = loanBefore->at(sfPrincipalOutstanding); + Number const totalValueOutstanding = loanBefore->at(sfTotalValueOutstanding); + Number const managementFeeOutstanding = loanBefore->at(sfManagementFeeOutstanding); + + Number const expectedExposure = + env.current()->rules().enabled(featureLendingProtocolV1_1) + ? principalOutstanding + : totalValueOutstanding - managementFeeOutstanding; + + auto const vaultBeforeImpair = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultBeforeImpair); + Number const lossBefore = vaultBeforeImpair->at(sfLossUnrealized); + + env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS)); + env.close(); + + auto const vaultAfterImpair = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultAfterImpair); + Number const impairDelta = Number(vaultAfterImpair->at(sfLossUnrealized)) - lossBefore; + + env(manage(lender, loanKeylet.key, tfLoanUnimpair), Ter(tesSUCCESS)); + env.close(); + + auto const vaultAfterUnimpair = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultAfterUnimpair); + Number const netDelta = Number(vaultAfterUnimpair->at(sfLossUnrealized)) - lossBefore; + + return std::make_tuple(expectedExposure, impairDelta, netDelta); + }; + + for (auto const features : {all_ | featureLendingProtocolV1_1, all_}) + { + testcase( + std::string("cash-basis: LoanManage impair/unimpair (") + + (features[featureLendingProtocolV1_1] ? "enabled)" : "disabled)")); + auto const [expectedExposure, impairDelta, netDelta] = runImpairUnimpair(features); + + BEAST_EXPECTS( + impairDelta == expectedExposure, + "impair must add loanVaultExposure to LossUnrealized; delta=" + + to_string(impairDelta) + " expected=" + to_string(expectedExposure)); + BEAST_EXPECTS( + netDelta == beast::kZero, + "unimpair must be an exact reversal of impair; net=" + to_string(netDelta)); + } + + // ---- impair, then default ---- + auto runDefault = [&](FeatureBitset features) { + Env env(*this, features); + auto const [broker, loanKeylet, lender, borrower] = setupLoan(env); + + auto const loanBeforeImpair = env.le(loanKeylet); + BEAST_EXPECT(loanBeforeImpair); + Number const principalOutstanding = loanBeforeImpair->at(sfPrincipalOutstanding); + Number const totalValueOutstanding = loanBeforeImpair->at(sfTotalValueOutstanding); + Number const managementFeeOutstanding = + loanBeforeImpair->at(sfManagementFeeOutstanding); + + Number const expectedExposure = + env.current()->rules().enabled(featureLendingProtocolV1_1) + ? principalOutstanding + : totalValueOutstanding - managementFeeOutstanding; + + env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS)); + env.close(); + + LoanState const state = getCurrentState(env, broker, loanKeylet); + env.close( + state.startDate + std::chrono::seconds(paymentInterval) + + std::chrono::seconds(gracePeriod) + 60s); + + auto const vaultBefore = env.le(broker.vaultKeylet()); + auto const brokerBefore = env.le(broker.brokerKeylet()); + BEAST_EXPECT(vaultBefore && brokerBefore); + Number const assetsTotalBefore = vaultBefore->at(sfAssetsTotal); + Number const debtTotalBefore = brokerBefore->at(sfDebtTotal); + Number const lossBefore = vaultBefore->at(sfLossUnrealized); + Number const coverAvailableBefore = brokerBefore->at(sfCoverAvailable); + + env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); + env.close(); + + auto const vaultAfter = env.le(broker.vaultKeylet()); + auto const brokerAfter = env.le(broker.brokerKeylet()); + BEAST_EXPECT(vaultAfter && brokerAfter); + Number const assetsTotalDelta = + Number(vaultAfter->at(sfAssetsTotal)) - assetsTotalBefore; + Number const debtTotalDelta = Number(brokerAfter->at(sfDebtTotal)) - debtTotalBefore; + Number const lossDelta = Number(vaultAfter->at(sfLossUnrealized)) - lossBefore; + Number const coverAvailableDelta = + Number(brokerAfter->at(sfCoverAvailable)) - coverAvailableBefore; + + Number const defaultCovered = -coverAvailableDelta; + Number const vaultDefaultAmount = expectedExposure - defaultCovered; + + return std::make_tuple( + expectedExposure, assetsTotalDelta, debtTotalDelta, lossDelta, vaultDefaultAmount); + }; + + for (auto const features : {all_ | featureLendingProtocolV1_1, all_}) + { + testcase( + std::string("cash-basis: LoanManage default (") + + (features[featureLendingProtocolV1_1] ? "enabled)" : "disabled)")); + auto const + [expectedExposure, + assetsTotalDelta, + debtTotalDelta, + lossDelta, + vaultDefaultAmount] = runDefault(features); + + BEAST_EXPECTS( + debtTotalDelta == -expectedExposure, + "default must reduce DebtTotal by the unified default amount; delta=" + + to_string(debtTotalDelta) + " expected=" + to_string(expectedExposure)); + BEAST_EXPECTS( + lossDelta == -expectedExposure, + "default must reverse the earlier impair's LossUnrealized exactly; delta=" + + to_string(lossDelta) + " expected=" + to_string(expectedExposure)); + BEAST_EXPECTS( + assetsTotalDelta == -vaultDefaultAmount, + "default must reduce AssetsTotal by (defaultAmount - defaultCovered); delta=" + + to_string(assetsTotalDelta) + " expected=" + to_string(-vaultDefaultAmount)); + } + } + + // 3b. LEVersion regression: a Vault created before featureLendingProtocolV1_1 + // activates (LEVersion absent) must keep whole-life (accrual) accounting + // forever, even after the amendment is later enabled -- the switch is + // per-Vault (LEVersion == VaultVersion::CashBasis), not a single global amendment + // flag. + void + testLegacyVaultKeepsAccrualAfterAmendmentEnabled() + { + testcase("LEVersion: legacy vault keeps accrual after amendment enabled"); + + using namespace jtx; + using namespace loan; + using namespace std::chrono_literals; + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + BrokerParameters const brokerParams{ + .vaultDeposit = 1'000'000, + .debtMax = 0, + .coverRateMin = TenthBips32{percentageToTenthBips(10)}, + .coverDeposit = 5'000, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{percentageToTenthBips(25)}}; + + Number const principalRequest{10'000}; + TenthBips32 const interestRate{percentageToTenthBips(12)}; + std::uint32_t const paymentTotal = 4; + std::uint32_t const paymentInterval = 600; + std::uint32_t const gracePeriod = 60; + + // Amendment disabled at Vault creation time: LEVersion stays absent. + Env env(*this, all_); + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + env.fund(XRP(10'000'000), lender, borrower); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + { + auto const vaultSle = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultSle); + BEAST_EXPECT(!vaultSle->isFieldPresent(sfLEVersion)); + } + + // Now enable the amendment -- production dispatch must still treat + // this specific Vault as accrual-basis, since its LEVersion is + // (and remains) absent. + env.enableFeature(featureLendingProtocolV1_1); + env.close(); + + LoanParameters const loanParams{ + .account = borrower, + .counter = lender, + .principalRequest = principalRequest, + .interest = interestRate, + .payTotal = paymentTotal, + .payInterval = paymentInterval, + .gracePd = gracePeriod, + }; + + auto const brokerBeforeLoan = env.le(broker.brokerKeylet()); + BEAST_EXPECT(brokerBeforeLoan); + auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence); + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + // ---- LoanSet origination: whole-life formulas expected ---- + auto const vaultBeforeSet = env.le(broker.vaultKeylet()); + auto const brokerBeforeSet = env.le(broker.brokerKeylet()); + BEAST_EXPECT(vaultBeforeSet && brokerBeforeSet); + Number const assetsTotalBeforeSet = vaultBeforeSet->at(sfAssetsTotal); + Number const debtTotalBeforeSet = brokerBeforeSet->at(sfDebtTotal); + + env(loanParams(env, broker)); + env.close(); + + auto const loanAfterSet = env.le(loanKeylet); + BEAST_EXPECT(loanAfterSet); + Number const principalOutstanding = loanAfterSet->at(sfPrincipalOutstanding); + Number const totalValueOutstanding = loanAfterSet->at(sfTotalValueOutstanding); + Number const interestDue = totalValueOutstanding - principalOutstanding; + BEAST_EXPECT(interestDue > beast::kZero); + + auto const vaultAfterSet = env.le(broker.vaultKeylet()); + auto const brokerAfterSet = env.le(broker.brokerKeylet()); + BEAST_EXPECT(vaultAfterSet && brokerAfterSet); + Number const assetsTotalDeltaSet = + Number(vaultAfterSet->at(sfAssetsTotal)) - assetsTotalBeforeSet; + Number const debtTotalDeltaSet = + Number(brokerAfterSet->at(sfDebtTotal)) - debtTotalBeforeSet; + + BEAST_EXPECTS( + assetsTotalDeltaSet == interestDue, + "legacy vault origination must still add interestDue to AssetsTotal; delta=" + + to_string(assetsTotalDeltaSet) + " interestDue=" + to_string(interestDue)); + BEAST_EXPECTS( + debtTotalDeltaSet == principalOutstanding + interestDue, + "legacy vault origination must still add principal+interest to DebtTotal; delta=" + + to_string(debtTotalDeltaSet)); + + LoanState const state = getCurrentState(env, broker, loanKeylet); + env.close(); + + // ---- LoanPay: whole-life formulas expected ---- + auto const vaultBeforePay = env.le(broker.vaultKeylet()); + auto const brokerBeforePay = env.le(broker.brokerKeylet()); + auto const loanBeforePay = env.le(loanKeylet); + BEAST_EXPECT(vaultBeforePay && brokerBeforePay && loanBeforePay); + Number const totalValueBeforePay = loanBeforePay->at(sfTotalValueOutstanding); + Number const assetsTotalBeforePay = vaultBeforePay->at(sfAssetsTotal); + Number const debtTotalBeforePay = brokerBeforePay->at(sfDebtTotal); + + STAmount const paymentAmount{ + xrpAsset, roundPeriodicPayment(xrpAsset, state.periodicPayment, state.loanScale)}; + env(pay(borrower, loanKeylet.key, paymentAmount), Ter(tesSUCCESS)); + env.close(); + + auto const vaultAfterPay = env.le(broker.vaultKeylet()); + auto const brokerAfterPay = env.le(broker.brokerKeylet()); + auto const loanAfterPay = env.le(loanKeylet); + BEAST_EXPECT(vaultAfterPay && brokerAfterPay && loanAfterPay); + Number const totalValueAfterPay = loanAfterPay->at(sfTotalValueOutstanding); + Number const assetsTotalDeltaPay = + Number(vaultAfterPay->at(sfAssetsTotal)) - assetsTotalBeforePay; + Number const debtTotalDeltaPay = + Number(brokerAfterPay->at(sfDebtTotal)) - debtTotalBeforePay; + Number const totalValueDeltaPay = totalValueAfterPay - totalValueBeforePay; + + // A regular, on-time payment has valueChange == 0, so whole-life + // AssetsTotal is untouched and DebtTotal mirrors TotalValueOutstanding. + BEAST_EXPECTS( + assetsTotalDeltaPay == beast::kZero, + "legacy vault regular payment must not change AssetsTotal; delta=" + + to_string(assetsTotalDeltaPay)); + BEAST_EXPECTS( + debtTotalDeltaPay == totalValueDeltaPay, + "legacy vault DebtTotal delta must mirror TotalValueOutstanding delta; " + "debtTotalDelta=" + + to_string(debtTotalDeltaPay) + " totalValueDelta=" + to_string(totalValueDeltaPay)); + + // ---- LoanManage: impair, then default -- whole-life exposure expected ---- + auto const loanBeforeImpair = env.le(loanKeylet); + BEAST_EXPECT(loanBeforeImpair); + Number const totalValueBeforeImpair = loanBeforeImpair->at(sfTotalValueOutstanding); + Number const managementFeeBeforeImpair = loanBeforeImpair->at(sfManagementFeeOutstanding); + Number const expectedExposure = totalValueBeforeImpair - managementFeeBeforeImpair; + + env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS)); + env.close(); + + LoanState const stateAtImpair = getCurrentState(env, broker, loanKeylet); + env.close( + stateAtImpair.startDate + std::chrono::seconds(paymentInterval) + + std::chrono::seconds(gracePeriod) + 60s); + + auto const vaultBeforeDefault = env.le(broker.vaultKeylet()); + auto const brokerBeforeDefault = env.le(broker.brokerKeylet()); + BEAST_EXPECT(vaultBeforeDefault && brokerBeforeDefault); + Number const debtTotalBeforeDefault = brokerBeforeDefault->at(sfDebtTotal); + Number const lossBeforeDefault = vaultBeforeDefault->at(sfLossUnrealized); + + env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); + env.close(); + + auto const vaultAfterDefault = env.le(broker.vaultKeylet()); + auto const brokerAfterDefault = env.le(broker.brokerKeylet()); + BEAST_EXPECT(vaultAfterDefault && brokerAfterDefault); + Number const debtTotalDeltaDefault = + Number(brokerAfterDefault->at(sfDebtTotal)) - debtTotalBeforeDefault; + Number const lossDeltaDefault = + Number(vaultAfterDefault->at(sfLossUnrealized)) - lossBeforeDefault; + + BEAST_EXPECTS( + debtTotalDeltaDefault == -expectedExposure, + "legacy vault default must reduce DebtTotal by whole-life exposure; delta=" + + to_string(debtTotalDeltaDefault) + " expected=" + to_string(expectedExposure)); + BEAST_EXPECTS( + lossDeltaDefault == -expectedExposure, + "legacy vault default must reverse the earlier impair's LossUnrealized exactly; " + "delta=" + + to_string(lossDeltaDefault) + " expected=" + to_string(expectedExposure)); + + // Confirm the Vault's LEVersion truly never got set, throughout. + { + auto const vaultSle = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultSle); + BEAST_EXPECT(!vaultSle->isFieldPresent(sfLEVersion)); + BEAST_EXPECT(getVaultVersion(vaultSle) == VaultVersion::Legacy); + } + } + + // 4. End-to-end trajectory: LoanSet -> 2 LoanPays -> LoanManage(default), + // entirely under the amendment, with independently hand-computed + // expected AssetsTotal/DebtTotal/LossUnrealized/CoverAvailable values at + // each step. 0% interest keeps the arithmetic exact and tractable; the + // divergence from whole-life accounting is already covered directly by + // testCashBasisLoanSetOrigination/LoanPay/LoanManage above, so this test + // focuses purely on an independent, from-scratch trajectory check. + void + testCashBasisEndToEndTrajectory() + { + testcase("cash-basis: end-to-end trajectory"); + + using namespace jtx; + using namespace loan; + using namespace std::chrono_literals; + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + BrokerParameters const brokerParams{ + .vaultDeposit = 100'000, .managementFeeRate = TenthBips16{0}}; + + Env env(*this, all_ | featureLendingProtocolV1_1); + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + env.fund(XRP(10'000'000), lender, borrower); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + // Hand computation (all values in XRP, drops == 1e-6 XRP): + // Vault: AssetsTotal starts at 100'000 (the deposit). + // Broker: DebtTotal starts at 0, CoverAvailable starts at 1'000 + // (BrokerParameters::defaults().coverDeposit). + auto const vaultKeylet = broker.vaultKeylet(); + auto const brokerKeylet = broker.brokerKeylet(); + + // All the "human XRP unit" constants below (e.g. `100'000`) are + // converted to raw native (drops) values via xrpAsset(...), since + // that's how the ledger fields are actually denominated. + auto const checkVaultBroker = [&](Number const& assetsTotalUnits, + Number const& debtTotalUnits, + Number const& lossUnrealizedUnits, + Number const& coverAvailableUnits, + char const* step) { + Number const assetsTotal = xrpAsset(assetsTotalUnits).value(); + Number const debtTotal = xrpAsset(debtTotalUnits).value(); + Number const lossUnrealized = xrpAsset(lossUnrealizedUnits).value(); + Number const coverAvailable = xrpAsset(coverAvailableUnits).value(); + + auto const vaultSle = env.le(vaultKeylet); + auto const brokerSle = env.le(brokerKeylet); + BEAST_EXPECT(vaultSle && brokerSle); + BEAST_EXPECTS( + vaultSle->at(sfAssetsTotal) == assetsTotal, + std::string(step) + ": AssetsTotal expected " + to_string(assetsTotal) + " got " + + to_string(Number(vaultSle->at(sfAssetsTotal)))); + BEAST_EXPECTS( + brokerSle->at(sfDebtTotal) == debtTotal, + std::string(step) + ": DebtTotal expected " + to_string(debtTotal) + " got " + + to_string(Number(brokerSle->at(sfDebtTotal)))); + BEAST_EXPECTS( + vaultSle->at(sfLossUnrealized) == lossUnrealized, + std::string(step) + ": LossUnrealized expected " + to_string(lossUnrealized) + + " got " + to_string(Number(vaultSle->at(sfLossUnrealized)))); + BEAST_EXPECTS( + brokerSle->at(sfCoverAvailable) == coverAvailable, + std::string(step) + ": CoverAvailable expected " + to_string(coverAvailable) + + " got " + to_string(Number(brokerSle->at(sfCoverAvailable)))); + }; + + checkVaultBroker(100'000, 0, 0, 1'000, "before LoanSet"); + + // Loan: principal=1200, 0% interest, 12 payments of 100 each, no fees. + Number const principalRequest{1'200}; + std::uint32_t const paymentTotal = 12; + std::uint32_t const paymentInterval = 600; + std::uint32_t const gracePeriod = 60; + + auto const brokerBeforeLoan = env.le(brokerKeylet); + BEAST_EXPECT(brokerBeforeLoan); + auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence); + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + LoanParameters const loanParams{ + .account = borrower, + .counter = lender, + .principalRequest = principalRequest, + .interest = TenthBips32{0}, + .payTotal = paymentTotal, + .payInterval = paymentInterval, + .gracePd = gracePeriod, + }; + env(loanParams(env, broker)); + env.close(); + + // Origination (cash-basis): AssetsTotal += 0, DebtTotal += principal. + checkVaultBroker(100'000, 1'200, 0, 1'000, "after LoanSet"); + + LoanState const state = getCurrentState(env, broker, loanKeylet); + BEAST_EXPECT(state.periodicPayment == xrpAsset(100).value()); + + // Payment 1: principalPaid=100, interestPaid=0. + // AssetsTotal += 0; DebtTotal -= 100. + env(pay(borrower, loanKeylet.key, xrpAsset(100).value()), Ter(tesSUCCESS)); + env.close(); + checkVaultBroker(100'000, 1'100, 0, 1'000, "after payment 1"); + + // Payment 2: same as above. + env(pay(borrower, loanKeylet.key, xrpAsset(100).value()), Ter(tesSUCCESS)); + env.close(); + checkVaultBroker(100'000, 1'000, 0, 1'000, "after payment 2"); + + // Default (no impair): principalOutstanding remaining is 1'000. + // totalDefaultAmount (cash-basis) = PrincipalOutstanding = 1'000. + // minimumCover = DebtTotal(1'000) * coverRateMin(10%) = 100. + // covered = min(minimumCover * coverRateLiquidation(25%), totalDefaultAmount) + // = min(25, 1'000) = 25. + // defaultCovered = min(covered, CoverAvailable(1'000)) = 25. + // vaultDefaultAmount = 1'000 - 25 = 975. + // DebtTotal -= 1'000 -> 0. CoverAvailable -= 25 -> 975. + // AssetsTotal -= 975 -> 99'025. LossUnrealized unaffected (never impaired). + auto const loanBeforeDefault = env.le(loanKeylet); + BEAST_EXPECT(loanBeforeDefault); + BEAST_EXPECT( + Number(loanBeforeDefault->at(sfPrincipalOutstanding)) == xrpAsset(1'000).value()); + + env.close(state.startDate + std::chrono::seconds((3 * paymentInterval) + gracePeriod) + 1s); + + env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); + env.close(); + + checkVaultBroker(99'025, 0, 0, 975, "after LoanManage(default)"); + } + +public: + void + run() override + { + testCashBasisLoanSetOrigination(); + testCashBasisLoanPay(); + testCashBasisLoanManage(); + testLegacyVaultKeepsAccrualAfterAmendmentEnabled(); + testCashBasisEndToEndTrajectory(); + } +}; + +BEAST_DEFINE_TESTSUITE(LoanCashBasis, tx, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/lending/LoanCoverFreezeAuth_test.cpp b/src/test/app/lending/LoanCoverFreezeAuth_test.cpp new file mode 100644 index 0000000000..ec0238c173 --- /dev/null +++ b/src/test/app/lending/LoanCoverFreezeAuth_test.cpp @@ -0,0 +1,722 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace xrpl::test { + +class LoanCoverFreezeAuth_test : public LoanTestBase +{ +private: + void + testSequentialFLCDepletion(FeatureBitset features) + { + testcase << "First-Loss Capital Depletion on Sequential Defaults"; + + using namespace jtx; + using namespace loan; + using namespace loan_broker; + + Env env{*this, features}; + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrowerA{"borrowerA"}; + Account const borrowerB{"borrowerB"}; + + env.fund(XRP(1'000'000), issuer, lender, borrowerA, borrowerB); + env.close(); + + PrettyAsset const asset = xrpIssue(); + auto const vaultDepositAmount = + asset(200'000); // Enough for 2 x 50k loans plus interest/fees + + auto const brokerInfo = createVaultAndBroker( + env, + asset, + lender, + { + .vaultDeposit = vaultDepositAmount.value(), + .debtMax = 0, + .coverRateMin = TenthBips32(20000), // 20% + .coverDeposit = 21'000, + .managementFeeRate = TenthBips16(100), // 0.1% + .coverRateLiquidation = TenthBips32(100000), + }); + auto const brokerKeylet = brokerInfo.brokerKeylet(); + + // Create two identical loans: each 50,000 XRP principal (scaled down to + // avoid funding issues) Total DebtTotal will be ~100,000 XRP (principal + // + interest) Formula will calculate cover as: 100% × (20% × 100,000) = + // 20,000 XRP So we need FLC = 20,000 XRP to be fully consumed by first + // default + auto const principalAmount = Number(50'000); + auto const loanPaymentInterval = 2592000; // 30 days + auto const loanGracePeriod = 604800; // 7 days + + // Create Loan A + auto loanATx = env.jt( + set(borrowerA, brokerKeylet.key, principalAmount), + Sig(sfCounterpartySignature, lender), + kInterestRate(TenthBips32(500)), // 5% + kPaymentTotal(12), + loan::kPaymentInterval(loanPaymentInterval), + loan::kGracePeriod(loanGracePeriod), + Fee(XRP(10))); // Sufficient fee for multi-sig transaction + env(loanATx); + env.close(); + + auto const loanAKeylet = keylet::loan(brokerKeylet.key, 1); + + // Create Loan B + auto loanBTx = env.jt( + set(borrowerB, brokerKeylet.key, principalAmount), + Sig(sfCounterpartySignature, lender), + kInterestRate(TenthBips32(500)), // 5% + kPaymentTotal(12), + loan::kPaymentInterval(loanPaymentInterval), + loan::kGracePeriod(loanGracePeriod), + Fee(XRP(10))); // Sufficient fee for multi-sig transaction + env(loanBTx); + env.close(); + + auto const loanBKeylet = keylet::loan(brokerKeylet.key, 2); + + auto loanASle = env.le(loanAKeylet); + if (!BEAST_EXPECT(loanASle)) + return; + + // Advance time past grace period for both loans to be defaultable + auto const loanANextDue = loanASle->at(sfNextPaymentDueDate); + auto const loanAGrace = loanASle->at(sfGracePeriod); + env.close(std::chrono::seconds{loanANextDue + loanAGrace + 60}); + + env(manage(lender, loanAKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); + env.close(); + + // Verify Loan A is defaulted + loanASle = env.le(loanAKeylet); + if (!BEAST_EXPECT(loanASle)) + return; + BEAST_EXPECT(loanASle->isFlag(lsfLoanDefault)); + BEAST_EXPECT(loanASle->at(sfPaymentRemaining) == 0); + + // Check broker state after first default (from committed ledger) + auto brokerSle = env.le(brokerKeylet); + if (!BEAST_EXPECT(brokerSle)) + return; + auto const afterFirstDebtTotal = brokerSle->at(sfDebtTotal); + auto const afterFirstCoverAvailable = brokerSle->at(sfCoverAvailable); + + // DebtTotal should have decreased by Loan A's debt + BEAST_EXPECT(afterFirstDebtTotal == 50'134); + + // CoverAvailable should have decreased significantly + BEAST_EXPECT(afterFirstCoverAvailable == 946); + + env(manage(lender, loanBKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); + + brokerSle = env.le(brokerKeylet); + if (!BEAST_EXPECT(brokerSle)) + return; + auto const afterSecondDebtTotal = brokerSle->at(sfDebtTotal); + auto const afterSecondCoverAvailable = brokerSle->at(sfCoverAvailable); + + BEAST_EXPECT(afterSecondDebtTotal == 0); + + BEAST_EXPECT(afterSecondCoverAvailable == 0); + } + + // Tests that vault withdrawals work correctly when the vault has unrealized + // loss from an impaired loan, ensuring the invariant check properly + // accounts for the loss. + void + testWithdrawReflectsUnrealizedLoss(FeatureBitset features) + { + using namespace jtx; + using namespace loan; + using namespace std::chrono_literals; + + testcase("Vault withdraw reflects sfLossUnrealized"); + + // Test constants + static constexpr std::int64_t kInitialFunding = 1'000'000; + static constexpr std::int64_t kLenderInitialIou = 5'000'000; + static constexpr std::int64_t kDepositorInitialIou = 1'000'000; + static constexpr std::int64_t kBorrowerInitialIou = 100'000; + static constexpr std::int64_t kDepositAmount = 5'000; + static constexpr std::int64_t kPrincipalAmount = 99; + static constexpr std::uint64_t kExpectedSharesPerDepositor = 5'000'000'000; + static constexpr std::uint32_t kLocalPaymentInterval = 600; + static constexpr std::uint32_t kLocalPaymentTotal = 2; + + Env env{*this, features}; + + // Setup accounts + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const depositorA{"lpA"}; + Account const depositorB{"lpB"}; + Account const borrower{"borrowerA"}; + + env.fund(XRP(kInitialFunding), issuer, lender, depositorA, depositorB, borrower); + env.close(); + + // Setup trust lines + PrettyAsset const iouAsset = issuer[iouCurrency_]; + env(trust(lender, iouAsset(10'000'000))); + env(trust(depositorA, iouAsset(10'000'000))); + env(trust(depositorB, iouAsset(10'000'000))); + env(trust(borrower, iouAsset(10'000'000))); + env.close(); + + // Fund accounts with IOUs + env(pay(issuer, lender, iouAsset(kLenderInitialIou))); + env(pay(issuer, depositorA, iouAsset(kDepositorInitialIou))); + env(pay(issuer, depositorB, iouAsset(kDepositorInitialIou))); + env(pay(issuer, borrower, iouAsset(kBorrowerInitialIou))); + env.close(); + + // Create vault and broker, then add deposits from two depositors + auto const broker = createVaultAndBroker(env, iouAsset, lender); + Vault v{env}; + + env(v.deposit({ + .depositor = depositorA, + .id = broker.vaultKeylet().key, + .amount = iouAsset(kDepositAmount), + }), + Ter(tesSUCCESS)); + env(v.deposit({ + .depositor = depositorB, + .id = broker.vaultKeylet().key, + .amount = iouAsset(kDepositAmount), + }), + Ter(tesSUCCESS)); + env.close(); + + // Create a loan + auto const sleBroker = env.le(keylet::loanBroker(broker.brokerID)); + if (!BEAST_EXPECT(sleBroker)) + return; + + auto const loanKeylet = keylet::loan(broker.brokerID, sleBroker->at(sfLoanSequence)); + + env(set(borrower, broker.brokerID, kPrincipalAmount), + Sig(sfCounterpartySignature, lender), + kPaymentTotal(kLocalPaymentTotal), + kPaymentInterval(kLocalPaymentInterval), + Fee(env.current()->fees().base * 2), + Ter(tesSUCCESS)); + env.close(); + + // Impair the loan to create unrealized loss + env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS)); + env.close(); + + // Verify unrealized loss is recorded in the vault + auto const vaultAfterImpair = env.le(broker.vaultKeylet()); + if (!BEAST_EXPECT(vaultAfterImpair)) + return; + + BEAST_EXPECT( + vaultAfterImpair->at(sfLossUnrealized) == broker.asset(kPrincipalAmount).value()); + + // Helper to get share balance for a depositor + auto const shareAsset = vaultAfterImpair->at(sfShareMPTID); + auto const getShareBalance = [&](Account const& depositor) -> std::uint64_t { + auto const token = env.le(keylet::mptoken(shareAsset, depositor.id())); + return token ? token->getFieldU64(sfMPTAmount) : 0; + }; + + // Verify both depositors have equal shares + auto const sharesLpA = getShareBalance(depositorA); + auto const sharesLpB = getShareBalance(depositorB); + BEAST_EXPECT(sharesLpA == kExpectedSharesPerDepositor); + BEAST_EXPECT(sharesLpB == kExpectedSharesPerDepositor); + BEAST_EXPECT(sharesLpA == sharesLpB); + + // Helper to attempt withdrawal + auto const attemptWithdrawShares = [&](Account const& depositor, + std::uint64_t shareAmount, + TER expected) { + STAmount const shareAmt{MPTIssue{shareAsset}, Number(shareAmount)}; + env(v.withdraw( + {.depositor = depositor, .id = broker.vaultKeylet().key, .amount = shareAmt}), + Ter(expected)); + env.close(); + }; + + // Regression test: Both depositors should successfully withdraw despite + // unrealized loss. Previously failed with invariant violation: + // "withdrawal must change vault and destination balance by equal + // amount". This was caused by sharesToAssetsWithdraw rounding down, + // creating a mismatch where vaultDeltaAssets * -1 != destinationDelta + // when unrealized loss exists. + attemptWithdrawShares(depositorA, sharesLpA, tesSUCCESS); + attemptWithdrawShares(depositorB, sharesLpB, tesSUCCESS); + } + + void + testServiceFeeOnBrokerDeepFreeze() + { + testcase << "Service Fee On Broker Deep Freeze"; + using namespace jtx; + using namespace loan; + Account const issuer("issuer"); + Account const borrower("borrower"); + Account const broker("broker"); + auto const iou = issuer["IOU"]; + + for (bool const deepFreeze : {true, false}) + { + Env env(*this); + + auto getCoverBalance = [&](BrokerInfo const& brokerInfo, auto const& accountField) { + if (auto const le = env.le(keylet::loanBroker(brokerInfo.brokerID)); + BEAST_EXPECT(le)) + { + auto const account = le->at(accountField); + if (auto const sleLine = env.le(keylet::trustLine(account, iou)); + BEAST_EXPECT(sleLine)) + { + STAmount balance = sleLine->at(sfBalance); + if (account > issuer.id()) + balance.negate(); + return balance; + } + } + return STAmount{iou}; + }; + + env.fund(XRP(20'000), issuer, broker, borrower); + env.close(); + + env(trust(broker, iou(20'000'000))); + env(pay(issuer, broker, iou(10'000'000))); + env.close(); + + auto const brokerInfo = createVaultAndBroker(env, iou, broker); + + BEAST_EXPECT(getCoverBalance(brokerInfo, sfAccount) == iou(1'000)); + + auto const keylet = keylet::loan(brokerInfo.brokerID, 1); + + env(set(borrower, brokerInfo.brokerID, 10'000), + Sig(sfCounterpartySignature, broker), + kLoanServiceFee(iou(100).value()), + kPaymentInterval(100), + Fee(XRP(100))); + env.close(); + + env(trust(borrower, iou(20'000'000))); + // The borrower increases their limit and acquires some IOU so + // they can pay interest + env(pay(issuer, borrower, iou(500))); + env.close(); + + if (auto const le = env.le(keylet::loan(keylet.key)); BEAST_EXPECT(le)) + { + if (deepFreeze) + { + env(trust(issuer, broker["IOU"](0), tfSetFreeze | tfSetDeepFreeze)); + env.close(); + } + + env(pay(borrower, keylet.key, iou(10'100)), Fee(XRP(100))); + env.close(); + + if (deepFreeze) + { + // The fee goes to the broker pseudo-account + BEAST_EXPECT(getCoverBalance(brokerInfo, sfAccount) == iou(1'100)); + BEAST_EXPECT(getCoverBalance(brokerInfo, sfOwner) == iou(8'999'000)); + } + else + { + // The fee goes to the broker account + BEAST_EXPECT(getCoverBalance(brokerInfo, sfOwner) == iou(8'999'100)); + BEAST_EXPECT(getCoverBalance(brokerInfo, sfAccount) == iou(1'000)); + } + } + }; + } + + void + testLoanPayBrokerOwnerMissingTrustline(FeatureBitset features) + { + testcase << "LoanPay Broker Owner Missing Trustline (PoC)"; + using namespace jtx; + using namespace loan; + Account const issuer("issuer"); + Account const borrower("borrower"); + Account const broker("broker"); + auto const iou = issuer["IOU"]; + Env env(*this, features); + env.fund(XRP(20'000), issuer, broker, borrower); + env.close(); + // Set up trustlines and fund accounts + env(trust(broker, iou(20'000'000))); + env(trust(borrower, iou(20'000'000))); + env(pay(issuer, broker, iou(10'000'000))); + env(pay(issuer, borrower, iou(1'000))); + env.close(); + // Create vault and broker + auto const brokerInfo = createVaultAndBroker(env, iou, broker); + // Create a loan first (this creates debt) + auto const keylet = keylet::loan(brokerInfo.brokerID, 1); + env(set(borrower, brokerInfo.brokerID, 10'000), + Sig(sfCounterpartySignature, broker), + kLoanServiceFee(iou(100).value()), + kPaymentInterval(100), + Fee(XRP(100))); + env.close(); + // Ensure broker has sufficient cover so brokerPayee == brokerOwner + // We need coverAvailable >= (debtTotal * coverRateMinimum) + // Deposit enough cover to ensure the fee goes to broker owner + // The default coverRateMinimum is 10%, so for a 10,000 loan we need + // at least 1,000 cover. Default cover is 1,000, so we add more to be + // safe. + auto const additionalCover = iou(50'000).value(); + env(loan_broker::coverDeposit(broker, brokerInfo.brokerID, STAmount{iou, additionalCover})); + env.close(); + // Verify broker owner has a trustline + auto const brokerTrustline = keylet::trustLine(broker, iou); + BEAST_EXPECT(env.le(brokerTrustline) != nullptr); + // Broker owner deletes their trustline + // First, pay any positive balance to issuer to zero it out + auto const brokerBalance = env.balance(broker, iou); + env(pay(broker, issuer, brokerBalance)); + env.close(); + // Remove the trustline by setting limit to 0 + env(trust(broker, iou(0))); + env.close(); + // Verify trustline is deleted + BEAST_EXPECT(env.le(brokerTrustline) == nullptr); + // Now borrower tries to make a payment + // We should get a tesSUCCESS instead of a tecNO_LINE. + env(pay(borrower, keylet.key, iou(10'100)), Fee(XRP(100)), Ter(tesSUCCESS)); + env.close(); + // Verify trustline is still deleted + BEAST_EXPECT(env.le(brokerTrustline) == nullptr); + // Verify the service fee went to the broker pseudo-account + if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); + BEAST_EXPECT(brokerSle)) + { + Account const pseudo("pseudo-account", brokerSle->at(sfAccount)); + auto const balance = env.balance(pseudo, iou); + // 1,000 default + 50,000 extra + 100 service fee from LoanPay + BEAST_EXPECTS(balance == iou(51'100), to_string(json::Value(balance))); + } + } + + void + testLoanPayBrokerOwnerUnauthorizedMPT(FeatureBitset features) + { + testcase << "LoanPay Broker Owner MPT unauthorized"; + using namespace jtx; + using namespace loan; + + Account const issuer("issuer"); + Account const borrower("borrower"); + Account const broker("broker"); + + Env env{*this, features}; + env.fund(XRP(20'000), issuer, broker, borrower); + env.close(); + + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); + + PrettyAsset const mpt{mptt.issuanceID()}; + + // Authorize broker and borrower + mptt.authorize({.account = broker}); + mptt.authorize({.account = borrower}); + + env.close(); + + // Fund accounts + env(pay(issuer, broker, mpt(10'000'000))); + env(pay(issuer, borrower, mpt(1'000))); + env.close(); + + // Create vault and broker + auto const brokerInfo = createVaultAndBroker(env, mpt, broker); + // Create a loan first (this creates debt) + auto const keylet = keylet::loan(brokerInfo.brokerID, 1); + env(set(borrower, brokerInfo.brokerID, 10'000), + Sig(sfCounterpartySignature, broker), + kLoanServiceFee(mpt(100).value()), + kPaymentInterval(100), + Fee(XRP(100))); + env.close(); + // Ensure broker has sufficient cover so brokerPayee == brokerOwner + // We need coverAvailable >= (debtTotal * coverRateMinimum) + // Deposit enough cover to ensure the fee goes to broker owner + // The default coverRateMinimum is 10%, so for a 10,000 loan we need + // at least 1,000 cover. Default cover is 1,000, so we add more to be + // safe. + auto const additionalCover = mpt(50'000).value(); + env(loan_broker::coverDeposit(broker, brokerInfo.brokerID, STAmount{mpt, additionalCover})); + env.close(); + // Verify broker owner is authorized + auto const brokerMpt = keylet::mptoken(mptt.issuanceID(), broker); + BEAST_EXPECT(env.le(brokerMpt) != nullptr); + // Broker owner unauthorizes. + // First, pay any positive balance to issuer to zero it out + auto const brokerBalance = env.balance(broker, mpt); + env(pay(broker, issuer, brokerBalance)); + env.close(); + // Then, unauthorize the MPT. + mptt.authorize({.account = broker, .flags = tfMPTUnauthorize}); + env.close(); + // Verify the MPT is unauthorized. + BEAST_EXPECT(env.le(brokerMpt) == nullptr); + // Now borrower tries to make a payment + // We should get a tesSUCCESS instead of a tecNO_AUTH. + env(pay(borrower, keylet.key, mpt(10'100)), Fee(XRP(100)), Ter(tesSUCCESS)); + env.close(); + // Verify the MPT is still unauthorized. + BEAST_EXPECT(env.le(brokerMpt) == nullptr); + // Verify the service fee went to the broker pseudo-account + if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); + BEAST_EXPECT(brokerSle)) + { + Account const pseudo("pseudo-account", brokerSle->at(sfAccount)); + auto const balance = env.balance(pseudo, mpt); + // 1,000 default + 50,000 extra + 100 service fee from LoanPay + BEAST_EXPECTS(balance == mpt(51'100), to_string(json::Value(balance))); + } + } + + void + testLoanPayBrokerOwnerNoPermissionedDomainMPT(FeatureBitset features) + { + testcase << "LoanPay Broker Owner without permissioned domain of the MPT"; + using namespace jtx; + using namespace loan; + + Account const issuer("issuer"); + Account const borrower("borrower"); + Account const broker("broker"); + + Env env{*this, features}; + env.fund(XRP(20'000), issuer, broker, borrower); + env.close(); + + auto credType = "credential1"; + + pdomain::Credentials const credentials1 = {{.issuer = issuer, .credType = credType}}; + env(pdomain::setTx(issuer, credentials1)); + env.close(); + + auto domainID = pdomain::getNewDomain(env.meta()); + + env(credentials::create(broker, issuer, credType)); + env(credentials::accept(broker, issuer, credType)); + env.close(); + + env(credentials::create(borrower, issuer, credType)); + env(credentials::accept(borrower, issuer, credType)); + env.close(); + + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create({ + .flags = tfMPTCanClawback | tfMPTRequireAuth | tfMPTCanTransfer | tfMPTCanLock, + .domainID = domainID, + }); + + PrettyAsset const mpt{mptt.issuanceID()}; + + // Authorize broker and borrower + mptt.authorize({.account = broker}); + mptt.authorize({.account = borrower}); + + env.close(); + + // Fund accounts + env(pay(issuer, broker, mpt(10'000'000))); + env(pay(issuer, borrower, mpt(1'000))); + env.close(); + + // Create vault and broker + auto const brokerInfo = createVaultAndBroker(env, mpt, broker); + // Create a loan first (this creates debt) + auto const keylet = keylet::loan(brokerInfo.brokerID, 1); + env(set(borrower, brokerInfo.brokerID, 10'000), + Sig(sfCounterpartySignature, broker), + kLoanServiceFee(mpt(100).value()), + kPaymentInterval(100), + Fee(XRP(100))); + env.close(); + // Ensure broker has sufficient cover so brokerPayee == brokerOwner + // We need coverAvailable >= (debtTotal * coverRateMinimum) + // Deposit enough cover to ensure the fee goes to broker owner + // The default coverRateMinimum is 10%, so for a 10,000 loan we need + // at least 1,000 cover. Default cover is 1,000, so we add more to be + // safe. + auto const additionalCover = mpt(50'000).value(); + env(loan_broker::coverDeposit(broker, brokerInfo.brokerID, STAmount{mpt, additionalCover})); + env.close(); + // Verify broker owner is authorized + auto const brokerMpt = keylet::mptoken(mptt.issuanceID(), broker); + BEAST_EXPECT(env.le(brokerMpt) != nullptr); + // Remove the credentials for the Broker owner. + // First, pay any positive balance to issuer to zero it out + auto const brokerBalance = env.balance(broker, mpt); + env(pay(broker, issuer, brokerBalance)); + env.close(); + + env(credentials::deleteCred(broker, broker, issuer, credType)); + env.close(); + + // Make sure the broker is not authorized to hold the MPT after we + // deleted the credentials + env(pay(issuer, broker, mpt(1'000)), Ter(tecNO_AUTH)); + + // Now borrower tries to make a payment + // We should get a tesSUCCESS instead of a tecNO_AUTH. + env(pay(borrower, keylet.key, mpt(10'100)), Fee(XRP(100)), Ter(tesSUCCESS)); + env.close(); + // Verify broker is still not authorized + env(pay(issuer, broker, mpt(1'000)), Ter(tecNO_AUTH)); + // Verify the service fee went to the broker pseudo-account + if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); + BEAST_EXPECT(brokerSle)) + { + Account const pseudo("pseudo-account", brokerSle->at(sfAccount)); + auto const balance = env.balance(pseudo, mpt); + // 1,000 default + 50,000 extra + 100 service fee from LoanPay + BEAST_EXPECTS(balance == mpt(51'100), to_string(json::Value(balance))); + } + } + + void + testLoanSetBrokerOwnerNoPermissionedDomainMPT(FeatureBitset features) + { + testcase << "LoanSet Broker Owner without permissioned domain of the MPT"; + using namespace jtx; + using namespace loan; + + Account const issuer("issuer"); + Account const borrower("borrower"); + Account const broker("broker"); + + Env env{*this, features}; + env.fund(XRP(20'000), issuer, broker, borrower); + env.close(); + + auto credType = "credential1"; + + pdomain::Credentials const credentials1{{.issuer = issuer, .credType = credType}}; + env(pdomain::setTx(issuer, credentials1)); + env.close(); + + auto domainID = pdomain::getNewDomain(env.meta()); + + // Add credentials for the broker and borrower + env(credentials::create(broker, issuer, credType)); + env(credentials::accept(broker, issuer, credType)); + env.close(); + + env(credentials::create(borrower, issuer, credType)); + env(credentials::accept(borrower, issuer, credType)); + env.close(); + + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create({ + .flags = tfMPTCanClawback | tfMPTRequireAuth | tfMPTCanTransfer | tfMPTCanLock, + .domainID = domainID, + }); + + PrettyAsset const mpt{mptt.issuanceID()}; + + // Authorize broker and borrower + mptt.authorize({.account = broker}); + mptt.authorize({.account = borrower}); + env.close(); + + // Fund accounts + env(pay(issuer, broker, mpt(10'000'000))); + env(pay(issuer, borrower, mpt(1'000))); + env.close(); + + // Create vault and broker + auto const brokerInfo = createVaultAndBroker(env, mpt, broker); + + // Remove the credentials for the Broker owner. + // Clear the balance first. + auto const brokerBalance = env.balance(broker, mpt); + env(pay(broker, issuer, brokerBalance)); + env.close(); + // Delete the credentials + env(credentials::deleteCred(broker, broker, issuer, credType)); + env.close(); + + // Create a loan, this should fail for tecNO_AUTH + env(set(borrower, brokerInfo.brokerID, 10'000), + Sig(sfCounterpartySignature, broker), + kLoanServiceFee(mpt(100).value()), + kPaymentInterval(100), + Fee(XRP(100)), + Ter(tecNO_AUTH)); + env.close(); + } + + void + runAmendmentIndependent() + { + testServiceFeeOnBrokerDeepFreeze(); + } + + // Tests run under each entry in amendmentCombinations(). + void + runAmendmentSensitive(FeatureBitset features) + { + testSequentialFLCDepletion(features); + testWithdrawReflectsUnrealizedLoss(features); + testLoanPayBrokerOwnerMissingTrustline(features); + testLoanPayBrokerOwnerUnauthorizedMPT(features); + testLoanPayBrokerOwnerNoPermissionedDomainMPT(features); + testLoanSetBrokerOwnerNoPermissionedDomainMPT(features); + } + +public: + void + run() override + { + runAmendmentIndependent(); + for (auto const& features : jtx::amendmentCombinations( + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) + runAmendmentSensitive(features); + } +}; + +BEAST_DEFINE_TESTSUITE(LoanCoverFreezeAuth, tx, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/lending/LoanInvariants_test.cpp b/src/test/app/lending/LoanInvariants_test.cpp new file mode 100644 index 0000000000..381a3f8b48 --- /dev/null +++ b/src/test/app/lending/LoanInvariants_test.cpp @@ -0,0 +1,873 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl::test { + +class LoanInvariants_test : public LoanTestBase +{ +private: + // Each of these regression tests reproduces a single fuzzer-found (FIND-*) + // scenario against xrpl::detail::computePeriodicPayment / + // loanComputePaymentParts. They're merged into one function, one block + // per finding, because each is a narrow, self-contained repro that + // shares little beyond the surrounding scaffold. + void + testLoanPayComputePeriodicPaymentInvariants(FeatureBitset features) + { + using namespace jtx; + using namespace std::chrono_literals; + using namespace lending; + + // From FIND-012 + { + testcase << "LoanPay xrpl::detail::computePeriodicPayment : " + "valid rate"; + + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + BrokerParameters const brokerParams; + env.fund(XRP(brokerParams.vaultDeposit * 100), issuer, lender, borrower); + env.close(); + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + using namespace loan; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{640562, -5}; + + Number const serviceFee{2462611968}; + std::uint32_t const numPayments{4294967295 / 800}; + + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + Fee(loanSetFee), + kLoanServiceFee(serviceFee), + kPaymentTotal(numPayments), + Json(sfCounterpartySignature, json::ValueType::Object)); + + createJson["CloseInterestRate"] = 55374; + createJson["ClosePaymentFee"] = "3825205248"; + createJson["LatePaymentFee"] = "237"; + createJson["LoanOriginationFee"] = "0"; + createJson["OverpaymentFee"] = 35167; + createJson["OverpaymentInterestRate"] = 1360; + createJson["PaymentInterval"] = 727; + + auto const keylet = nextLoanKeylet(env, broker); + + createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); + // Fails in preclaim because principal requested can't be + // represented as XRP + env(createJson, Ter(tecPRECISION_LOSS)); + env.close(); + + BEAST_EXPECT(!env.le(keylet)); + + Number const actualPrincipal{6}; + + createJson[sfPrincipalRequested] = actualPrincipal; + createJson.removeMember(sfSequence.jsonName); + createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); + // Fails in doApply because the payment is too small to be + // represented as XRP. + env(createJson, Ter(tecPRECISION_LOSS)); + env.close(); + } + + // From FIND-010 + { + testcase << "xrpl::loanComputePaymentParts : valid total interest"; + + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + PrettyAsset const iouAsset = createFundedIouAsset(env, issuer, lender, borrower); + + BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)}; + + using namespace loan; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{1, 3}; + + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + Fee(loanSetFee), + Json(sfCounterpartySignature, json::ValueType::Object)); + + createJson["CloseInterestRate"] = 47299; + createJson["ClosePaymentFee"] = "3985819770"; + createJson["InterestRate"] = 92; + createJson["LatePaymentFee"] = "3866894865"; + createJson["LoanOriginationFee"] = "0"; + createJson["LoanServiceFee"] = "2348810240"; + createJson["OverpaymentFee"] = 58545; + createJson["PaymentInterval"] = 60; + createJson["PaymentTotal"] = 1; + createJson["PrincipalRequested"] = "0.000763058"; + + auto const keylet = nextLoanKeylet(env, broker); + + createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); + env(createJson); + env.close(); + + auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); + loanPayTx["Amount"]["value"] = "0.000281284125490196"; + env(loanPayTx, Ter(tecINSUFFICIENT_PAYMENT)); + env.close(); + } + + // From FIND-009 + { + testcase << "xrpl::loanComputePaymentParts : totalPrincipalPaid " + "rounded"; + + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + PrettyAsset const iouAsset = createFundedIouAsset(env, issuer, lender, borrower); + + BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)}; + + using namespace loan; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{1, 3}; + + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + Fee(loanSetFee), + Json(sfCounterpartySignature, json::ValueType::Object)); + + createJson["ClosePaymentFee"] = "0"; + createJson["InterestRate"] = 24346; + createJson["LateInterestRate"] = 65535; + createJson["LatePaymentFee"] = "0"; + createJson["LoanOriginationFee"] = "218"; + createJson["LoanServiceFee"] = "0"; + createJson["PaymentInterval"] = 60; + createJson["PaymentTotal"] = 5678; + createJson["PrincipalRequested"] = "9924.81"; + + auto const keylet = nextLoanKeylet(env, broker); + + createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); + env(createJson, Ter(tesSUCCESS)); + env.close(); + + auto const baseFee = env.current()->fees().base; + + auto const stateBefore = getCurrentState(env, broker, keylet); + + { + auto loanPayTx = + env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); + Number const amount{3074'745'058'823'529, -12}; + BEAST_EXPECT(to_string(amount) == "3074.745058823529"); + XRPAmount const payFee{ + baseFee * + (amount / stateBefore.periodicPayment / kLoanPaymentsPerFeeIncrement + 1)}; + loanPayTx["Amount"]["value"] = to_string(amount); + env(loanPayTx, Fee(payFee), Ter(tesSUCCESS)); + env.close(); + } + + { + auto loanPayTx = + env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); + Number const amount{6732'118'170'944'051, -12}; + BEAST_EXPECT(to_string(amount) == "6732.118170944051"); + XRPAmount const payFee{ + baseFee * + (amount / stateBefore.periodicPayment / kLoanPaymentsPerFeeIncrement + 1)}; + loanPayTx["Amount"]["value"] = to_string(amount); + env(loanPayTx, Fee(payFee), Ter(tesSUCCESS)); + env.close(); + } + + auto const stateAfter = getCurrentState(env, broker, keylet); + // Total interest outstanding is non-negative + BEAST_EXPECT(stateAfter.totalValue >= stateAfter.principalOutstanding); + // Principal paid is non-negative + BEAST_EXPECT(stateBefore.principalOutstanding >= stateAfter.principalOutstanding); + // Total value change is non-negative + BEAST_EXPECT(stateBefore.totalValue >= stateAfter.totalValue); + // Value delta is larger or same as principal delta (meaning + // non-negative interest paid) + BEAST_EXPECT( + (stateBefore.totalValue - stateAfter.totalValue) >= + (stateBefore.principalOutstanding - stateAfter.principalOutstanding)); + } + + // From FIND-008 + { + testcase << "xrpl::loanComputePaymentParts : loanValueChange rounded"; + + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + PrettyAsset const iouAsset = + createFundedIouAsset(env, issuer, lender, borrower, 100'000'000, 10'000'000); + + BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)}; + { + auto const coverDepositValue = + broker.asset(broker.params.coverDeposit * 10).value(); + env(loan_broker::coverDeposit(lender, broker.brokerID, coverDepositValue)); + env.close(); + } + + using namespace loan; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{1, 3}; + + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + Fee(loanSetFee), + Json(sfCounterpartySignature, json::ValueType::Object)); + + createJson["ClosePaymentFee"] = "0"; + createJson["InterestRate"] = 12833; + createJson["LateInterestRate"] = 77048; + createJson["LatePaymentFee"] = "0"; + createJson["LoanOriginationFee"] = "218"; + createJson["LoanServiceFee"] = "0"; + createJson["PaymentInterval"] = 752; + createJson["PaymentTotal"] = 5678; + createJson["PrincipalRequested"] = "9924.81"; + + auto const keylet = nextLoanKeylet(env, broker); + + createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); + env(createJson, Ter(tesSUCCESS)); + env.close(); + + auto const baseFee = env.current()->fees().base; + + auto const stateBefore = getCurrentState(env, broker, keylet); + BEAST_EXPECT(stateBefore.paymentRemaining == 5678); + BEAST_EXPECT(stateBefore.paymentRemaining > kLoanMaximumPaymentsPerTransaction); + + auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, Number{}})); + Number const amount{9924'81, -2}; + BEAST_EXPECT(to_string(amount) == "9924.81"); + XRPAmount const payFee{ + baseFee * + (amount / stateBefore.periodicPayment / kLoanPaymentsPerFeeIncrement + 1)}; + loanPayTx["Amount"]["value"] = to_string(amount); + env(loanPayTx, Fee(payFee), Ter(tesSUCCESS)); + env.close(); + + auto const stateAfter = getCurrentState(env, broker, keylet); + BEAST_EXPECT( + stateAfter.paymentRemaining == + stateBefore.paymentRemaining - kLoanMaximumPaymentsPerTransaction); + } + } + + void + testLoanPayDebtDecreaseInvariant(FeatureBitset features) + { + // From FIND-007 + testcase << "LoanPay xrpl::LoanPay::doApply : debtDecrease " + "rounding good"; + + using namespace jtx; + using namespace std::chrono_literals; + using namespace lending; + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + PrettyAsset const iouAsset = createFundedIouAsset(env, issuer, lender, borrower); + + BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)}; + + using namespace loan; + + auto const baseFee = env.current()->fees().base; + auto const loanSetFee = Fee(baseFee * 2); + Number const principalRequest{1, 3}; + + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + Fee(loanSetFee), + Json(sfCounterpartySignature, json::ValueType::Object)); + + createJson["ClosePaymentFee"] = "0"; + createJson["GracePeriod"] = 60; + createJson["InterestRate"] = 24346; + createJson["LateInterestRate"] = 65535; + createJson["LatePaymentFee"] = "0"; + createJson["LoanOriginationFee"] = "218"; + createJson["LoanServiceFee"] = "0"; + createJson["PaymentInterval"] = 60; + createJson["PaymentTotal"] = 5678; + createJson["PrincipalRequested"] = "9924.81"; + + auto const keylet = nextLoanKeylet(env, broker); + + createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); + env(createJson, Ter(tesSUCCESS)); + env.close(); + + auto const pseudoAcct = brokerPseudoAccount(env, broker, lender); + + VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, keylet); + auto const originalState = getCurrentState(env, broker, keylet); + verifyLoanStatus(originalState); + + Number const payment{3'269'349'176'470'588, -12}; + XRPAmount const payFee{ + baseFee * + ((payment / originalState.periodicPayment) / kLoanPaymentsPerFeeIncrement + 1)}; + auto loanPayTx = + env.json(pay(borrower, keylet.key, STAmount{broker.asset, payment}), Fee(payFee)); + BEAST_EXPECT(to_string(payment) == "3269.349176470588"); + env(loanPayTx, Ter(tesSUCCESS)); + env.close(); + + auto const newState = getCurrentState(env, broker, keylet); + BEAST_EXPECT( + isRounded(broker.asset, newState.managementFeeOutstanding, originalState.loanScale)); + BEAST_EXPECT(newState.managementFeeOutstanding < originalState.managementFeeOutstanding); + BEAST_EXPECT(isRounded(broker.asset, newState.totalValue, originalState.loanScale)); + BEAST_EXPECT( + isRounded(broker.asset, newState.principalOutstanding, originalState.loanScale)); + } + + void + testAccountSendMptMinAmountInvariant(FeatureBitset features) + { + // (From FIND-006) + testcase << "LoanSet trigger xrpl::accountSendMPT : minimum amount " + "and MPT"; + + using namespace jtx; + using namespace std::chrono_literals; + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); + PrettyAsset const mptAsset = mptt.issuanceID(); + mptt.authorize({.account = lender}); + mptt.authorize({.account = borrower}); + env(pay(issuer, lender, mptAsset(2'000'000))); + env(pay(issuer, borrower, mptAsset(1'000))); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, mptAsset, lender)}; + + using namespace loan; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{1, 3}; + + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + Fee(loanSetFee), + Json(sfCounterpartySignature, json::ValueType::Object)); + + createJson["CloseInterestRate"] = 76671; + createJson["ClosePaymentFee"] = "2061925410"; + createJson["GracePeriod"] = 434; + createJson["InterestRate"] = 50302; + createJson["LateInterestRate"] = 30322; + createJson["LatePaymentFee"] = "294427911"; + createJson["LoanOriginationFee"] = "3250635102"; + createJson["LoanServiceFee"] = "9557386"; + createJson["OverpaymentFee"] = 51249; + createJson["OverpaymentInterestRate"] = 14304; + createJson["PaymentInterval"] = 434; + createJson["PaymentTotal"] = "2891743748"; + createJson["PrincipalRequested"] = "8516.98"; + + createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); + env(createJson, Ter(temINVALID)); + env.close(); + } + + // Verify that LoanPay, LoanBrokerCoverWithdraw, and LoanSet all use the + // same vault-scale minimum cover when fixCleanup3_2_0 is enabled. + // Before the amendment, each transactor computed its minimum cover at a + // different precision (loanScale, debtScale, or the raw unrounded + // tenthBipsOfValue), which could lead to inconsistent decisions for the + // same broker state. After the amendment all three use + // minimumBrokerCover at vaultScale. + void + testMinimumBrokerCoverConsistency(FeatureBitset features) + { + using namespace jtx; + using namespace loan; + using namespace loan_broker; + + bool const withAmendment = features[fixCleanup3_2_0]; + + struct Ctx + { + jtx::Account issuer; + jtx::Account lender; + jtx::Account borrower; + jtx::PrettyAsset iou; + BrokerInfo broker; + BrokerParameters brokerParams; + }; + + // Shared setup, parametrized by vaultDeposit (the only varying setup + // field across the three scenarios). Each call runs in its own Env + // so multiple invocations within one scenario cannot interfere. + // The caller is responsible for invoking testcase(...) before the + // first runTest call of each scenario. + auto runTest = [&](Number vaultDeposit, auto&& body) { + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000'000), issuer, lender, borrower); + env.close(); + + // Enable clawback on the issuer *before* any trust lines exist + // (asfAllowTrustLineClawback requires an empty owner directory). + env(fset(issuer, asfAllowTrustLineClawback)); + env.close(); + + PrettyAsset const iou = issuer[iouCurrency_]; + env(trust(lender, iou(1'000'000'000))); + env(trust(borrower, iou(1'000'000'000))); + env.close(); + env(pay(issuer, lender, iou(100'000'000))); + env(pay(issuer, borrower, iou(100'000'000))); + env.close(); + + // 13.37% — non-round rate produces a messier minimum. + BrokerParameters const brokerParams{ + .vaultDeposit = vaultDeposit, + .debtMax = 0, + .coverRateMin = TenthBips32{13'370}, + .coverDeposit = 5'000, + .managementFeeRate = TenthBips16{500}}; + + BrokerInfo const broker = createVaultAndBroker(env, iou, lender, brokerParams); + + body( + env, + Ctx{.issuer = issuer, + .lender = lender, + .borrower = borrower, + .iou = iou, + .broker = broker, + .brokerParams = brokerParams}); + }; + + // Scenario 1 — LoanPay + // + // Verify that LoanPay's minimum cover check uses vault scale (not + // loan scale). Before the amendment, different loans could produce + // different fee routing decisions for the same broker-level state. + // Small vault deposit => vaultScale = -12. + testcase("LoanPay minimum cover scale consistency"); + { + struct LoanKeylets + { + Keylet tiny; + Keylet big; + }; + + // Create the tiny + big loans and reduce cover via clawback so + // that subsequent LoanPay calls hit the minimum-cover boundary. + // Used by the two pay-and-check sub-tests below so each can run + // in its own Env. + auto setupLoansAndClawback = [&](Env& env, Ctx const& c) -> std::optional { + Asset const asset{c.iou}; + + // Create the TINY loan first (while vaultScale is still + // small). principal 0.01, 0% interest, 1 payment => + // loanScale = vaultScale. + auto const brokerSle1 = env.le(keylet::loanBroker(c.broker.brokerID)); + if (!BEAST_EXPECT(brokerSle1)) + return std::nullopt; + auto const tinyLoanSeq = brokerSle1->at(sfLoanSequence); + auto const tinyLoanKeylet = keylet::loan(c.broker.brokerID, tinyLoanSeq); + + env(set(c.borrower, c.broker.brokerID, Number{1, -2}), + Sig(sfCounterpartySignature, c.lender), + kInterestRate(TenthBips32{0}), + kPaymentTotal(1), + kPaymentInterval(86400 * 365), + Fee(XRP(10))); + env.close(); + + // Create the BIG loan second. 100% annual interest over 20 + // payments pushes totalValueOutstanding high enough that + // loanScale > vaultScale. + auto const brokerSle2 = env.le(keylet::loanBroker(c.broker.brokerID)); + if (!BEAST_EXPECT(brokerSle2)) + return std::nullopt; + auto const bigLoanSeq = brokerSle2->at(sfLoanSequence); + auto const bigLoanKeylet = keylet::loan(c.broker.brokerID, bigLoanSeq); + + env(set(c.borrower, c.broker.brokerID, Number{500}), + Sig(sfCounterpartySignature, c.lender), + kInterestRate(TenthBips32{100'000}), + kPaymentTotal(20), + kPaymentInterval(86400 * 365), + Fee(XRP(10))); + env.close(); + + // The tiny loan's scale is frozen at the vault's pre-big-loan + // scale, so it is strictly smaller than the big loan's. + // After the big loan is created the vault absorbs its value, + // pushing vaultScale up to match bigLoanScale. + auto const tinyLoanSle = env.le(tinyLoanKeylet); + auto const bigLoanSle = env.le(bigLoanKeylet); + auto const vaultSle = env.le(keylet::vault(c.broker.vaultID)); + if (!BEAST_EXPECT(tinyLoanSle) || !BEAST_EXPECT(bigLoanSle) || + !BEAST_EXPECT(vaultSle)) + return std::nullopt; + if (!BEAST_EXPECT(tinyLoanSle->at(sfLoanScale) == -12) || + !BEAST_EXPECT(bigLoanSle->at(sfLoanScale) == -11) || + !BEAST_EXPECT(getAssetsTotalScale(vaultSle) == -11)) + return std::nullopt; + + // Use issuer clawback to reduce cover to the minimum the + // clawback transactor allows. Compute the amount as + // initialCover - expectedCoverAfter so we exercise the exact + // clawback rather than relying on the transactor to clip + // down. + // + // Before the amendment the clawback minimum is the + // *unrounded* tenthBipsOfValue — strictly less than the + // rounded-at-vaultScale minimum LoanPay uses for the big + // loan. After the amendment both clawback and LoanPay use + // the same rounded minimum (via minimumBrokerCover), so + // cover lands exactly at that threshold. + Number const expectedCoverAfter = withAmendment ? Number{1330651855688460000, -15} + : Number{1330651855688458000, -15}; + Number const clawbackAmount = + Number{c.brokerParams.coverDeposit} - expectedCoverAfter; + + env(coverClawback(c.issuer), + kLoanBrokerId(c.broker.brokerID), + kAmount(STAmount{asset, clawbackAmount})); + env.close(); + + auto const brokerSle = env.le(keylet::loanBroker(c.broker.brokerID)); + if (!BEAST_EXPECT(brokerSle) || + !BEAST_EXPECT(brokerSle->at(sfCoverAvailable) == expectedCoverAfter)) + return std::nullopt; + + return LoanKeylets{.tiny = tinyLoanKeylet, .big = bigLoanKeylet}; + }; + + // Pay one loan and report whether the fee went to the broker's + // pseudo account (the fallback when cover < minimum) rather + // than to the owner. + auto feeGoesToPseudo = [&](Env& env, Ctx const& c, Keylet const& loanKeylet) -> bool { + Asset const asset{c.iou}; + auto const brokerSle = env.le(keylet::loanBroker(c.broker.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + return false; + auto const pseudoAcct = Account("pseudo", brokerSle->at(sfAccount)); + auto const pseudoBefore = env.balance(pseudoAcct, c.iou); + + auto const payLoan = env.le(loanKeylet); + if (!BEAST_EXPECT(payLoan)) + return false; + auto const periodicPayment = payLoan->at(sfPeriodicPayment); + auto const serviceFee = payLoan->at(sfLoanServiceFee); + std::int32_t const loanScale = payLoan->at(sfLoanScale); + + auto const payment = roundPeriodicPayment(asset, periodicPayment, loanScale); + auto const payAmt = STAmount{asset, payment + serviceFee}; + + env(loan::pay(c.borrower, loanKeylet.key, payAmt), Fee(XRP(10))); + env.close(); + + auto const pseudoAfter = env.balance(pseudoAcct, c.iou); + return pseudoAfter.number() > pseudoBefore.number(); + }; + + // Pay the BIG loan in its own Env so its outcome cannot affect + // the TINY-loan check. With the fix, LoanPay and clawback use + // the same vaultScale minimum (cover == minAtVaultScale => + // fee to owner). Without the fix, LoanPay uses bigLoanScale=-11, + // rounds up to a larger minimum than what clawback used => + // cover < min => fee to pseudo. + runTest(/*vaultDeposit=*/1'000, [&](Env& env, Ctx const& c) { + auto const loans = setupLoansAndClawback(env, c); + if (!loans) + return; + BEAST_EXPECT(feeGoesToPseudo(env, c, loans->big) == !withAmendment); + }); + + // Pay the TINY loan in its own Env. Fee goes to the owner + // either way: + // - With the fix: LoanPay uses vaultScale=-11 (same as + // clawback) => owner. + // - Without the fix: LoanPay uses tinyLoanScale=-12, rounds + // up at -12 (a no-op) => min == cover => owner. + runTest(/*vaultDeposit=*/1'000, [&](Env& env, Ctx const& c) { + auto const loans = setupLoansAndClawback(env, c); + if (!loans) + return; + BEAST_EXPECT(!feeGoesToPseudo(env, c, loans->tiny)); + }); + } + + // Scenario 2 — LoanBrokerCoverWithdraw + // + // Verify that CoverWithdraw's minimum cover check uses vault scale + // (not scale(debtTotal, asset)). Before the amendment, CoverWithdraw + // used: + // roundToAsset(asset, tenthBipsOfValue(debt, rate), scale(debt, asset)) + // which could disagree with LoanPay's minimum (which used loanScale). + // + // Use a large vault deposit so that vaultScale (from AssetsTotal) is + // strictly larger than debtScale (from DebtTotal). With + // vaultDeposit = 100,000: after the big loan + // AssetsTotal ≈ 109,500 → vaultScale = -10 + // DebtTotal ≈ 10,000 → debtScale = -11 + // The one-order-of-magnitude gap makes roundToAsset at -10 truncate + // more aggressively than at -11, exposing the bug. + testcase("CoverWithdraw minimum cover scale consistency"); + runTest( + /*vaultDeposit=*/100'000, [&](Env& env, Ctx const& c) { + Asset const asset{c.iou}; + + // Create only the big loan to push DebtTotal up to ~10,000 + // while AssetsTotal stays around 109,500 (dominated by the + // large vault deposit). + env(set(c.borrower, c.broker.brokerID, Number{500}), + Sig(sfCounterpartySignature, c.lender), + kInterestRate(TenthBips32{100'000}), + kPaymentTotal(20), + kPaymentInterval(86400 * 365), + Fee(XRP(10))); + env.close(); + + // Read broker state and compute both old and new minimums. + auto const brokerSle = env.le(keylet::loanBroker(c.broker.brokerID)); + auto const vaultSle = env.le(keylet::vault(c.broker.vaultID)); + if (!BEAST_EXPECT(brokerSle) || !BEAST_EXPECT(vaultSle)) + return; + + auto const coverAvail = brokerSle->at(sfCoverAvailable); + auto const debtTotal = brokerSle->at(sfDebtTotal); + auto const vaultScale = getAssetsTotalScale(vaultSle); + auto const debtScale = scale(debtTotal, asset); + + // Sanity: debt scale differs from vault scale for this setup. + BEAST_EXPECT(debtScale < vaultScale); + + auto const oldMin = [&]() { + NumberRoundModeGuard const mg(Number::RoundingMode::Upward); + return roundToAsset( + asset, + tenthBipsOfValue(debtTotal, TenthBips32{c.brokerParams.coverRateMin}), + debtScale); + }(); + auto const newMin = minimumBrokerCover( + debtTotal, TenthBips32{c.brokerParams.coverRateMin}, vaultSle); + + // The new (vaultScale) minimum must be strictly larger than + // the old (debtScale) minimum — that is the gap the amendment + // closes. + Number const expectedNewMin{1330650518688500000, -15}; + Number const expectedOldMin{1330650518688472000, -15}; + BEAST_EXPECT(newMin == expectedNewMin); + BEAST_EXPECT(oldMin == expectedOldMin); + + // Try to withdraw so that remaining cover lands between the + // two minimums: oldMin < target < newMin. + auto const target = oldMin + (newMin - oldMin) / 2; + auto const withdrawAmount = STAmount{asset, coverAvail - target}; + + if (withAmendment) + { + // CoverWithdraw now uses vaultScale: target < newMin + // => FAILS. + env(coverWithdraw(c.lender, c.broker.brokerID, withdrawAmount), + Ter(tecINSUFFICIENT_FUNDS)); + } + else + { + // Old CoverWithdraw uses debtScale: target > oldMin + // => SUCCEEDS. + env(coverWithdraw(c.lender, c.broker.brokerID, withdrawAmount)); + } + env.close(); + }); + + // Scenario 3 — LoanSet + // + // Verify that LoanSet's minimum cover check uses vault scale (not the + // raw unrounded tenthBipsOfValue). Before the amendment, LoanSet + // used tenthBipsOfValue(newDebtTotal, coverRateMinimum) (no + // roundToAsset), while clawback/withdraw used different formulas. + // After the amendment all use minimumBrokerCover at vaultScale, and + // rounding at a coarser scale can absorb a tiny debt increase — + // allowing a loan that would otherwise be rejected. + testcase("LoanSet minimum cover scale consistency"); + runTest( + /*vaultDeposit=*/1'000, [&](Env& env, Ctx const& c) { + // Create the tiny loan (scale -12) AND the big loan (scale + // -11). Both loans are needed so that DebtTotal has a full + // 16-digit mantissa — a "messy" value where roundToAsset at + // vaultScale actually truncates digits and produces a + // different result from the raw tenthBipsOfValue. With only + // the big loan, DebtTotal has ~4 significant digits and + // rounding at scale -11 is a no-op, masking the amendment's + // effect. + env(set(c.borrower, c.broker.brokerID, Number{1, -2}), + Sig(sfCounterpartySignature, c.lender), + kInterestRate(TenthBips32{0}), + kPaymentTotal(1), + kPaymentInterval(86400 * 365), + Fee(XRP(10))); + env.close(); + + env(set(c.borrower, c.broker.brokerID, Number{500}), + Sig(sfCounterpartySignature, c.lender), + kInterestRate(TenthBips32{100'000}), + kPaymentTotal(20), + kPaymentInterval(86400 * 365), + Fee(XRP(10))); + env.close(); + + // Clawback to reduce cover to the clawback transactor's + // minimum. Pass the exact amount rather than relying on the + // transactor to clip down; the setup matches Scenario 1 so + // the same residual-cover values apply. + Number const expectedCoverAfter = withAmendment ? Number{1330651855688460000, -15} + : Number{1330651855688458000, -15}; + Number const clawbackAmount = + Number{c.brokerParams.coverDeposit} - expectedCoverAfter; + env(coverClawback(c.issuer), + kLoanBrokerId(c.broker.brokerID), + kAmount(c.iou(clawbackAmount))); + env.close(); + + // Verify scales. + auto const vaultSle = env.le(keylet::vault(c.broker.vaultID)); + if (!BEAST_EXPECT(vaultSle)) + return; + auto const vaultScale = getAssetsTotalScale(vaultSle); + BEAST_EXPECT(vaultScale == -11); + + // Now try to create a tiny additional loan. Principal is + // 1e-11 (the smallest value that survives the precision + // check at loanScale = vaultScale = -11), with 0% interest + // and 1 payment. + // + // The tiny debt increase adds ~1.337e-12 to the unrounded + // minimum. + // - Without the amendment: the old LoanSet formula rounds + // up during tenthBipsOfValue (16-digit Number + // normalisation), pushing the minimum past the cover left + // by clawback => tecINSUFFICIENT_FUNDS. + // - With the amendment: minimumBrokerCover rounds at + // vaultScale=-11, which absorbs the tiny increase — the + // rounded minimum stays the same => tesSUCCESS. + auto const tinyPrincipal = Number{1, -11}; + + if (withAmendment) + { + env(set(c.borrower, c.broker.brokerID, tinyPrincipal), + Sig(sfCounterpartySignature, c.lender), + kInterestRate(TenthBips32{0}), + kPaymentTotal(1), + kPaymentInterval(86400 * 365), + Fee(XRP(10))); + } + else + { + env(set(c.borrower, c.broker.brokerID, tinyPrincipal), + Sig(sfCounterpartySignature, c.lender), + kInterestRate(TenthBips32{0}), + kPaymentTotal(1), + kPaymentInterval(86400 * 365), + Fee(XRP(10)), + Ter(tecINSUFFICIENT_FUNDS)); + } + env.close(); + }); + } + + // Tests run under each entry in amendmentCombinations(). + void + runAmendmentSensitive(FeatureBitset features) + { + testLoanPayComputePeriodicPaymentInvariants(features); + testLoanPayDebtDecreaseInvariant(features); + testAccountSendMptMinAmountInvariant(features); + testMinimumBrokerCoverConsistency(features); + } + +public: + void + run() override + { + for (auto const& features : jtx::amendmentCombinations( + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) + runAmendmentSensitive(features); + } +}; + +BEAST_DEFINE_TESTSUITE(LoanInvariants, tx, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/lending/LoanLifecycle_test.cpp b/src/test/app/lending/LoanLifecycle_test.cpp new file mode 100644 index 0000000000..e9868769b7 --- /dev/null +++ b/src/test/app/lending/LoanLifecycle_test.cpp @@ -0,0 +1,689 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl::test { + +class LoanLifecycle_test : public LoanTestBase +{ +private: + void + testLifecycle(FeatureBitset features) + { + testcase("Lifecycle"); + using namespace jtx; + + // Create 3 loan brokers: one for XRP, one for an IOU, and one for + // an MPT. That'll require three corresponding SAVs. + Env env(*this, features); + + Account const issuer{"issuer"}; + // For simplicity, lender will be the sole actor for the vault & + // brokers. + Account const lender{"lender"}; + // Borrower only wants to borrow + Account const borrower{"borrower"}; + // Evan will attempt to be naughty + Account const evan{"evan"}; + // Do not fund alice + Account const alice{"alice"}; + + // Fund the accounts and trust lines with the same amount so that + // tests can use the same values regardless of the asset. + env.fund(XRP(100'000'000), issuer, noripple(lender, borrower, evan)); + env.close(); + + // Create assets + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + PrettyAsset const iouAsset = issuer[iouCurrency_]; + env(trust(lender, iouAsset(10'000'000))); + env(trust(borrower, iouAsset(10'000'000))); + env(trust(evan, iouAsset(10'000'000))); + env(pay(issuer, evan, iouAsset(1'000'000))); + env(pay(issuer, lender, iouAsset(10'000'000))); + // Fund the borrower with enough to cover interest and fees + env(pay(issuer, borrower, iouAsset(10'000))); + env.close(); + + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); + // Scale the MPT asset a little bit so we can get some interest + PrettyAsset const mptAsset{mptt.issuanceID(), 100}; + mptt.authorize({.account = lender}); + mptt.authorize({.account = borrower}); + mptt.authorize({.account = evan}); + env(pay(issuer, lender, mptAsset(10'000'000))); + env(pay(issuer, evan, mptAsset(1'000'000))); + // Fund the borrower with enough to cover interest and fees + env(pay(issuer, borrower, mptAsset(10'000))); + env.close(); + + std::array const assets{iouAsset, xrpAsset, mptAsset}; + + // Create vaults and loan brokers + std::vector brokers; + brokers.reserve(assets.size()); + for (auto const& asset : assets) + { + brokers.emplace_back(createVaultAndBroker( + env, asset, lender, BrokerParameters{.data = "spam spam spam spam"})); + } + + // Create and update Loans + for (auto const& broker : brokers) + { + for (int amountExponent = 3; amountExponent >= 3; --amountExponent) + { + Number const loanAmount{1, amountExponent}; + for (int interestExponent = 0; interestExponent >= 0; --interestExponent) + { + testCaseWrapper(env, mptt, assets, broker, loanAmount, interestExponent); + } + } + + if (auto brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + BEAST_EXPECT(brokerSle)) + { + BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); + BEAST_EXPECT(brokerSle->at(sfDebtTotal) == 0); + + auto const coverAvailable = brokerSle->at(sfCoverAvailable); + env(loan_broker::coverWithdraw( + lender, broker.brokerID, STAmount(broker.asset, coverAvailable))); + env.close(); + + brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + BEAST_EXPECT(brokerSle && brokerSle->at(sfCoverAvailable) == 0); + } + // Verify we can delete the loan broker + env(loan_broker::del(lender, broker.brokerID)); + env.close(); + } + } + + void + testSelfLoan(FeatureBitset features) + { + testcase << "Self Loan"; + + using namespace jtx; + using namespace std::chrono_literals; + // Create 3 loan brokers: one for XRP, one for an IOU, and one for + // an MPT. That'll require three corresponding SAVs. + Env env(*this, features); + + Account const issuer{"issuer"}; + // For simplicity, lender will be the sole actor for the vault & + // brokers. + Account const lender{"lender"}; + + // Fund the accounts and trust lines with the same amount so that + // tests can use the same values regardless of the asset. + env.fund(XRP(100'000'000), issuer, noripple(lender)); + env.close(); + + // Use an XRP asset for simplicity + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + + // Create vaults and loan brokers + BrokerInfo broker{createVaultAndBroker(env, xrpAsset, lender)}; + + using namespace loan; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{1, 3}; + + // The LoanSet json can be created without a counterparty signature, + // but it will not pass preflight + auto createJson = env.json( + set(lender, broker.brokerID, broker.asset(principalRequest).value()), Fee(loanSetFee)); + env(createJson, Ter(temBAD_SIGNER)); + + // Adding an empty counterparty signature object also fails, but + // at the RPC level. + createJson = env.json(createJson, Json(sfCounterpartySignature, json::ValueType::Object)); + env(createJson, Ter(telENV_RPC_FAILED)); + + if (auto const jt = env.jt(createJson); BEAST_EXPECT(jt.stx)) + { + Serializer s; + jt.stx->add(s); + auto const jr = env.rpc("submit", strHex(s.slice())); + + BEAST_EXPECT(jr.isMember(jss::result)); + auto const jResult = jr[jss::result]; + BEAST_EXPECT(jResult[jss::error] == "invalidTransaction"); + BEAST_EXPECT( + jResult[jss::error_exception] == + "fails local checks: Transaction has bad signature."); + } + + // Copy the transaction signature into the counterparty signature. + json::Value counterpartyJson{json::ValueType::Object}; + counterpartyJson[sfTxnSignature] = createJson[sfTxnSignature]; + counterpartyJson[sfSigningPubKey] = createJson[sfSigningPubKey]; + if (!BEAST_EXPECT(!createJson.isMember(jss::Signers))) + counterpartyJson[sfSigners] = createJson[sfSigners]; + + // The duplicated signature works + createJson = env.json(createJson, Json(sfCounterpartySignature, counterpartyJson)); + env(createJson); + + env.close(); + + auto const startDate = env.current()->header().parentCloseTime; + + // Loan is successfully created + { + auto const res = env.rpc("account_objects", lender.human()); + auto const objects = res[jss::result][jss::account_objects]; + + std::map types; + BEAST_EXPECT(objects.size() == 4); + for (auto const& object : objects) + { + ++types[object[sfLedgerEntryType].asString()]; + } + BEAST_EXPECT(types.size() == 4); + for (std::string const type : {"MPToken", "Vault", "LoanBroker", "Loan"}) + { + BEAST_EXPECT(types[type] == 1); + } + } + auto const loanID = [&]() { + json::Value params(json::ValueType::Object); + params[jss::account] = lender.human(); + params[jss::type] = "Loan"; + auto const res = env.rpc("json", "account_objects", to_string(params)); + auto const objects = res[jss::result][jss::account_objects]; + + BEAST_EXPECT(objects.size() == 1); + + auto const loan = objects[0u]; + BEAST_EXPECT(loan[sfBorrower] == lender.human()); + // soeDEFAULT fields are not returned if they're in the default + // state + BEAST_EXPECT(!loan.isMember(sfCloseInterestRate)); + BEAST_EXPECT(!loan.isMember(sfClosePaymentFee)); + BEAST_EXPECT(loan[sfFlags] == 0); + BEAST_EXPECT(loan[sfGracePeriod] == 60); + BEAST_EXPECT(!loan.isMember(sfInterestRate)); + BEAST_EXPECT(!loan.isMember(sfLateInterestRate)); + BEAST_EXPECT(!loan.isMember(sfLatePaymentFee)); + BEAST_EXPECT(loan[sfLoanBrokerID] == to_string(broker.brokerID)); + BEAST_EXPECT(!loan.isMember(sfLoanOriginationFee)); + BEAST_EXPECT(loan[sfLoanSequence] == 1); + BEAST_EXPECT(!loan.isMember(sfLoanServiceFee)); + BEAST_EXPECT(loan[sfNextPaymentDueDate] == loan[sfStartDate].asUInt() + 60); + BEAST_EXPECT(!loan.isMember(sfOverpaymentFee)); + BEAST_EXPECT(!loan.isMember(sfOverpaymentInterestRate)); + BEAST_EXPECT(loan[sfPaymentInterval] == 60); + BEAST_EXPECT(loan[sfPeriodicPayment] == "1000000000"); + BEAST_EXPECT(loan[sfPaymentRemaining] == 1); + BEAST_EXPECT(!loan.isMember(sfPreviousPaymentDueDate)); + BEAST_EXPECT(loan[sfPrincipalOutstanding] == "1000000000"); + BEAST_EXPECT(loan[sfTotalValueOutstanding] == "1000000000"); + BEAST_EXPECT(!loan.isMember(sfLoanScale)); + BEAST_EXPECT(loan[sfStartDate].asUInt() == startDate.time_since_epoch().count()); + + return loan["index"].asString(); + }(); + auto const loanKeylet{keylet::loan(uint256{std::string_view(loanID)})}; + + env.close(startDate); + + // Make a payment + env(pay(lender, loanKeylet.key, broker.asset(1000))); + } + + void + testIssuerLoan() + { + testcase << "Issuer Loan"; + + using namespace jtx; + using namespace loan; + Account const issuer("issuer"); + Account const borrower = issuer; + Account const lender("lender"); + Env env(*this); + + env.fund(XRP(1'000), issuer, lender); + + static constexpr std::int64_t kIssuerBalance = 10'000'000; + MPTTester const asset( + {.env = env, .issuer = issuer, .holders = {lender}, .pay = kIssuerBalance}); + + BrokerParameters const brokerParams{ + .debtMax = 200, + }; + auto const broker = createVaultAndBroker(env, asset, lender, brokerParams); + auto const loanSetFee = Fee(env.current()->fees().base * 2); + // Create Loan + env(set(borrower, broker.brokerID, 200), Sig(sfCounterpartySignature, lender), loanSetFee); + env.close(); + // Issuer should not create MPToken + BEAST_EXPECT(!env.le(keylet::mptoken(asset.issuanceID(), issuer))); + // Issuer "borrowed" 200, OutstandingAmount decreased by 200 + BEAST_EXPECT(env.balance(issuer, asset) == asset(-kIssuerBalance + 200)); + // Pay Loan + auto const loanKeylet = keylet::loan(broker.brokerID, 1); + env(pay(borrower, loanKeylet.key, asset(200))); + env.close(); + // Issuer "re-payed" 200, OutstandingAmount increased by 200 + BEAST_EXPECT(env.balance(issuer, asset) == asset(-kIssuerBalance)); + } + + void + testBorrowerIsBroker() + { + testcase("Test Borrower is Broker"); + using namespace jtx; + using namespace loan; + Account const broker{"broker"}; + Account const issuer{"issuer"}; + Account const borrower{"borrower"}; + Account const depositor{"depositor"}; + + auto testLoanAsset = [&](auto&& getMaxDebt, auto const& borrower) { + Env env(*this); + Vault const vault(env); + + if (borrower == broker) + { + env.fund(XRP(10'000), broker, issuer, depositor); + } + else + { + env.fund(XRP(10'000), broker, borrower, issuer, depositor); + } + env.close(); + + auto const xrpFee = XRP(100); + auto const txFee = Fee(xrpFee); + + STAmount const debtMaximumRequest = getMaxDebt(env); + + auto const& asset = debtMaximumRequest.asset(); + auto const initialVault = asset(debtMaximumRequest * 100); + + auto [tx, vaultKeylet] = vault.create({.owner = broker, .asset = asset}); + env(tx, txFee); + env.close(); + + env(vault.deposit( + {.depositor = depositor, .id = vaultKeylet.key, .amount = initialVault}), + txFee); + env.close(); + + auto const brokerKeylet = keylet::loanBroker(broker.id(), env.seq(broker)); + + env(loan_broker::set(broker, vaultKeylet.key), txFee); + env.close(); + + auto const serviceFee = 101; + + env(set(broker, brokerKeylet.key, debtMaximumRequest), + kCounterparty(borrower), + Sig(sfCounterpartySignature, borrower), + kLoanServiceFee(serviceFee), + kPaymentTotal(10), + txFee); + env.close(); + + std::uint32_t const loanSequence = 1; + auto const loanKeylet = keylet::loan(brokerKeylet.key, loanSequence); + + auto const brokerBalanceBefore = env.balance(broker, asset); + + if (auto const loanSle = env.le(loanKeylet); env.test.BEAST_EXPECT(loanSle)) + { + auto const payment = loanSle->at(sfPeriodicPayment); + auto const totalPayment = payment + serviceFee; + env(loan::pay(borrower, loanKeylet.key, asset(totalPayment)), txFee); + env.close(); + if (auto const vaultSle = env.le(vaultKeylet); BEAST_EXPECT(vaultSle)) + { + auto const expected = [&]() { + // The service fee is transferred to the broker if + // a borrower is not the broker + if (borrower != broker) + return brokerBalanceBefore.number() + serviceFee; + // Since a borrower is the broker, the payment is + // transferred to the Vault from the broker but not + // the service fee. + // If the asset is XRP then the broker pays the txFee. + if (asset.native()) + return brokerBalanceBefore.number() - payment - xrpFee.number(); + return brokerBalanceBefore.number() - payment; + }(); + BEAST_EXPECT(env.balance(broker, asset).value() == asset(expected).value()); + } + } + }; + // Test when a borrower is the broker and is not to verify correct + // service fee transfer in both cases. + for (auto const& borrowerAcct : {broker, borrower}) + { + testLoanAsset( + [&](Env&) -> STAmount { return STAmount{XRPAmount{200'000}}; }, borrowerAcct); + testLoanAsset( + [&](Env& env) -> STAmount { + auto const iou = issuer["USD"]; + env(trust(broker, iou(1'000'000'000))); + env(trust(depositor, iou(1'000'000'000))); + env(pay(issuer, broker, iou(100'000'000))); + env(pay(issuer, depositor, iou(100'000'000))); + env.close(); + return iou(200'000); + }, + borrowerAcct); + testLoanAsset( + [&](Env& env) -> STAmount { + MPTTester const mpt( + {.env = env, + .issuer = issuer, + .holders = {broker, depositor}, + .pay = 100'000'000}); + return mpt(200'000); + }, + borrowerAcct); + } + } + + void + testIssuerIsBorrower(FeatureBitset features) + { + testcase("RIPD-4096 - Issuer as borrower"); + + using namespace jtx; + + Account const issuer("issuer"); + Account const lender("lender"); + + BrokerParameters const brokerParams{ + .vaultDeposit = 100'000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + LoanParameters const loanParams{ + .account = lender, .counter = issuer, .principalRequest = Number{10000}}; + + auto const assetType = AssetType::IOU; + + Env env{*this, features}; + + auto loanResult = + createLoan(env, assetType, brokerParams, loanParams, issuer, lender, issuer); + + if (BEAST_EXPECT(loanResult); !loanResult.has_value()) + return; + + auto broker = std::get(*loanResult); + auto loanKeylet = std::get(*loanResult); + auto pseudoAcct = std::get(*loanResult); + + VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); + + makeLoanPayments( + env, + broker, + loanParams, + loanKeylet, + verifyLoanStatus, + issuer, + lender, + issuer, + PaymentParameters{.showStepBalances = true}); + } + + void + testBatchBypassCounterparty(FeatureBitset features) + { + // From FIND-001 + testcase << "Batch Bypass Counterparty"; + + bool const lendingBatchEnabled = !std::ranges::any_of( + Batch::kDisabledTxTypes, [](auto const& disabled) { return disabled == ttLOAN_SET; }); + + using namespace jtx; + using namespace std::chrono_literals; + Env env(*this, features); + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + BrokerParameters const brokerParams; + env.fund(XRP(brokerParams.vaultDeposit * 100), lender, borrower); + env.close(); + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + using namespace loan; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{1, 3}; + + auto forgedLoanSet = set(borrower, broker.brokerID, principalRequest, 0); + + json::Value randomData{json::ValueType::Object}; + randomData[jss::SigningPubKey] = json::StaticString{"2600"}; + json::Value sigObject{json::ValueType::Object}; + sigObject[jss::SigningPubKey] = strHex(lender.pk().slice()); + Serializer ss; + ss.add32(HashPrefix::TxSign); + parse(randomData).addWithoutSigningFields(ss); + auto const sig = xrpl::sign(borrower.pk(), borrower.sk(), ss.slice()); + sigObject[jss::TxnSignature] = strHex(Slice{sig.data(), sig.size()}); + + forgedLoanSet[json::StaticString{"CounterpartySignature"}] = sigObject; + + // ? Fails because the lender hasn't signed the tx + env(env.json(forgedLoanSet, Fee(loanSetFee)), Ter(telENV_RPC_FAILED)); + + auto const seq = env.seq(borrower); + auto const batchFee = batch::calcBatchFee(env, 1, 2); + // ! Should fail because the lender hasn't signed the tx + env(batch::outer(borrower, seq, batchFee, tfAllOrNothing), + batch::Inner(forgedLoanSet, seq + 1), + batch::Inner(pay(borrower, lender, XRP(1)), seq + 2), + Ter(lendingBatchEnabled ? temBAD_SIGNATURE : temINVALID_INNER_BATCH)); + env.close(); + + // ? Check that the loan was NOT created + { + json::Value params(json::ValueType::Object); + params[jss::account] = borrower.human(); + params[jss::type] = "Loan"; + auto const res = env.rpc("json", "account_objects", to_string(params)); + auto const objects = res[jss::result][jss::account_objects]; + BEAST_EXPECT(objects.size() == 0); + } + } + + // Integration test: full lifecycle of a $1B loan in the bug regime. + // Verifies that the vault collects the economically-correct interest + // income and that conservation holds at the trust-line level. + // + // Pre-fix (closed-form `power(1+r, n) - 1`): vault collected only + // ~$0.058 per $1B due to cancellation of `(1+r)^n - 1` at r*n ~ 5.7e-10. + // Post-fix (hybrid binomial path): vault collects ~$0.38 per $1B, + // matching the value computed independently with arbitrary-precision + // Decimal arithmetic. + void + testFullLifecycleVaultPnLNearZeroRate() + { + testcase("integration: full loan lifecycle, vault interest at near-zero rate"); + + using namespace jtx; + using namespace jtx::loan; + using namespace std::chrono_literals; + Env env(*this, all_); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + env(fset(issuer, asfDefaultRipple)); + env.close(); + + PrettyAsset const iouAsset = issuer["USD"]; + STAmount const trustLimit{iouAsset.raw(), Number{1, 17}}; + env(trust(lender, trustLimit)); + env(trust(borrower, trustLimit)); + env.close(); + env(pay(issuer, lender, iouAsset(5'000'000'000LL))); + env(pay(issuer, borrower, iouAsset(5'000'000'000LL))); + env.close(); + + auto usdBalance = [&](Account const& a) { + return env.balance(a, iouAsset.raw().get()).value(); + }; + STAmount const borrowerStartBal = usdBalance(borrower); + + BrokerParameters const brokerParams{ + .vaultDeposit = Number{2, 9}, + .debtMax = Number{0}, + .coverRateMin = TenthBips32{0}, + .coverDeposit = 0, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender, brokerParams)}; + + auto const vaultBefore = env.le(broker.vaultKeylet()); + if (!BEAST_EXPECT(vaultBefore)) + return; + Number const vaultAvailableBefore = vaultBefore->at(sfAssetsAvailable); + + // Loan: $1B principal, 3 payments, 600s interval, rate=1 TenthBips32. + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{1, 9}; + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + Fee(loanSetFee), + Json(sfCounterpartySignature, json::ValueType::Object)); + createJson["InterestRate"] = 1; + createJson["PaymentTotal"] = 3; + createJson["PaymentInterval"] = 600; + + auto const loanKeylet = nextLoanKeylet(env, broker); + createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); + env(createJson, Ter(tesSUCCESS)); + env.close(); + + auto const loanSle = env.le(loanKeylet); + if (!BEAST_EXPECT(loanSle)) + return; + Number const expectedTotalInterest = + loanSle->at(sfTotalValueOutstanding) - loanSle->at(sfPrincipalOutstanding); + + env(pay(borrower, loanKeylet.key, iouAsset(1'500'000'000LL)), Ter(tesSUCCESS)); + env.close(); + + auto const vaultAfter = env.le(broker.vaultKeylet()); + if (!BEAST_EXPECT(vaultAfter)) + return; + Number const vaultAvailableAfter = vaultAfter->at(sfAssetsAvailable); + Number const vaultGain = vaultAvailableAfter - vaultAvailableBefore; + + STAmount const borrowerEndBal = usdBalance(borrower); + STAmount const borrowerNetOut = borrowerStartBal - borrowerEndBal; + + // Self-consistency: vault gained exactly the expected interest + // computed at LoanSet, and the borrower's outflow matches. + BEAST_EXPECT(vaultGain == expectedTotalInterest); + BEAST_EXPECT(Number(borrowerNetOut) == expectedTotalInterest); + + // Mathematical correctness: the total interest for this loan + // configuration is 0.38051750382930729983, calculated + // independently using 50-digit Decimal arithmetic (no + // cancellation possible at that precision). At Number's 19-digit + // mantissa this rounds to 0.38051750382930729 — the literal + // below. The vault's actual gain must agree to within + // sub-microcent precision. + Number const decimalReference{38051750382930729LL, -17}; + Number const tolerance{1, -6}; // 1e-6 USD = sub-microcent + Number const error = abs(vaultGain - decimalReference); + BEAST_EXPECTS( + error < tolerance, + "vault gain " + to_string(vaultGain) + " differs from Decimal reference " + + to_string(decimalReference) + " by " + to_string(error) + " — exceeds tolerance " + + to_string(tolerance)); + } + + void + runAmendmentIndependent() + { + testIssuerLoan(); + testBorrowerIsBroker(); + testFullLifecycleVaultPnLNearZeroRate(); + } + + // Tests run under each entry in amendmentCombinations(). + void + runAmendmentSensitive(FeatureBitset features) + { + testLifecycle(features); + testSelfLoan(features); + testIssuerIsBorrower(features); + testBatchBypassCounterparty(features); + } + +public: + void + run() override + { + runAmendmentIndependent(); + for (auto const& features : jtx::amendmentCombinations( + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) + runAmendmentSensitive(features); + } +}; + +BEAST_DEFINE_TESTSUITE(LoanLifecycle, tx, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/lending/LoanMisc_test.cpp b/src/test/app/lending/LoanMisc_test.cpp new file mode 100644 index 0000000000..798cddda17 --- /dev/null +++ b/src/test/app/lending/LoanMisc_test.cpp @@ -0,0 +1,561 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace xrpl::test { + +class LoanMisc_test : public LoanTestBase +{ +private: + void + testRPC(FeatureBitset features) + { + // This will expand as more test cases are added. Some functionality + // is tested in other test functions. + testcase("RPC"); + + using namespace jtx; + + Env env(*this, features); + + auto lowerFee = [&]() { + // Run the local fee back down. + while (env.app().getFeeTrack().lowerLocalFee()) + ; + }; + + auto const baseFee = env.current()->fees().base; + + Account const alice{"alice"}; + std::string const borrowerPass = "borrower"; + Account const borrower{borrowerPass, KeyType::Ed25519}; + auto const lenderPass = "lender"; + Account const lender{lenderPass, KeyType::Ed25519}; + + env.fund(XRP(1'000'000), alice, lender, borrower); + env.close(); + env(noop(lender)); + env(noop(lender)); + env(noop(lender)); + env(noop(lender)); + env(noop(lender)); + env.close(); + + { + testcase("RPC AccountSet"); + json::Value txJson{json::ValueType::Object}; + txJson[sfTransactionType] = "AccountSet"; + txJson[sfAccount] = borrower.human(); + + auto const signParams = [&]() { + json::Value signParams{json::ValueType::Object}; + signParams[jss::passphrase] = borrowerPass; + signParams[jss::key_type] = "ed25519"; + signParams[jss::tx_json] = txJson; + return signParams; + }(); + auto const jSign = env.rpc("json", "sign", to_string(signParams)); + BEAST_EXPECT(jSign.isMember(jss::result) && jSign[jss::result].isMember(jss::tx_json)); + auto txSignResult = jSign[jss::result][jss::tx_json]; + auto txSignBlob = jSign[jss::result][jss::tx_blob].asString(); + txSignResult.removeMember(jss::hash); + + auto const jtx = env.jt(txJson, Sig(borrower)); + BEAST_EXPECT(txSignResult == jtx.jv); + + lowerFee(); + auto const jSubmit = env.rpc("submit", txSignBlob); + BEAST_EXPECT( + jSubmit.isMember(jss::result) && + jSubmit[jss::result].isMember(jss::engine_result) && + jSubmit[jss::result][jss::engine_result].asString() == "tesSUCCESS"); + + lowerFee(); + env(jtx.jv, Sig(kNone), Seq(kNone), Fee(kNone), Ter(tefPAST_SEQ)); + } + + { + testcase("RPC LoanSet - illegal signature_target"); + + json::Value txJson{json::ValueType::Object}; + txJson[sfTransactionType] = "AccountSet"; + txJson[sfAccount] = borrower.human(); + + auto const borrowerSignParams = [&]() { + json::Value params{json::ValueType::Object}; + params[jss::passphrase] = borrowerPass; + params[jss::key_type] = "ed25519"; + params[jss::signature_target] = "Destination"; + params[jss::tx_json] = txJson; + return params; + }(); + auto const jSignBorrower = env.rpc("json", "sign", to_string(borrowerSignParams)); + BEAST_EXPECT( + jSignBorrower.isMember(jss::result) && + jSignBorrower[jss::result].isMember(jss::error) && + jSignBorrower[jss::result][jss::error] == "invalidParams" && + jSignBorrower[jss::result].isMember(jss::error_message) && + jSignBorrower[jss::result][jss::error_message] == "Destination"); + } + { + testcase("RPC LoanSet - sign and submit borrower initiated"); + // 1. Borrower creates the transaction + json::Value txJson{json::ValueType::Object}; + txJson[sfTransactionType] = "LoanSet"; + txJson[sfAccount] = borrower.human(); + txJson[sfCounterparty] = lender.human(); + txJson[sfLoanBrokerID] = + "FF924CD18A236C2B49CF8E80A351CEAC6A10171DC9F110025646894FEC" + "F83F" + "5C"; + txJson[sfPrincipalRequested] = "100000000"; + txJson[sfPaymentTotal] = 10000; + txJson[sfPaymentInterval] = 3600; + txJson[sfGracePeriod] = 300; + txJson[sfFlags] = 65536; // tfLoanOverpayment + txJson[sfFee] = to_string(24 * baseFee / 10); + + // 2. Borrower signs the transaction + auto const borrowerSignParams = [&]() { + json::Value params{json::ValueType::Object}; + params[jss::passphrase] = borrowerPass; + params[jss::key_type] = "ed25519"; + params[jss::tx_json] = txJson; + return params; + }(); + auto const jSignBorrower = env.rpc("json", "sign", to_string(borrowerSignParams)); + BEAST_EXPECTS( + jSignBorrower.isMember(jss::result) && + jSignBorrower[jss::result].isMember(jss::tx_json), + to_string(jSignBorrower)); + auto const txBorrowerSignResult = jSignBorrower[jss::result][jss::tx_json]; + auto const txBorrowerSignBlob = jSignBorrower[jss::result][jss::tx_blob].asString(); + + // 2a. Borrower attempts to submit the transaction. It doesn't + // work + { + lowerFee(); + auto const jSubmitBlob = env.rpc("submit", txBorrowerSignBlob); + BEAST_EXPECT(jSubmitBlob.isMember(jss::result)); + auto const jSubmitBlobResult = jSubmitBlob[jss::result]; + BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json)); + // Transaction fails because the CounterpartySignature is + // missing + BEAST_EXPECT( + jSubmitBlobResult.isMember(jss::engine_result) && + jSubmitBlobResult[jss::engine_result].asString() == "temBAD_SIGNER"); + } + + // 3. Borrower sends the signed transaction to the lender + // 4. Lender signs the transaction + auto const lenderSignParams = [&]() { + json::Value params{json::ValueType::Object}; + params[jss::passphrase] = lenderPass; + params[jss::key_type] = "ed25519"; + params[jss::signature_target] = "CounterpartySignature"; + params[jss::tx_json] = txBorrowerSignResult; + return params; + }(); + auto const jSignLender = env.rpc("json", "sign", to_string(lenderSignParams)); + BEAST_EXPECT( + jSignLender.isMember(jss::result) && + jSignLender[jss::result].isMember(jss::tx_json)); + auto const txLenderSignResult = jSignLender[jss::result][jss::tx_json]; + auto const txLenderSignBlob = jSignLender[jss::result][jss::tx_blob].asString(); + + // 5. Lender submits the signed transaction blob + lowerFee(); + auto const jSubmitBlob = env.rpc("submit", txLenderSignBlob); + BEAST_EXPECT(jSubmitBlob.isMember(jss::result)); + auto const jSubmitBlobResult = jSubmitBlob[jss::result]; + BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json)); + auto const jSubmitBlobTx = jSubmitBlobResult[jss::tx_json]; + // To get far enough to return tecNO_ENTRY means that the + // signatures all validated. Of course the transaction won't + // succeed because no Vault or Broker were created. + BEAST_EXPECTS( + jSubmitBlobResult.isMember(jss::engine_result) && + jSubmitBlobResult[jss::engine_result].asString() == "tecNO_ENTRY", + to_string(jSubmitBlobResult)); + + BEAST_EXPECT( + !jSubmitBlob.isMember(jss::error) && !jSubmitBlobResult.isMember(jss::error)); + + // 4-alt. Lender submits the transaction json originally + // received from the Borrower. It gets signed, but is now a + // duplicate, so fails. Borrower could done this instead of + // steps 4 and 5. + lowerFee(); + auto const jSubmitJson = env.rpc("json", "submit", to_string(lenderSignParams)); + BEAST_EXPECT(jSubmitJson.isMember(jss::result)); + auto const jSubmitJsonResult = jSubmitJson[jss::result]; + BEAST_EXPECT(jSubmitJsonResult.isMember(jss::tx_json)); + auto const jSubmitJsonTx = jSubmitJsonResult[jss::tx_json]; + // Since the previous tx claimed a fee, this duplicate is not + // going anywhere + BEAST_EXPECTS( + jSubmitJsonResult.isMember(jss::engine_result) && + jSubmitJsonResult[jss::engine_result].asString() == "tefPAST_SEQ", + to_string(jSubmitJsonResult)); + + BEAST_EXPECT( + !jSubmitJson.isMember(jss::error) && !jSubmitJsonResult.isMember(jss::error)); + + BEAST_EXPECT(jSubmitBlobTx == jSubmitJsonTx); + } + + { + testcase("RPC LoanSet - sign and submit lender initiated"); + // 1. Lender creates the transaction + json::Value txJson{json::ValueType::Object}; + txJson[sfTransactionType] = "LoanSet"; + txJson[sfAccount] = lender.human(); + txJson[sfCounterparty] = borrower.human(); + txJson[sfLoanBrokerID] = + "FF924CD18A236C2B49CF8E80A351CEAC6A10171DC9F110025646894FEC" + "F83F" + "5C"; + txJson[sfPrincipalRequested] = "100000000"; + txJson[sfPaymentTotal] = 10000; + txJson[sfPaymentInterval] = 3600; + txJson[sfGracePeriod] = 300; + txJson[sfFlags] = 65536; // tfLoanOverpayment + txJson[sfFee] = to_string(24 * baseFee / 10); + + // 2. Lender signs the transaction + auto const lenderSignParams = [&]() { + json::Value params{json::ValueType::Object}; + params[jss::passphrase] = lenderPass; + params[jss::key_type] = "ed25519"; + params[jss::tx_json] = txJson; + return params; + }(); + auto const jSignLender = env.rpc("json", "sign", to_string(lenderSignParams)); + BEAST_EXPECT( + jSignLender.isMember(jss::result) && + jSignLender[jss::result].isMember(jss::tx_json)); + auto const txLenderSignResult = jSignLender[jss::result][jss::tx_json]; + auto const txLenderSignBlob = jSignLender[jss::result][jss::tx_blob].asString(); + + // 2a. Lender attempts to submit the transaction. It doesn't + // work + { + lowerFee(); + auto const jSubmitBlob = env.rpc("submit", txLenderSignBlob); + BEAST_EXPECT(jSubmitBlob.isMember(jss::result)); + auto const jSubmitBlobResult = jSubmitBlob[jss::result]; + BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json)); + // Transaction fails because the CounterpartySignature is + // missing + BEAST_EXPECT( + jSubmitBlobResult.isMember(jss::engine_result) && + jSubmitBlobResult[jss::engine_result].asString() == "temBAD_SIGNER"); + } + + // 3. Lender sends the signed transaction to the Borrower + // 4. Borrower signs the transaction + auto const borrowerSignParams = [&]() { + json::Value params{json::ValueType::Object}; + params[jss::passphrase] = borrowerPass; + params[jss::key_type] = "ed25519"; + params[jss::signature_target] = "CounterpartySignature"; + params[jss::tx_json] = txLenderSignResult; + return params; + }(); + auto const jSignBorrower = env.rpc("json", "sign", to_string(borrowerSignParams)); + BEAST_EXPECT( + jSignBorrower.isMember(jss::result) && + jSignBorrower[jss::result].isMember(jss::tx_json)); + auto const txBorrowerSignResult = jSignBorrower[jss::result][jss::tx_json]; + auto const txBorrowerSignBlob = jSignBorrower[jss::result][jss::tx_blob].asString(); + + // 5. Borrower submits the signed transaction blob + lowerFee(); + auto const jSubmitBlob = env.rpc("submit", txBorrowerSignBlob); + BEAST_EXPECT(jSubmitBlob.isMember(jss::result)); + auto const jSubmitBlobResult = jSubmitBlob[jss::result]; + BEAST_EXPECT(jSubmitBlobResult.isMember(jss::tx_json)); + auto const jSubmitBlobTx = jSubmitBlobResult[jss::tx_json]; + // To get far enough to return tecNO_ENTRY means that the + // signatures all validated. Of course the transaction won't + // succeed because no Vault or Broker were created. + BEAST_EXPECTS( + jSubmitBlobResult.isMember(jss::engine_result) && + jSubmitBlobResult[jss::engine_result].asString() == "tecNO_ENTRY", + to_string(jSubmitBlobResult)); + + BEAST_EXPECT( + !jSubmitBlob.isMember(jss::error) && !jSubmitBlobResult.isMember(jss::error)); + + // 4-alt. Borrower submits the transaction json originally + // received from the Lender. It gets signed, but is now a + // duplicate, so fails. Lender could done this instead of steps + // 4 and 5. + lowerFee(); + auto const jSubmitJson = env.rpc("json", "submit", to_string(borrowerSignParams)); + BEAST_EXPECT(jSubmitJson.isMember(jss::result)); + auto const jSubmitJsonResult = jSubmitJson[jss::result]; + BEAST_EXPECT(jSubmitJsonResult.isMember(jss::tx_json)); + auto const jSubmitJsonTx = jSubmitJsonResult[jss::tx_json]; + // Since the previous tx claimed a fee, this duplicate is not + // going anywhere + BEAST_EXPECTS( + jSubmitJsonResult.isMember(jss::engine_result) && + jSubmitJsonResult[jss::engine_result].asString() == "tefPAST_SEQ", + to_string(jSubmitJsonResult)); + + BEAST_EXPECT( + !jSubmitJson.isMember(jss::error) && !jSubmitJsonResult.isMember(jss::error)); + + BEAST_EXPECT(jSubmitBlobTx == jSubmitJsonTx); + } + } + + void + testLendingCanTradeDisabledNoImpact() + { + testcase("Lending: CanTrade disabled has no impact"); + using namespace jtx; + using namespace loan; + using namespace loan_broker; + + Env env(*this, all_); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + + MPTTester mpt( + {.env = env, + .issuer = issuer, + .holders = {lender, borrower}, + .flags = tfMPTCanTransfer | tfMPTCanLock, + .mutableFlags = tmfMPTCanEnableCanTrade}); + PrettyAsset const asset = mpt.issuanceID(); + env(pay(issuer, lender, asset(10'000'000))); + env(pay(issuer, borrower, asset(100'000))); + env.close(); + + auto const broker = createVaultAndBroker(env, asset, lender); + + // CanTrade is not set + env(offer(lender, XRP(1), asset(10)), Ter{tecNO_PERMISSION}); + env.close(); + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + + // New cover deposits still work. + env(coverDeposit(lender, broker.brokerID, asset(100))); + env.close(); + + // New loan issuance still works. + env(loan::set(borrower, broker.brokerID, 1'000), + Sig(sfCounterpartySignature, lender), + loanSetFee); + env.close(); + auto const loanKeylet = keylet::loan(broker.brokerID, 1); + BEAST_EXPECT(env.le(loanKeylet)); + + // Repayment still works. + env(pay(borrower, loanKeylet.key, asset(1'000))); + env.close(); + + // Cover withdrawal still works. + env(coverWithdraw(lender, broker.brokerID, asset(100))); + env.close(); + + // Enable CanTrade and verify the DEX path is restored. + mpt.set({.mutableFlags = tmfMPTSetCanTrade}); + env.close(); + + env(offer(lender, XRP(1), asset(10))); + env.close(); + } + + void + runAmendmentIndependent() + { + testLendingCanTradeDisabledNoImpact(); + } + + // Tests run under each entry in amendmentCombinations(). + void + runAmendmentSensitive(FeatureBitset features) + { + testRPC(features); + } + +public: + void + run() override + { + runAmendmentIndependent(); + for (auto const& features : jtx::amendmentCombinations( + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) + runAmendmentSensitive(features); + } +}; + +class LoanBatch_test : public LoanTestBase +{ +protected: + beast::xor_shift_engine engine_; + + std::uniform_int_distribution<> assetDist_{0, 2}; + std::uniform_int_distribution principalDist_{100'000, 1'000'000'000}; + std::uniform_int_distribution interestRateDist_{0, 10000}; + std::uniform_int_distribution<> paymentTotalDist_{12, 10000}; + std::uniform_int_distribution<> paymentIntervalDist_{60, 3600 * 24 * 30}; + std::uniform_int_distribution managementFeeRateDist_{0, 10'000}; + std::uniform_int_distribution<> serviceFeeDist_{0, 20}; + /* + # Generate parameters that are more likely to be valid + principal = Decimal(str(rand.randint(100000, + 100'000'000))).quantize(ROUND_TARGET) + + interest_rate = Decimal(rand.randint(1, 10000)) / + Decimal(100000) + + payment_total = rand.randint(12, 10000) + + payment_interval = Decimal(str(rand.randint(60, 2629746))) + + interest_fee = Decimal(rand.randint(0, 100000)) / + Decimal(100000) +*/ + + void + testRandomLoan() + { + using namespace jtx; + + Account const issuer("issuer"); + Account const lender("lender"); + Account const borrower("borrower"); + + // Determine all the random parameters at once + auto const assetType = static_cast(assetDist_(engine_)); + auto const principalRequest = principalDist_(engine_); + TenthBips16 const managementFeeRate{managementFeeRateDist_(engine_)}; + auto const serviceFee = serviceFeeDist_(engine_); + TenthBips32 interest{interestRateDist_(engine_)}; + auto const payTotal = paymentTotalDist_(engine_); + auto const payInterval = paymentIntervalDist_(engine_); + + BrokerParameters const brokerParams{ + .vaultDeposit = principalRequest * 10, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .managementFeeRate = managementFeeRate}; + LoanParameters const loanParams{ + .account = lender, + .counter = borrower, + .principalRequest = principalRequest, + .serviceFee = serviceFee, + .interest = interest, + .payTotal = payTotal, + .payInterval = payInterval, + }; + + runLoan(assetType, brokerParams, loanParams, all_); + } + +public: + void + run() override + { + auto const numIterations = [s = arg()]() -> int { + int const defaultNum = 5; + if (s.empty()) + return defaultNum; + try + { + std::size_t pos = 0; + auto const r = stoi(s, &pos); + if (pos != s.size()) + return defaultNum; + return r; + } + catch (...) + { + return defaultNum; + } + }(); + + using namespace jtx; + + auto const updateInterval = std::max(std::min(numIterations / 5, 100), 1); + + for (int i = 0; i < numIterations; ++i) + { + if (i % updateInterval == 0) + testcase << "Random Loan Test iteration " << (i + 1) << "/" << numIterations; + testRandomLoan(); + } + } +}; + +class LoanArbitrary_test : public LoanBatch_test +{ + void + run() override + { + using namespace jtx; + + BrokerParameters const brokerParams{ + .vaultDeposit = 10000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + LoanParameters const loanParams{ + .account = Account("lender"), + .counter = Account("borrower"), + .principalRequest = Number{200000, -6}, + .interest = TenthBips32{50000}, + .payTotal = 2, + .payInterval = 200}; + + runLoan(AssetType::XRP, brokerParams, loanParams, all_); + } +}; + +BEAST_DEFINE_TESTSUITE(LoanMisc, tx, xrpl); +BEAST_DEFINE_TESTSUITE_MANUAL(LoanBatch, tx, xrpl); +BEAST_DEFINE_TESTSUITE_MANUAL(LoanArbitrary, tx, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/lending/LoanPay_test.cpp b/src/test/app/lending/LoanPay_test.cpp new file mode 100644 index 0000000000..e06d728c87 --- /dev/null +++ b/src/test/app/lending/LoanPay_test.cpp @@ -0,0 +1,760 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace xrpl::test { + +class LoanPay_test : public LoanTestBase +{ +private: +#if LOAN_TODO + void + testLoanPayLateFullPaymentBypassesPenalties(FeatureBitset features) + { + testcase("LoanPay full payment skips late penalties"); + using namespace jtx; + using namespace loan; + using namespace std::chrono_literals; + + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + + PrettyAsset const asset = issuer[iouCurrency]; + env(trust(lender, asset(100'000'000))); + env(trust(borrower, asset(100'000'000))); + env(pay(issuer, lender, asset(50'000'000))); + env(pay(issuer, borrower, asset(5'000'000))); + env.close(); + + BrokerInfo broker{createVaultAndBroker(env, asset, lender)}; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + + auto const brokerPreLoan = env.le(keylet::loanBroker(broker.brokerID)); + if (BEAST_EXPECT(brokerPreLoan); !brokerPreLoan.has_value()) + return; + + auto const loanSequence = brokerPreLoan->at(sfLoanSequence); + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + Number const principal = asset(1'000).value(); + Number const serviceFee = asset(2).value(); + Number const lateFee = asset(5).value(); + Number const closeFee = asset(4).value(); + + env(set(borrower, broker.brokerID, principal), + Sig(sfCounterpartySignature, lender), + kLoanServiceFee(serviceFee), + kLatePaymentFee(lateFee), + kClosePaymentFee(closeFee), + kInterestRate(percentageToTenthBips(12)), + kLateInterestRate(percentageToTenthBips(24) / 10), + kCloseInterestRate(percentageToTenthBips(5)), + kPaymentTotal(12), + kPaymentInterval(600), + kGracePeriod(0), + Fee(loanSetFee)); + env.close(); + + auto state1 = getCurrentState(env, broker, loanKeylet); + if (!BEAST_EXPECT(state1.paymentRemaining > 1)) + return; + + using d = NetClock::duration; + using tp = NetClock::time_point; + auto const overdueClose = tp{d{state1.nextPaymentDate + state1.paymentInterval}}; + env.close(overdueClose); + + auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + auto const loanSle = env.le(loanKeylet); + if (!BEAST_EXPECT(brokerSle && loanSle)) + return; + + auto state = getCurrentState(env, broker, loanKeylet); + + TenthBips16 const managementFeeRate{brokerSle->at(sfManagementFeeRate)}; + TenthBips32 const interestRateValue{loanSle->at(sfInterestRate)}; + TenthBips32 const lateInterestRateValue{loanSle->at(sfLateInterestRate)}; + TenthBips32 const closeInterestRateValue{loanSle->at(sfCloseInterestRate)}; + + Number const closePaymentFeeRounded = + roundToAsset(broker.asset, loanSle->at(sfClosePaymentFee), state.loanScale); + Number const latePaymentFeeRounded = + roundToAsset(broker.asset, loanSle->at(sfLatePaymentFee), state.loanScale); + + auto const roundedLoanState = constructLoanState( + state.totalValue, state.principalOutstanding, state.managementFeeOutstanding); + Number const totalInterestOutstanding = roundedLoanState.interestDue; + + auto const periodicRate = loanPeriodicRate(interestRateValue, state.paymentInterval); + auto const rawLoanState = computeTheoreticalLoanState( + env.current()->rules(), + state.periodicPayment, + periodicRate, + state.paymentRemaining, + managementFeeRate); + + auto const parentCloseTime = env.current()->parentCloseTime(); + auto const startDateSeconds = + static_cast(state.startDate.time_since_epoch().count()); + + Number const fullPaymentInterest = computeFullPaymentInterest( + rawLoanState.principalOutstanding, + periodicRate, + parentCloseTime, + state.paymentInterval, + state.previousPaymentDate, + startDateSeconds, + closeInterestRateValue); + + Number const roundedFullInterestAmount = + roundToAsset(broker.asset, fullPaymentInterest, state.loanScale); + Number const roundedFullManagementFee = computeManagementFee( + broker.asset, roundedFullInterestAmount, managementFeeRate, state.loanScale); + Number const roundedFullInterest = roundedFullInterestAmount - roundedFullManagementFee; + + Number const trackedValueDelta = + state.principalOutstanding + totalInterestOutstanding + state.managementFeeOutstanding; + Number const untrackedManagementFee = + closePaymentFeeRounded + roundedFullManagementFee - state.managementFeeOutstanding; + Number const untrackedInterest = roundedFullInterest - totalInterestOutstanding; + + Number const baseFullDue = trackedValueDelta + untrackedInterest + untrackedManagementFee; + BEAST_EXPECT(baseFullDue == roundToAsset(broker.asset, baseFullDue, state.loanScale)); + + auto const overdueSeconds = + parentCloseTime.time_since_epoch().count() - state.nextPaymentDate; + if (!BEAST_EXPECT(overdueSeconds > 0)) + return; + + Number const overdueRate = loanPeriodicRate(lateInterestRateValue, overdueSeconds); + Number const lateInterestRaw = state.principalOutstanding * overdueRate; + Number const lateInterestRounded = + roundToAsset(broker.asset, lateInterestRaw, state.loanScale); + Number const lateManagementFeeRounded = computeManagementFee( + broker.asset, lateInterestRounded, managementFeeRate, state.loanScale); + Number const penaltyDue = + lateInterestRounded + lateManagementFeeRounded + latePaymentFeeRounded; + BEAST_EXPECT(penaltyDue > Number{}); + + auto const balanceBefore = env.balance(borrower, broker.asset).number(); + + STAmount const paymentAmount{broker.asset.raw(), baseFullDue}; + env(pay(borrower, loanKeylet.key, paymentAmount, tfLoanFullPayment)); + env.close(); + + if (auto const meta = env.meta(); BEAST_EXPECT(meta)) + BEAST_EXPECT(meta->at(sfTransactionResult) == tesSUCCESS); + + auto const balanceAfter = env.balance(borrower, broker.asset).number(); + Number const actualPaid = balanceBefore - balanceAfter; + BEAST_EXPECT(actualPaid == baseFullDue); + + Number const expectedWithPenalty = baseFullDue + penaltyDue; + BEAST_EXPECT(expectedWithPenalty > actualPaid); + BEAST_EXPECT(expectedWithPenalty - actualPaid == penaltyDue); + } +#endif + + void + testOverpaymentManagementFee(FeatureBitset features) + { + testcase("testOverpaymentManagementFee"); + + using namespace jtx; + using namespace loan; + + Env env{*this, features}; + + Account const lender{"lender"}, borrower{"borrower"}; + + env.fund(XRP(10'000'000), lender, borrower); + env.close(); + + PrettyAsset const asset{xrpIssue(), 1000}; + + auto const result = createVaultAndBroker( + env, + asset, + lender, + { + .vaultDeposit = asset(100'000).value(), + .managementFeeRate = TenthBips16(10'000), + }); + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + + auto const brokerSle = env.le(result.brokerKeylet()); + if (!BEAST_EXPECT(brokerSle)) + return; + auto const loanKeylet = + keylet::loan(result.brokerKeylet().key, brokerSle->at(sfLoanSequence)); + env(loan::set( + borrower, result.brokerKeylet().key, asset(10'000).value(), tfLoanOverpayment), + Sig(sfCounterpartySignature, lender), + loan::kPaymentInterval(86400 * 30), + loan::kPaymentTotal(3), + loan::kOverpaymentInterestRate(TenthBips32(percentageToTenthBips(20))), + loanSetFee); + + // From calculator + auto const expectedOverpaymentManagementFee = Number{33333, 0}; + auto const loanBrokerBalanceBefore = env.balance(lender); + + auto const loanPayFee = Fee(env.current()->fees().base * 2); + env(pay(borrower, loanKeylet.key, asset(5'000).value(), tfLoanOverpayment), loanPayFee); + env.close(); + + BEAST_EXPECTS( + env.balance(lender) - loanBrokerBalanceBefore == expectedOverpaymentManagementFee, + "overpayment management fee mismatch; expected:" + + to_string(expectedOverpaymentManagementFee) + + " got: " + to_string(env.balance(lender) - loanBrokerBalanceBefore)); + } + + void + testDosLoanPay(FeatureBitset features) + { + bool const feeCapped = features[fixCleanup3_1_3]; + + // From FIND-005 + testcase << "DoS LoanPay: fee calculation " << (feeCapped ? "capped" : "uncapped"); + + using namespace jtx; + using namespace std::chrono_literals; + using namespace lending; + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + + BEAST_EXPECT(feeCapped == env.current()->rules().enabled(fixCleanup3_1_3)); + + PrettyAsset const iouAsset = issuer[iouCurrency_]; + env(trust(lender, iouAsset(100'000'000))); + env(trust(borrower, iouAsset(100'000'000))); + env(pay(issuer, lender, iouAsset(10'000'000))); + env(pay(issuer, borrower, iouAsset(1'000))); + env.close(); + + BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender)}; + + using namespace loan; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{3959'37, -2}; + auto const baseFee = env.current()->fees().base; + + auto const createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + Fee(loanSetFee), + Json(sfCounterpartySignature, json::ValueType::Object), + kClosePaymentFee(0), + kGracePeriod(60), + kInterestRate(TenthBips32(20930)), + kLateInterestRate(TenthBips32(77049)), + kLatePaymentFee(0), + kLoanServiceFee(0), + kOverpaymentFee(TenthBips32(7)), + kOverpaymentInterestRate(TenthBips32(66653)), + kPaymentInterval(60), + kPaymentTotal(3239184)); + + // There are enough payments due on this loan that it only needs to be + // created once, and can be paid on multiple times. Just don't create a + // gazillion test cases. + auto const keylet = nextLoanKeylet(env, broker); + + env(createJson, Sig(sfCounterpartySignature, lender)); + env.close(); + + auto const roundedPayment = [&]() { + auto const stateBefore = getCurrentState(env, broker, keylet); + BEAST_EXPECT(stateBefore.paymentRemaining == 3239184); + BEAST_EXPECT(stateBefore.paymentRemaining > kLoanMaximumPaymentsPerTransaction); + + return roundToAsset( + iouAsset, + stateBefore.periodicPayment, + stateBefore.loanScale, + Number::RoundingMode::Upward); + }(); + + auto test = [&](int const payFactor, + int const feeFactor, + TER const expectedTer = tesSUCCESS) { + auto const stateBefore = getCurrentState(env, broker, keylet); + BEAST_EXPECT(stateBefore.paymentRemaining <= 3239184); + BEAST_EXPECT(stateBefore.paymentRemaining > kLoanMaximumPaymentsPerTransaction); + + Number const amount = roundedPayment * payFactor; + auto loanPayTx = env.json(pay(borrower, keylet.key, STAmount{broker.asset, amount})); + XRPAmount const payFee{baseFee * feeFactor}; + env(loanPayTx, Ter(expectedTer), Fee(payFee)); + env.close(); + auto const expectedChange = isTesSuccess(expectedTer) + ? std::min(kLoanMaximumPaymentsPerTransaction, payFactor) + : 0; + + auto const stateAfter = getCurrentState(env, broker, keylet); + BEAST_EXPECT( + stateAfter.paymentRemaining == stateBefore.paymentRemaining - expectedChange); + }; + + static constexpr std::int64_t kMaxFeeIncrements = + kLoanMaximumPaymentsPerTransaction / kLoanPaymentsPerFeeIncrement; + + TER const failWithoutFix = feeCapped ? (TER)tesSUCCESS : (TER)telINSUF_FEE_P; + + // * Amount well above threshold -> capped fee + // The original test case - way over the limit - more fee is always ok + test(1819878, 363976); + // The capped fee is only sufficient if the amendment is enabled. + test(1819878, kMaxFeeIncrements, failWithoutFix); + + // * Amount exactly at threshold -> capped fee + test(kLoanMaximumPaymentsPerTransaction, kMaxFeeIncrements); + // More fee is always ok + test(kLoanMaximumPaymentsPerTransaction, kMaxFeeIncrements + 10); + + // * Amount below threshold -> normal calculation + test(1, 1); + test(kLoanPaymentsPerFeeIncrement * 2, 2); + test(0, 0, temBAD_AMOUNT); + test(0, 1, temBAD_AMOUNT); + // Fee difference rounds evenly + test( + kLoanMaximumPaymentsPerTransaction - 10, + ((kLoanMaximumPaymentsPerTransaction - 10) / kLoanPaymentsPerFeeIncrement) - 1, + telINSUF_FEE_P); + test( + kLoanMaximumPaymentsPerTransaction - 10, + ((kLoanMaximumPaymentsPerTransaction - 10) / kLoanPaymentsPerFeeIncrement)); + // More fee is always ok + test( + kLoanMaximumPaymentsPerTransaction - 10, + ((kLoanMaximumPaymentsPerTransaction - 10) / kLoanPaymentsPerFeeIncrement) + 3); + // Fee rounds up + for (int under = 1; under < kLoanPaymentsPerFeeIncrement; ++under) + { + test(kLoanMaximumPaymentsPerTransaction - under, kMaxFeeIncrements - 1, telINSUF_FEE_P); + test(kLoanMaximumPaymentsPerTransaction - under, kMaxFeeIncrements); + } + // Only when you get one less fee increment can you pay less + test( + kLoanMaximumPaymentsPerTransaction - kLoanPaymentsPerFeeIncrement, + kMaxFeeIncrements - 1); + // And again, more fee is always ok. + test(kLoanMaximumPaymentsPerTransaction - kLoanPaymentsPerFeeIncrement, kMaxFeeIncrements); + } + + // A LoanSet with InterestRate = 1 (0.001% annualized, the minimum non-zero + // rate). At such a near-zero rate the closed-form payment factor + // (1 + r)^n - 1 cancels catastrophically. + // + // Without fixCleanup3_2_0 the resulting amortization is degenerate and the + // LoanSet is rejected with tecPRECISION_LOSS (no loan created). With the + // amendment, computePowerMinusOneHybrid uses a numerically-stable series + // expansion, so the loan is created and the scheduled payments + // (2 * periodicPayment) cover the principal — no economic underpayment + // (yield theft). + // + // The test runs the same LoanSet under both amendment settings and pins the + // exact outcome for each. + void + testLoanSetNearZeroInterestRateSucceeds() + { + testcase("LoanSet near-zero interest rate covers principal"); + + using namespace jtx; + using namespace loan; + + Number const principalRequested{1000}; + + struct Result + { + TER ter = tesSUCCESS; + bool created = false; + std::int32_t loanScale = 0; + Number principal; + Number totalValue; + Number managementFee; + Number periodicPayment; + }; + + auto runScenario = [&](FeatureBitset features, TER expectedTer) -> Result { + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"vaultOwner"}; + Account const borrower{"borrower"}; + + PrettyAsset const iouAsset = createFundedRippleIouAsset(env, issuer, lender, borrower); + + auto const broker = createVaultAndBroker( + env, + iouAsset, + lender, + {.vaultDeposit = 100'000, .debtMax = 0, .managementFeeRate = TenthBips16{0}}); + + auto const brokerSle = env.le(broker.brokerKeylet()); + BEAST_EXPECT(brokerSle); + auto const loanSequence = brokerSle ? brokerSle->at(sfLoanSequence) : 0; + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + env(set(borrower, broker.brokerID, principalRequested), + Sig(sfCounterpartySignature, lender), + kInterestRate(TenthBips32{1}), + kPaymentTotal(2), + kPaymentInterval(400), + Fee(env.current()->fees().base * 2), + Ter(expectedTer)); + env.close(); + + Result r; + r.ter = env.ter(); + if (auto const loanSle = env.le(loanKeylet)) + { + r.created = true; + r.loanScale = loanSle->at(sfLoanScale); + r.principal = loanSle->at(sfPrincipalOutstanding); + r.totalValue = loanSle->at(sfTotalValueOutstanding); + r.managementFee = loanSle->at(sfManagementFeeOutstanding); + r.periodicPayment = loanSle->at(sfPeriodicPayment); + } + return r; + }; + + Result const fixed = runScenario(all_, tesSUCCESS); + Result const legacy = runScenario(all_ - fixCleanup3_2_0, tecPRECISION_LOSS); + + // Without the amendment, the catastrophically-cancelling closed-form + // payment factor produces a degenerate amortization that fails + // checkLoanGuards: the LoanSet is rejected with tecPRECISION_LOSS and no + // loan is created. + BEAST_EXPECT(legacy.ter == tecPRECISION_LOSS); + BEAST_EXPECT(!legacy.created); + + // With the amendment the stable series expansion produces a valid loan + // at loanScale -10. + BEAST_EXPECT(fixed.ter == tesSUCCESS); + BEAST_EXPECT(fixed.created); + BEAST_EXPECT(fixed.loanScale == -10); + BEAST_EXPECT(fixed.principal == principalRequested); + BEAST_EXPECT((fixed.totalValue == Number{10000000001903, -10})); + BEAST_EXPECT(fixed.managementFee == beast::kZero); + + // Periodic payment from the numerically-stable series expansion, and the + // scheduled total (2 * periodicPayment) which exceeds the 1000 principal + // — no economic underpayment / yield theft. + BEAST_EXPECT((fixed.periodicPayment == Number{5000000000951293762, -16})); + BEAST_EXPECT((fixed.periodicPayment * 2 == Number{1000000000190258752, -15})); + BEAST_EXPECT(fixed.periodicPayment * 2 > principalRequested); + } + + void + testLoanNextPaymentDueDateOverflow(FeatureBitset features) + { + // For FIND-013 + testcase << "Prevent nextPaymentDueDate overflow"; + + using namespace jtx; + using namespace std::chrono_literals; + using namespace lending; + Env env{*this, features}; + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + PrettyAsset const iouAsset = + createFundedIouAsset(env, issuer, lender, borrower, 100'000'000, 10'000'000); + + BrokerParameters const brokerParams{.debtMax = Number{0}, .coverRateMin = TenthBips32{1}}; + BrokerInfo broker{createVaultAndBroker(env, iouAsset, lender, brokerParams)}; + + using namespace loan; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + + using timeType = decltype(sfNextPaymentDueDate)::type::value_type; + static_assert(std::is_same_v); + constexpr timeType kMaxTime = std::numeric_limits::max(); + static_assert(kMaxTime == 4'294'967'295); + + auto const baseJson = [&]() { + auto createJson = env.json( + set(borrower, broker.brokerID, Number{55524'81, -2}), + Fee(loanSetFee), + kClosePaymentFee(0), + kGracePeriod(LoanSet::kDefaultGracePeriod), + kInterestRate(TenthBips32(12833)), + kLateInterestRate(TenthBips32(77048)), + kLatePaymentFee(0), + kLoanOriginationFee(218), + Json(sfCounterpartySignature, json::ValueType::Object)); + + createJson.removeMember(sfSequence.getJsonName()); + + return createJson; + }(); + + auto const baseFee = env.current()->fees().base; + + auto parentCloseTime = [&]() { + return env.current()->parentCloseTime().time_since_epoch().count(); + }; + auto maxLoanTime = [&]() { + auto const startDate = parentCloseTime(); + + BEAST_EXPECT(startDate >= 50); + + return kMaxTime - startDate; + }; + + { + // straight-up overflow: interval + auto const interval = maxLoanTime() + 1; + auto const total = 1; + auto createJson = env.json(baseJson, kPaymentInterval(interval), kPaymentTotal(total)); + + env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); + env.close(); + } + { + // straight-up overflow: total + // min interval is 60 + auto const interval = 60; + auto const total = maxLoanTime() + 1; + auto createJson = env.json(baseJson, kPaymentInterval(interval), kPaymentTotal(total)); + + env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); + env.close(); + } + { + // straight-up overflow: grace period + // min interval is 60 + auto const interval = maxLoanTime() + 1; + auto const total = 1; + auto const grace = interval; + auto createJson = env.json( + baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace)); + + // The grace period can't be larger than the interval. + env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); + env.close(); + } + { + // Overflow with multiplication of a few large intervals + auto const interval = 1'000'000'000; + auto const total = 10; + auto createJson = env.json(baseJson, kPaymentInterval(interval), kPaymentTotal(total)); + + env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); + env.close(); + } + { + // Overflow with multiplication of many small payments + // min interval is 60 + auto const interval = 60; + auto const total = 1'000'000'000; + auto createJson = env.json(baseJson, kPaymentInterval(interval), kPaymentTotal(total)); + + env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); + env.close(); + } + { + // Overflow with an absurdly large grace period + // min interval is 60 + auto const total = 60; + auto const interval = (maxLoanTime() - total) / total; + auto const grace = interval; + auto createJson = env.json( + baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace)); + + env(createJson, Sig(sfCounterpartySignature, lender), Ter(tecKILLED)); + env.close(); + } + { + // Start date when the ledger is closed will be larger + auto const keylet = nextLoanKeylet(env, broker); + + auto const grace = 100; + auto const interval = maxLoanTime() - grace; + auto const total = 1; + auto createJson = env.json( + baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace)); + + env(createJson, Sig(sfCounterpartySignature, lender), Ter(tesSUCCESS)); + env.close(); + + // The transaction is killed in the closed ledger + auto const meta = env.meta(); + if (BEAST_EXPECT(meta)) + { + BEAST_EXPECT(meta->at(sfTransactionResult) == tecKILLED); + } + + // If the transaction had succeeded, the loan would exist + auto const loanSle = env.le(keylet); + // but it doesn't + BEAST_EXPECT(!loanSle); + } + { + // Start date when the ledger is closed will be larger + auto const keylet = nextLoanKeylet(env, broker); + + auto const closeStartDate = ((parentCloseTime() / 10) + 1) * 10; + auto const grace = 5'000; + auto const interval = kMaxTime - closeStartDate - grace; + auto const total = 1; + auto createJson = env.json( + baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace)); + + env(createJson, Sig(sfCounterpartySignature, lender), Ter(tesSUCCESS)); + env.close(); + + // The transaction succeeds in the closed ledger + auto const meta = env.meta(); + if (BEAST_EXPECT(meta)) + { + BEAST_EXPECT(meta->at(sfTransactionResult) == tesSUCCESS); + } + + // This loan exists + auto const afterState = getCurrentState(env, broker, keylet); + BEAST_EXPECT(afterState.nextPaymentDate == kMaxTime - grace); + BEAST_EXPECT(afterState.previousPaymentDate == 0); + BEAST_EXPECT(afterState.paymentRemaining == 1); + } + + { + // Ensure the borrower has funds to pay back the loan + env(pay(issuer, borrower, iouAsset(Number{1'055'524'81, -2}))); + + // Start date when the ledger is closed will be larger + auto const closeStartDate = ((parentCloseTime() / 10) + 1) * 10; + auto const grace = 5'000; + auto const maxLoanTime = kMaxTime - closeStartDate - grace; + auto const total = [&]() { + if (maxLoanTime % 5 == 0) + return 5; + if (maxLoanTime % 3 == 0) + return 3; + if (maxLoanTime % 2 == 0) + return 2; + return 0; + }(); + if (!BEAST_EXPECT(total != 0)) + return; + + auto const brokerState = env.le(keylet::loanBroker(broker.brokerID)); + if (!BEAST_EXPECT(brokerState)) + return; + // Intentionally shadow the outer values + auto const loanSequence = brokerState->at(sfLoanSequence); + auto const keylet = keylet::loan(broker.brokerID, loanSequence); + + auto const interval = maxLoanTime / total; + auto createJson = env.json( + baseJson, kPaymentInterval(interval), kPaymentTotal(total), kGracePeriod(grace)); + + env(createJson, Sig(sfCounterpartySignature, lender), Ter(tesSUCCESS)); + env.close(); + + // This loan exists + auto const beforeState = getCurrentState(env, broker, keylet); + BEAST_EXPECT(beforeState.nextPaymentDate == closeStartDate + interval); + BEAST_EXPECT(beforeState.previousPaymentDate == 0); + BEAST_EXPECT(beforeState.paymentRemaining == total); + BEAST_EXPECT(beforeState.periodicPayment > 0); + + // pay all but the last payment + { + NumberRoundModeGuard const mg{Number::RoundingMode::Upward}; + Number const payment = beforeState.periodicPayment * (total - 1); + XRPAmount const payFee{baseFee * ((total - 1) / kLoanPaymentsPerFeeIncrement + 1)}; + STAmount const paymentAmount = + roundToScale(STAmount{broker.asset, payment}, beforeState.loanScale); + auto loanPayTx = env.json(pay(borrower, keylet.key, paymentAmount), Fee(payFee)); + env(loanPayTx, Ter(tesSUCCESS)); + env.close(); + } + + // The loan is on the last payment + auto const afterState = getCurrentState(env, broker, keylet); + BEAST_EXPECT(afterState.paymentRemaining == 1); + BEAST_EXPECT(afterState.nextPaymentDate == kMaxTime - grace); + BEAST_EXPECT(afterState.previousPaymentDate == kMaxTime - grace - interval); + } + } + + void + runAmendmentIndependent() + { + testLoanSetNearZeroInterestRateSucceeds(); + } + + // Tests run under each entry in amendmentCombinations(). + void + runAmendmentSensitive(FeatureBitset features) + { +#if LOAN_TODO + testLoanPayLateFullPaymentBypassesPenalties(features); +#endif + testOverpaymentManagementFee(features); + testDosLoanPay(features); + testLoanNextPaymentDueDateOverflow(features); + } + +public: + void + run() override + { + runAmendmentIndependent(); + for (auto const& features : jtx::amendmentCombinations( + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) + runAmendmentSensitive(features); + } +}; + +BEAST_DEFINE_TESTSUITE(LoanPay, tx, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/lending/LoanRounding_test.cpp b/src/test/app/lending/LoanRounding_test.cpp new file mode 100644 index 0000000000..b914961e78 --- /dev/null +++ b/src/test/app/lending/LoanRounding_test.cpp @@ -0,0 +1,993 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl::test { + +class LoanRounding_test : public LoanTestBase +{ +private: + void + testDustManipulation(FeatureBitset features) + { + testcase("Dust manipulation"); + + using namespace jtx; + using namespace std::chrono_literals; + Env env{*this, features}; + + // Setup: Create accounts + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + Account const victim{"victim"}; + + env.fund(XRP(1'000'000'00), issuer, lender, borrower, victim); + env.close(); + + // Step 1: Create vault with IOU asset + auto asset = issuer["USD"]; + env(trust(lender, asset(100000))); + env(trust(borrower, asset(100000))); + env(trust(victim, asset(100000))); + env(pay(issuer, lender, asset(50000))); + env(pay(issuer, borrower, asset(50000))); + env(pay(issuer, victim, asset(50000))); + env.close(); + + BrokerParameters const brokerParams{ + .vaultDeposit = 10000, + .debtMax = Number{0}, + .coverRateMin = TenthBips32{1000}, + .coverRateLiquidation = TenthBips32{2500}}; + + auto broker = createVaultAndBroker(env, asset, lender, brokerParams); + + auto const loanKeyletOpt = [&]() -> std::optional { + auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + return std::nullopt; + + // Broker has no loans + BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); + + // The loan keylet is based on the LoanSequence of the + // _LOAN_BROKER_ object. + auto const loanSequence = brokerSle->at(sfLoanSequence); + return keylet::loan(broker.brokerID, loanSequence); + }(); + if (!loanKeyletOpt) + return; + + auto const& vaultKeylet = broker.vaultKeylet(); + + { + auto const vaultSle = env.le(vaultKeylet); + Number const assetsTotal = vaultSle->at(sfAssetsTotal); + Number const assetsAvail = vaultSle->at(sfAssetsAvailable); + + log << "Before loan creation:" << std::endl; + log << " AssetsTotal: " << assetsTotal << std::endl; + log << " AssetsAvailable: " << assetsAvail << std::endl; + log << " Difference: " << (assetsTotal - assetsAvail) << std::endl; + + // before the loan the assets total and available should be equal + BEAST_EXPECT(assetsAvail == assetsTotal); + BEAST_EXPECT(assetsAvail == broker.asset(brokerParams.vaultDeposit).number()); + } + + Keylet const& loanKeylet = *loanKeyletOpt; + + LoanParameters const loanParams{ + .account = lender, + .counter = borrower, + .principalRequest = Number{100}, + .interest = TenthBips32{1922}, + .payTotal = 5816, + .payInterval = 86400 * 6, + .gracePd = 86400 * 5, + }; + + env(loanParams(env, broker)); + env.close(); + + // Wait for loan to be late enough to default + env.close(std::chrono::seconds(86400 * 40)); // 40 days + + { + auto const vaultSle = env.le(vaultKeylet); + Number const assetsTotal = vaultSle->at(sfAssetsTotal); + Number const assetsAvail = vaultSle->at(sfAssetsAvailable); + + log << "After loan creation:" << std::endl; + log << " AssetsTotal: " << assetsTotal << std::endl; + log << " AssetsAvailable: " << assetsAvail << std::endl; + log << " Difference: " << (assetsTotal - assetsAvail) << std::endl; + + auto const loanSle = env.le(loanKeylet); + if (!BEAST_EXPECT(loanSle)) + return; + auto const state = constructLoanState(loanSle); + + log << "Loan state:" << std::endl; + log << " ValueOutstanding: " << state.valueOutstanding << std::endl; + log << " PrincipalOutstanding: " << state.principalOutstanding << std::endl; + log << " InterestOutstanding: " << state.interestOutstanding() << std::endl; + log << " InterestDue: " << state.interestDue << std::endl; + log << " FeeDue: " << state.managementFeeDue << std::endl; + + // after loan creation the assets total and available should + // reflect the value of the loan + BEAST_EXPECT(assetsAvail < assetsTotal); + BEAST_EXPECT( + assetsAvail == + broker.asset(brokerParams.vaultDeposit - loanParams.principalRequest).number()); + BEAST_EXPECT( + assetsTotal == + broker.asset(brokerParams.vaultDeposit + state.interestDue).number()); + } + + // Step 7: Trigger default (dust adjustment will occur) + env(jtx::loan::manage(lender, loanKeylet.key, tfLoanDefault)); + env.close(); + + // Step 8: Verify phantom assets created + { + auto const vaultSle2 = env.le(vaultKeylet); + Number const assetsTotal2 = vaultSle2->at(sfAssetsTotal); + Number const assetsAvail2 = vaultSle2->at(sfAssetsAvailable); + + log << "After default:" << std::endl; + log << " AssetsTotal: " << assetsTotal2 << std::endl; + log << " AssetsAvailable: " << assetsAvail2 << std::endl; + log << " Difference: " << (assetsTotal2 - assetsAvail2) << std::endl; + + // after a default the assets total and available should be equal + BEAST_EXPECT(assetsAvail2 == assetsTotal2); + } + } + + void + testRoundingAllowsUndercoverage(FeatureBitset features) + { + testcase("Minimum cover rounding allows undercoverage (XRP)"); + + using namespace jtx; + using namespace loan_broker; + + Env env{*this, features}; + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(200'000), lender, borrower); + env.close(); + + // Vault with XRP asset + Vault const vault{env}; + auto [vaultCreate, vaultKeylet] = vault.create({.owner = lender, .asset = xrpIssue()}); + env(vaultCreate); + env.close(); + BEAST_EXPECT(env.le(vaultKeylet)); + + // Seed the vault with XRP so it can fund the loan principal + PrettyAsset const xrpAsset{xrpIssue(), 1}; + + BrokerParameters const brokerParams{ + .vaultDeposit = 1'000, + .debtMax = Number{0}, + .coverRateMin = TenthBips32{10'000}, + .coverDeposit = 82, + }; + + auto const brokerInfo = createVaultAndBroker(env, xrpAsset, lender, brokerParams); + // Create a loan with principal 804 XRP and 0% interest (so + // DebtTotal increases by exactly 804) + env(loan::set(borrower, brokerInfo.brokerID, xrpAsset(804).value()), + loan::kInterestRate(TenthBips32(0)), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 2)); + BEAST_EXPECT(env.ter() == tesSUCCESS); + env.close(); + + // Verify DebtTotal is exactly 804 + if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); + BEAST_EXPECT(brokerSle)) + { + log << *brokerSle << std::endl; + BEAST_EXPECT(brokerSle->at(sfDebtTotal) == Number(804)); + } + + // Attempt to withdraw 2 XRP to self, leaving 80 XRP CoverAvailable. + // The minimum is 80.4 XRP, which rounds up to 81 XRP, so this fails. + env(coverWithdraw(lender, brokerInfo.brokerID, xrpAsset(2).value()), + Ter(tecINSUFFICIENT_FUNDS)); + BEAST_EXPECT(env.ter() == tecINSUFFICIENT_FUNDS); + env.close(); + + // Attempt to withdraw 1 XRP to self, leaving 81 XRP CoverAvailable. + // because that leaves sufficient cover, this succeeds + env(coverWithdraw(lender, brokerInfo.brokerID, xrpAsset(1).value())); + BEAST_EXPECT(env.ter() == tesSUCCESS); + env.close(); + + // Validate CoverAvailable == 81 XRP and DebtTotal remains 804 + if (auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); + BEAST_EXPECT(brokerSle)) + { + log << *brokerSle << std::endl; + BEAST_EXPECT(brokerSle->at(sfCoverAvailable) == xrpAsset(81).value()); + BEAST_EXPECT(brokerSle->at(sfDebtTotal) == Number(804)); + + // Also demonstrate that the true minimum (804 * 10%) exceeds 80 + auto const theoreticalMin = tenthBipsOfValue(Number(804), TenthBips32(10'000)); + log << "Theoretical min cover: " << theoreticalMin << std::endl; + BEAST_EXPECT(Number(804, -1) == theoreticalMin); + } + } + + void + testYieldTheftRounding(std::uint32_t flags) + { + testcase("Rounding manipulation does not permit yield theft"); + using namespace jtx; + using namespace loan; + + // 1. Setup Environment + Env env(*this, all_); + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1000), issuer, lender, borrower); + env.close(); + + // 2. Asset Selection + PrettyAsset const iou = issuer["USD"]; + env(trust(lender, iou(100'000'000))); + env(trust(borrower, iou(100'000'000))); + env(pay(issuer, lender, iou(100'000'000))); + env(pay(issuer, borrower, iou(100'000'000))); + env.close(); + + // 3. Create Vault and Broker with High Debt Limit (100M) + auto const brokerInfo = createVaultAndBroker( + env, + iou, + lender, + { + .vaultDeposit = 5'000'000, + .debtMax = Number{100'000'000}, + .coverDeposit = 500'000, + }); + auto const [currentSeq, vaultKeylet] = [&]() { + auto const brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + return std::make_tuple(0u, keylet::unchecked(beast::kZero)); + auto const currentSeq = brokerSle->at(sfLoanSequence); + auto const vaultKeylet = keylet::vault(brokerSle->at(sfVaultID)); + return std::make_tuple(currentSeq, vaultKeylet); + }(); + + // 4. Loan Parameters (Attack Vector) + Number const principal = 1'000'000; + TenthBips32 const interestRate = TenthBips32{1}; // 0.001% + std::uint32_t const paymentInterval = 86400; + std::uint32_t const paymentTotal = 3650; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + env(set(borrower, brokerInfo.brokerID, iou(principal).value(), flags), + Sig(sfCounterpartySignature, lender), + loan::kInterestRate(interestRate), + loan::kPaymentInterval(paymentInterval), + loan::kPaymentTotal(paymentTotal), + Fee(loanSetFee)); + env.close(); + + // --- RETRIEVE OBJECTS & SETUP ATTACK --- + + auto borrowerBalance = [&]() { return env.balance(borrower, iou); }; + auto const borrowerScale = static_cast(borrowerBalance()).exponent(); + + auto const loanKeylet = keylet::loan(brokerInfo.brokerID, currentSeq); + auto const maybePeriodicPayment = [&]() -> std::optional { + auto const loanSle = env.le(loanKeylet); + if (!BEAST_EXPECT(loanSle)) + return std::nullopt; + // Construct Payment + return STAmount{iou, loanSle->at(sfPeriodicPayment)}; + }(); + if (!maybePeriodicPayment) + return; + auto const periodicPayment = *maybePeriodicPayment; + auto const roundedPayment = + roundToScale(periodicPayment, borrowerScale, Number::RoundingMode::Upward); + + // ATTACK: Add dust buffer (1e-9) to force 'excess' logic execution + STAmount const paymentBuffer{iou, Number(1, -9)}; + STAmount const attackPayment = periodicPayment + paymentBuffer; + + auto const maybeInitialVaultAssets = [&]() -> std::optional { + auto const vault = env.le(vaultKeylet); + if (!BEAST_EXPECT(vault)) + return std::nullopt; + return vault->at(sfAssetsTotal); + }(); + if (!maybeInitialVaultAssets) + return; + auto const initialVaultAssets = *maybeInitialVaultAssets; + + // 5. Execution Loop + int yieldTheftCount = 0; + auto previousAssetsTotal = initialVaultAssets; + + for (int i = 0; i < 100; ++i) + { + auto const balanceBefore = borrowerBalance(); + env(pay(borrower, loanKeylet.key, attackPayment, flags)); + env.close(); + auto const borrowerDelta = balanceBefore - borrowerBalance(); + BEAST_EXPECT(borrowerDelta.signum() == roundedPayment.signum()); + + auto const loanSle = env.le(loanKeylet); + if (!BEAST_EXPECT(loanSle)) + break; + auto const updatedPayment = STAmount{iou, loanSle->at(sfPeriodicPayment)}; + BEAST_EXPECT( + (roundToScale(updatedPayment, borrowerScale, Number::RoundingMode::Upward) == + roundedPayment)); + BEAST_EXPECT( + (updatedPayment == periodicPayment) || + (flags == tfLoanOverpayment && i >= 2 && updatedPayment < periodicPayment)); + + auto const currentVaultSle = env.le(vaultKeylet); + if (!BEAST_EXPECT(currentVaultSle)) + break; + + auto const currentAssetsTotal = currentVaultSle->at(sfAssetsTotal); + auto const delta = currentAssetsTotal - previousAssetsTotal; + + BEAST_EXPECT( + (delta == beast::kZero && borrowerDelta <= roundedPayment) || + (delta > beast::kZero && borrowerDelta > roundedPayment)); + + // If tx succeeded but Assets Total didn't change, interest was + // stolen. + if (delta == beast::kZero && borrowerDelta > roundedPayment) + { + yieldTheftCount++; + } + + previousAssetsTotal = currentAssetsTotal; + } + + BEAST_EXPECTS(yieldTheftCount == 0, std::to_string(yieldTheftCount)); + } + + // Regression for the dual-rounding fix at coarse (integer-MPT) scale. + // + // Loan: P=1, r=50% (50000 tenth-bips), n=3, yearly interval. The + // amortization schedule produces a fractional principal + // (~0.47) which under round-to-nearest collapses to 0 in a single + // step, causing `doPayment`'s strict `>` assertion on principal to + // fire mid-loan. With fixCleanup3_2_0 enabled, principal is rounded + // upward (sticks at 1 across the first two periods) and only clears + // in the final payment. + // + // The test pays one period at a time across three LoanPay + // transactions and verifies the loan completes (paymentRemaining=0) + // with totals matching the loan's economics (1 principal + 2 interest). + void + testIntegerScalePrincipalSticks(FeatureBitset features) + { + // Without fixCleanup3_2_0, this behavior will abort the server, so + // don't run without it. + if (!features[fixCleanup3_2_0]) + return; + + testcase("edge: integer MPT principal stuck mid-loan completes via final"); + + using namespace jtx; + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(100'000), issuer, lender, borrower); + env.close(); + + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create({.maxAmt = 100'000, .flags = tfMPTCanTransfer}); + PrettyAsset const asset{mptt.issuanceID()}; + + mptt.authorize({.account = lender}); + mptt.authorize({.account = borrower}); + + env(pay(issuer, lender, asset(10'000))); + env(pay(issuer, borrower, asset(10'000))); + env.close(); + + Vault const vault{env}; + auto [vaultTx, vaultKeylet] = vault.create({.owner = lender, .asset = asset}); + env(vaultTx); + env.close(); + + env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = asset(5'000)})); + env.close(); + + auto const brokerKeylet = keylet::loanBroker(lender.id(), env.seq(lender)); + env(loan_broker::set(lender, vaultKeylet.key), + loan_broker::kDebtMaximum(Number{100}), + Fee(env.current()->fees().base * 2)); + env.close(); + + auto const brokerStateBefore = env.le(brokerKeylet); + if (!BEAST_EXPECT(brokerStateBefore)) + return; + auto const loanSequence = brokerStateBefore->at(sfLoanSequence); + auto const loanKeylet = keylet::loan(brokerKeylet.key, loanSequence); + + env(loan::set(borrower, brokerKeylet.key, Number{1}), + Sig(sfCounterpartySignature, lender), + loan::kInterestRate(TenthBips32{50'000}), + loan::kPaymentTotal(3), + loan::kPaymentInterval(31'536'000), + Fee(env.current()->fees().base * 2)); + env.close(); + + auto const borrowerStart = env.balance(borrower, asset).value(); + + // Three separate periodic payments of 1 each. Expected per-period + // evolution at integer MPT scale (TVO = PO + interestDue + + // managementFeeDue): + // start: PO=1, TVO=3, paymentRemaining=3 + // after pay #1: PO=1, TVO=2, paymentRemaining=2 (principal sticks) + // after pay #2: PO=1, TVO=1, paymentRemaining=1 (principal sticks) + // after pay #3: PO=0, TVO=0, paymentRemaining=0 (final clears) + std::array const expectedPO{Number{1}, Number{1}, Number{0}}; + std::array const expectedTVO{Number{2}, Number{1}, Number{0}}; + std::array const expectedRemaining{2, 1, 0}; + + for (int i = 0; i < 3; ++i) + { + env(loan::pay(borrower, loanKeylet.key, asset(1)), Ter(tesSUCCESS)); + env.close(); + + auto const sle = env.le(loanKeylet); + if (!BEAST_EXPECT(sle)) + return; + BEAST_EXPECT(sle->at(sfPrincipalOutstanding) == expectedPO[i]); + BEAST_EXPECT(sle->at(sfTotalValueOutstanding) == expectedTVO[i]); + BEAST_EXPECT(sle->at(sfPaymentRemaining) == expectedRemaining[i]); + } + + // Borrower paid 3 total regardless of fee split (1 principal + 2 + // interest+fee, matching loan economics). + auto const borrowerEnd = env.balance(borrower, asset).value(); + BEAST_EXPECT(borrowerStart - borrowerEnd == asset(3).value()); + } + +#if LOAN_TODO + void + testLoanCoverMinimumRoundingExploit(FeatureBitset features) + { + auto testLoanCoverMinimumRoundingExploit = [&, this](Number const& principalRequest) { + testcase << "LoanBrokerCoverClawback drains cover via rounding" + << " principalRequested=" << to_string(principalRequest); + + using namespace jtx; + using namespace loan; + using namespace loan_broker; + + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000'000), issuer, lender, borrower); + env.close(); + + env(fset(issuer, asfAllowTrustLineClawback)); + env.close(); + + PrettyAsset const asset = issuer[iouCurrency]; + env(trust(lender, asset(2'000'0000))); + env(trust(borrower, asset(2'000'0000))); + env.close(); + + env(pay(issuer, lender, asset(2'000'0000))); + env.close(); + + BrokerParameters brokerParams{.debtMax = 0, .coverRateMin = TenthBips32{10'000}}; + BrokerInfo broker{createVaultAndBroker(env, asset, lender, brokerParams)}; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + auto createTx = env.jt( + set(borrower, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + loanSetFee, + kPaymentInterval(600), + kPaymentTotal(1), + kGracePeriod(60)); + env(createTx); + env.close(); + + auto const brokerBefore = env.le(keylet::loanBroker(broker.brokerID)); + BEAST_EXPECT(brokerBefore); + if (!brokerBefore) + return; + + Number const debtOutstanding = brokerBefore->at(sfDebtTotal); + Number const coverAvailableBefore = brokerBefore->at(sfCoverAvailable); + + BEAST_EXPECT(debtOutstanding > Number{}); + BEAST_EXPECT(coverAvailableBefore > Number{}); + + log << "debt=" << to_string(debtOutstanding) + << " cover_available=" << to_string(coverAvailableBefore); + + env(coverClawback(issuer, 0), loanBrokerID(broker.brokerID)); + env.close(); + + auto const brokerAfter = env.le(keylet::loanBroker(broker.brokerID)); + BEAST_EXPECT(brokerAfter); + if (!brokerAfter) + return; + + Number const debtAfter = brokerAfter->at(sfDebtTotal); + // the debt has not changed + BEAST_EXPECT(debtAfter == debtOutstanding); + + Number const coverAvailableAfter = brokerAfter->at(sfCoverAvailable); + + // since the cover rate min != 0, the cover available should not + // be zero + BEAST_EXPECT(coverAvailableAfter != Number{}); + }; + + // Call the lambda with different principal values + testLoanCoverMinimumRoundingExploit(Number{1, -30}); // 1e-30 units + testLoanCoverMinimumRoundingExploit(Number{1, -20}); // 1e-20 units + testLoanCoverMinimumRoundingExploit(Number{1, -10}); // 1e-10 units + testLoanCoverMinimumRoundingExploit(Number{1, 1}); // 1e-10 units + } +#endif + + // A residual overpayment can reduce the stored principal by one scale-unit + // *less* than computeOverpaymentComponents predicts, firing the + // "principal change agrees" XRPL_ASSERT_PARTS in doOverpayment: + // + // trackedPrincipalDelta == principalOutstanding - newPrincipalOutstanding + // + // tryOverpayment re-amortizes the loan at the reduced principal, then + // re-derives the theoretical principal from the new periodic payment via + // (P * paymentFactor) / paymentFactor. That round-trip is not exact in + // Number's 19-digit arithmetic; a positive residual pushes the recomputed + // principal a hair above the exact grid point `oldPrincipal - delta`, and + // the Upward rounding in tryOverpayment then bumps it a full scale-unit + // higher. The principal therefore drops by `delta - 1 unit`, not `delta`. + // + // Concrete case (isolated, at the tryOverpayment level): + // A 100 USD loan at the minimum non-zero rate, 3 payments, loanScale -10. + // After one regular payment (principalOutstanding 66.6666666674) a residual overpayment of + // 0.049999998 yields trackedPrincipalDelta 0.048999998 but only reduces the principal by + // 0.0489999979 (newPrincipal 66.6176666695) — short by 1e-10. + // + // With fixCleanup3_2_0, tryOverpayment pins the new principal to the exact, + // on-grid reduction (oldPrincipal - trackedPrincipalDelta) instead of the + // lossy (P*factor)/factor round-trip, so the assertion holds and the + // overpayment applies cleanly. The three "principal change agrees" / + // "interest paid agrees" / "principal payment matches" assertions are + // gated behind the same amendment, so without it they are disabled (the + // server does not abort) and the loan keeps the pre-amendment computation. + // + // The test runs the same scenario under both amendment settings and checks + // the stored principal against a ground-truth value derived independently of + // the loan-state computation under test. + void + testBugOverpaymentPrincipalChange() + { + testcase("bug: doOverpayment asserts 'principal change agrees'"); + + using namespace jtx; + using namespace loan; + using namespace xrpl::detail; + + struct Params + { + TenthBips32 interestRate; + TenthBips16 managementFeeRate; + std::uint32_t paymentTotal; + std::uint32_t paymentInterval; + std::int64_t principal; + Number overpayment; + TenthBips32 overpaymentInterestRate; + TenthBips32 overpaymentFeeRate; + std::optional vaultScale; + }; + + struct Result + { + Number principalOutstanding; // stored principal after the LoanPay + Number expectedNewPrincipal; // ground truth, independent of the fix + Number managementFeeChange; // managementFeeOutstanding after - before + Number unit; // one scale-unit at the loan scale + }; + + auto runScenario = [this](FeatureBitset features, Params const& p) -> Result { + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"vaultOwner"}; + Account const borrower{"borrower"}; + + PrettyAsset const iouAsset = createFundedRippleIouAsset(env, issuer, lender, borrower); + Asset const asset = iouAsset.raw(); + + auto const broker = createVaultAndBroker( + env, + iouAsset, + lender, + {.vaultDeposit = 900'000, + .debtMax = 0, + .managementFeeRate = p.managementFeeRate, + .vaultScale = p.vaultScale}); + + auto const brokerSle = env.le(broker.brokerKeylet()); + BEAST_EXPECT(brokerSle); + auto const loanSequence = brokerSle ? brokerSle->at(sfLoanSequence) : 0; + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + env(set(borrower, broker.brokerID, Number{p.principal}, tfLoanOverpayment), + Sig(sfCounterpartySignature, lender), + kInterestRate(p.interestRate), + kPaymentTotal(p.paymentTotal), + kPaymentInterval(p.paymentInterval), + kGracePeriod(p.paymentInterval), + kOverpaymentFee(p.overpaymentFeeRate), + kOverpaymentInterestRate(p.overpaymentInterestRate), + Fee(env.current()->fees().base * 2), + Ter(tesSUCCESS)); + env.close(); + + // The single LoanPay below makes one regular payment (the overpayment + // is smaller than one period) and leaves the residual as an + // overpayment. + auto const s = getCurrentState(env, broker, loanKeylet); + auto const periodicRate = loanPeriodicRate(s.interestRate, s.paymentInterval); + auto const onePeriod = computePaymentComponents( + env.current()->rules(), + asset, + s.loanScale, + s.totalValue, + s.principalOutstanding, + s.managementFeeOutstanding, + s.periodicPayment, + periodicRate, + s.paymentRemaining, + p.managementFeeRate); + + // Ground truth: the stored principal must drop by exactly the regular + // payment's principal portion plus the overpayment's principal + // portion. computeOverpaymentComponents depends only on the + // overpayment amount and rates (not on the loan-state computation + // under test), so it is an independent oracle. Both components are + // computed under the same rules as the env so the payment factor + // matches. + auto const overpaymentComponents = computeOverpaymentComponents( + env.current()->rules(), + asset, + s.loanScale, + p.overpayment, + p.overpaymentInterestRate, + p.overpaymentFeeRate, + p.managementFeeRate); + Number const expectedNewPrincipal = s.principalOutstanding - + onePeriod.trackedPrincipalDelta - overpaymentComponents.trackedPrincipalDelta; + + Number const managementFeeBefore = s.managementFeeOutstanding; + + STAmount const payAmount{asset, onePeriod.trackedValueDelta + p.overpayment}; + env(pay(borrower, loanKeylet.key, payAmount), + Txflags(tfLoanOverpayment), + Ter(tesSUCCESS)); + env.close(); + + auto const loanSle = env.le(loanKeylet); + BEAST_EXPECT(loanSle); + + return Result{ + .principalOutstanding = loanSle ? Number{loanSle->at(sfPrincipalOutstanding)} : 0, + .expectedNewPrincipal = expectedNewPrincipal, + .managementFeeChange = + (loanSle ? Number{loanSle->at(sfManagementFeeOutstanding)} : Number{0}) - + managementFeeBefore, + .unit = Number{1, s.loanScale}}; + }; + + // Scenario 1: the original near-zero-rate principal reproduction + // (loanScale -10, no management fee). 0.049999998 is smaller than one + // period, so it stays a residual overpayment. + Params const principalCase{ + .interestRate = TenthBips32{1}, + .managementFeeRate = TenthBips16{0}, + .paymentTotal = 3, + .paymentInterval = 60, + .principal = 100, + .overpayment = Number{49999998, -9}, + .overpaymentInterestRate = TenthBips32{1000}, + .overpaymentFeeRate = TenthBips32{1000}, + .vaultScale = 1}; + + // With fixCleanup3_2_0 the stored principal lands exactly on the + // ground-truth grid point: it is reduced by exactly the overpayment's + // principal portion. This is the key correctness check: if the principal + // pin were removed (even with the assertions still gated off), the lossy + // (P * factor) / factor round-trip would leave the principal one + // scale-unit high and this would fail. + Result const fixed = runScenario(all_, principalCase); + BEAST_EXPECTS( + fixed.principalOutstanding == fixed.expectedNewPrincipal, + "fixed principal " + to_string(fixed.principalOutstanding) + " != expected " + + to_string(fixed.expectedNewPrincipal)); + + // Without the amendment the loan amortizes with the catastrophically + // cancelling near-zero payment factor, so its schedule (and ground truth) + // differ from the fixed case; the gated assertions keep the server from + // aborting and the overpayment still lands exactly on that schedule. + Result const legacy = runScenario(all_ - fixCleanup3_2_0, principalCase); + BEAST_EXPECTS( + legacy.principalOutstanding == legacy.expectedNewPrincipal, + "legacy principal " + to_string(legacy.principalOutstanding) + " != expected " + + to_string(legacy.expectedNewPrincipal)); + + // Scenario 2: a normal-rate loan with a 10% management fee. At a normal + // rate the payment factor is identical across the amendment, so toggling + // fixCleanup3_2_0 isolates the fix. This overpayment (found by search) + // lands on a state where both the principal and the management fee differ + // by one scale-unit between the fixed and legacy paths. + Params const feeCase{ + .interestRate = TenthBips32{10000}, + .managementFeeRate = TenthBips16{10000}, + .paymentTotal = 6, + .paymentInterval = 30u * 24 * 60 * 60, + .principal = 1000, + .overpayment = Number{214367363, -10}, + .overpaymentInterestRate = TenthBips32{0}, + .overpaymentFeeRate = TenthBips32{0}, + .vaultScale = std::nullopt}; + + Result const feeFixed = runScenario(all_, feeCase); + Result const feeLegacy = runScenario(all_ - fixCleanup3_2_0, feeCase); + + // With the fix the principal is the exact reduction; without it the lossy + // (P * factor) / factor round-trip leaves it one scale-unit high. + BEAST_EXPECTS( + feeFixed.principalOutstanding == feeFixed.expectedNewPrincipal, + "fee-case fixed principal " + to_string(feeFixed.principalOutstanding) + + " != expected " + to_string(feeFixed.expectedNewPrincipal)); + BEAST_EXPECTS( + feeLegacy.principalOutstanding == feeLegacy.expectedNewPrincipal + feeLegacy.unit, + "fee-case legacy principal " + to_string(feeLegacy.principalOutstanding) + + " != expected " + to_string(feeLegacy.expectedNewPrincipal + feeLegacy.unit)); + + // Management fee: the overpayment re-amortizes a fee-bearing loan, so the management fee + // outstanding drops. + // + // Unlike the principal that is already at the correct precision, the re-amortized + // management fee is tenthBipsOfValue of the new schedule's gross interest, which depends + // on the recomputed periodic payment. So the expected change below is a pinned constant + // captured from a passing run a magic value only because there is nothing simpler to + // compare against. + // + // At the integration level, toggling the amendment also changes the regular payment's + // rounding so a fixed-vs-legacy comparison cannot isolate the overpayment management-fee + // fix. + BEAST_EXPECT(feeFixed.managementFeeChange == feeLegacy.managementFeeChange); + BEAST_EXPECTS( + (feeFixed.managementFeeChange == Number{-8219709543, -10}), + "fee-case mgmt fee change " + to_string(feeFixed.managementFeeChange)); + } + + // An overpayment whose residual amount has more precision than loanScale + // fires the isRounded(asset, overpayment, loanScale) assertion in + // computeOverpaymentComponents (and a downstream "interest paid agrees" + // assertion in doOverpayment). fixCleanup3_2_0 rounds the residual down + // to loanScale before passing it in. The pre-amendment path can't be + // tested here because the assertion fires in Debug builds and aborts + // the test process — see the PR description for context. + void + testBugOverpayUnroundedAmount() + { + testcase("bug: computeOverpaymentComponents isRounded assertion"); + + using namespace jtx; + using namespace loan; + Env env(*this, all_); + + Account const issuer{"issuer"}; + Account const lender{"vaultOwner"}; + Account const borrower{"borrower"}; + + PrettyAsset const iouAsset = createFundedRippleIouAsset(env, issuer, lender, borrower); + + auto const broker = createVaultAndBroker( + env, + iouAsset, + lender, + {.vaultDeposit = 100'000, + .debtMax = 5000, + .managementFeeRate = TenthBips16{1000}, + .vaultScale = 1}); + + auto const sleBroker = env.le(broker.brokerKeylet()); + if (!BEAST_EXPECT(sleBroker)) + return; + auto const loanSequence = sleBroker->at(sfLoanSequence); + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + using namespace loan; + env(set(borrower, broker.brokerID, Number{1000}, tfLoanOverpayment), + Sig(sfCounterpartySignature, lender), + kInterestRate(TenthBips32{10000}), + kPaymentTotal(12), + kPaymentInterval(60), + kGracePeriod(60), + kOverpaymentFee(TenthBips32{1000}), + kOverpaymentInterestRate(TenthBips32{1000}), + Fee(env.current()->fees().base * 2), + Ter(tesSUCCESS)); + env.close(); + + // periodic * 1.5 at 15-sig-digit precision: 125.000154585042. This + // has too many digits to round cleanly to loanScale=-10, so the + // overpayment residual fails the isRounded check. + STAmount const payAmount{iouAsset.raw(), Number{125'000'154'585'042LL, -12}}; + env(pay(borrower, loanKeylet.key, payAmount), Txflags(tfLoanOverpayment), Ter(tesSUCCESS)); + env.close(); + } + + // A near-zero interest rate on a 100 USD loan + // produces total interest of ~6 units at loanScale -9. Numerical error + // in the amortization formula pushes the theoretical principal above + // the theoretical value, producing a negative theoretical interest. + // The payment delta then exceeds the actual outstanding interest, + // violating XRPL_ASSERT_PARTS in computePaymentComponents. + void + testBugInterestDueDeltaCrash() + { + testcase("bug: LoanPay asserts 'interest due delta' on near-zero rate"); + + using namespace jtx; + using namespace std::chrono_literals; + Env env(*this, all_); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + env(fset(issuer, asfDefaultRipple)); + env.close(); + + PrettyAsset const iouAsset = issuer["USD"]; + env(trust(lender, iouAsset(1'000'000'000))); + env(trust(borrower, iouAsset(1'000'000'000))); + env(pay(issuer, lender, iouAsset(5'000'000))); + env(pay(issuer, borrower, iouAsset(5'000'000))); + env.close(); + + BrokerParameters const brokerParams{ + .vaultDeposit = 1'000'000, + .debtMax = 1'000'000, + .coverRateMin = TenthBips32{0}, + .coverDeposit = 0, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + + BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender, brokerParams)}; + + using namespace loan; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{100}; + + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + Fee(loanSetFee), + Json(sfCounterpartySignature, json::ValueType::Object)); + + createJson["InterestRate"] = 1; // minimum non-zero rate + createJson["PaymentTotal"] = 3; + createJson["PaymentInterval"] = 600; + + auto const keylet = nextLoanKeylet(env, broker); + + createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); + env(createJson, Ter(tesSUCCESS)); + env.close(); + + // For principal=100, n=3 the amortization schedule produces a + // periodic payment ≈ 33.33 USD. We pay 35 USD, which is more than + // one period's worth — enough for the LoanPay path to enter + // computePaymentComponents and reach the assertion that fires + // when the bug is present. With the fix, the tx applies cleanly. + env(pay(borrower, keylet.key, iouAsset(35)), Ter(tesSUCCESS)); + env.close(); + } + + void + runAmendmentIndependent() + { + for (auto const flags : {0u, tfLoanOverpayment}) + testYieldTheftRounding(flags); + testBugOverpaymentPrincipalChange(); + testBugOverpayUnroundedAmount(); + testBugInterestDueDeltaCrash(); + } + + // Tests run under each entry in amendmentCombinations(). + void + runAmendmentSensitive(FeatureBitset features) + { + testDustManipulation(features); + testRoundingAllowsUndercoverage(features); + testIntegerScalePrincipalSticks(features); +#if LOAN_TODO + testLoanCoverMinimumRoundingExploit(features); +#endif + } + +public: + void + run() override + { + runAmendmentIndependent(); + for (auto const& features : jtx::amendmentCombinations( + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) + runAmendmentSensitive(features); + } +}; + +BEAST_DEFINE_TESTSUITE(LoanRounding, tx, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/lending/LoanSecurity_test.cpp b/src/test/app/lending/LoanSecurity_test.cpp new file mode 100644 index 0000000000..b08d80b51c --- /dev/null +++ b/src/test/app/lending/LoanSecurity_test.cpp @@ -0,0 +1,538 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace xrpl::test { + +class LoanSecurity_test : public LoanTestBase +{ +private: + void + testPoCUnsignedUnderflowOnFullPayAfterEarlyPeriodic(FeatureBitset features) + { + // --- PoC Summary ---------------------------------------------------- + // Scenario: Borrower makes one periodic payment early (before next due) + // so doPayment sets sfPreviousPaymentDueDate to the (future) + // sfNextPaymentDueDate and advances sfNextPaymentDueDate by one + // interval. Borrower then immediately performs a full-payment + // (tfLoanFullPayment). Why it matters: Full-payment interest accrual + // uses + // delta = now - max(prevPaymentDate, startDate) + // with an unsigned clock representation (uint32). If prevPaymentDate is + // in the future, the subtraction underflows to a very large positive + // number. This inflates roundedFullInterest and total full-close due, + // and LoanPay applies the inflated valueChange to the vault + // (sfAssetsTotal), increasing NAV. + // -------------------------------------------------------------------- + testcase("PoC: Unsigned-underflow full-pay accrual after early periodic"); + + using namespace jtx; + using namespace loan; + using namespace std::chrono_literals; + + Env env{*this, features}; + + Account const lender{"poc_lender4"}; + Account const borrower{"poc_borrower4"}; + env.fund(XRP(3'000'000), lender, borrower); + env.close(); + + PrettyAsset const asset{xrpIssue(), 1'000'000}; + BrokerParameters const brokerParams{}; + auto const broker = createVaultAndBroker(env, asset, lender, brokerParams); + + // Create a 3-payment loan so full-payment path is enabled after 1 + // periodic payment. + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest = asset(1000).value(); + auto const originationFee = asset(0).value(); + auto const serviceFee = asset(1).value(); + auto const serviceFeePA = asset(1); + auto const lateFee = asset(0).value(); + auto const closeFee = asset(0).value(); + auto const interest = percentageToTenthBips(12); + auto const lateInterest = percentageToTenthBips(12) / 10; + auto const closeInterest = percentageToTenthBips(12) / 10; + auto const overpaymentInterest = percentageToTenthBips(12) / 10; + auto const total = 3u; + auto const interval = 600u; + auto const grace = 60u; + + auto createJtx = env.jt( + set(borrower, broker.brokerID, principalRequest, 0), + Sig(sfCounterpartySignature, lender), + kLoanOriginationFee(originationFee), + kLoanServiceFee(serviceFee), + kLatePaymentFee(lateFee), + kClosePaymentFee(closeFee), + kOverpaymentFee(percentageToTenthBips(5) / 10), + kInterestRate(interest), + kLateInterestRate(lateInterest), + kCloseInterestRate(closeInterest), + kOverpaymentInterestRate(overpaymentInterest), + kPaymentTotal(total), + kPaymentInterval(interval), + kGracePeriod(grace), + Fee(loanSetFee)); + + auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + BEAST_EXPECT(brokerSle); + auto const loanSequence = brokerSle ? brokerSle->at(sfLoanSequence) : 0; + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + + env(createJtx); + env.close(); + + // Compute a regular periodic due and pay it early (before next due). + auto state = getCurrentState(env, broker, loanKeylet); + Number const periodicRate = loanPeriodicRate(state.interestRate, state.paymentInterval); + auto const components = xrpl::detail::computePaymentComponents( + env.current()->rules(), + asset.raw(), + state.loanScale, + state.totalValue, + state.principalOutstanding, + state.managementFeeOutstanding, + state.periodicPayment, + periodicRate, + state.paymentRemaining, + brokerParams.managementFeeRate); + STAmount const regularDue{asset, components.trackedValueDelta + serviceFeePA.number()}; + // now < nextDue immediately after creation, so this is an early pay. + env(pay(borrower, loanKeylet.key, regularDue)); + env.close(); + + // Immediately attempt a full payoff. Compute the exact full-payment + // due to ensure the tx applies. + auto after = getCurrentState(env, broker, loanKeylet); + auto const loanSle = env.le(loanKeylet); + BEAST_EXPECT(loanSle); + auto const brokerSle2 = env.le(keylet::loanBroker(broker.brokerID)); + BEAST_EXPECT(brokerSle2); + + auto const closePaymentFee = loanSle ? loanSle->at(sfClosePaymentFee) : Number{}; + auto const closeInterestRate = + loanSle ? TenthBips32{loanSle->at(sfCloseInterestRate)} : TenthBips32{}; + auto const managementFeeRate = + brokerSle2 ? TenthBips16{brokerSle2->at(sfManagementFeeRate)} : TenthBips16{}; + + Number const periodicRate2 = loanPeriodicRate(after.interestRate, after.paymentInterval); + // Accrued + prepayment-penalty interest based on current periodic + // schedule + auto const fullPaymentInterest = computeFullPaymentInterest( + xrpl::detail::loanPrincipalFromPeriodicPayment( + env.current()->rules(), + after.periodicPayment, + periodicRate2, + after.paymentRemaining), + periodicRate2, + env.current()->parentCloseTime(), + after.paymentInterval, + after.previousPaymentDate, + static_cast(after.startDate.time_since_epoch().count()), + closeInterestRate); + + // Round to asset scale and split interest/fee parts + auto const roundedInterest = + roundToAsset(asset.raw(), fullPaymentInterest, after.loanScale); + Number const roundedFullMgmtFee = + computeManagementFee(asset.raw(), roundedInterest, managementFeeRate, after.loanScale); + Number const roundedFullInterest = roundedInterest - roundedFullMgmtFee; + + // Show both signed and unsigned deltas to highlight the underflow. + auto const nowSecs = + static_cast(env.current()->parentCloseTime().time_since_epoch().count()); + auto const startSecs = + static_cast(after.startDate.time_since_epoch().count()); + auto const lastPaymentDate = std::max(after.previousPaymentDate, startSecs); + auto const signedDelta = + static_cast(nowSecs) - static_cast(lastPaymentDate); + auto const unsignedDelta = static_cast(nowSecs - lastPaymentDate); + log << "PoC window: prev=" << after.previousPaymentDate << " start=" << startSecs + << " now=" << nowSecs << " signedDelta=" << signedDelta + << " unsignedDelta=" << unsignedDelta << std::endl; + + // Reference (clamped) computation: emulate a non-negative accrual + // window by clamping prevPaymentDate to 'now' for the full-pay path. + auto const prevClamped = std::min(after.previousPaymentDate, nowSecs); + auto const fullPaymentInterestClamped = computeFullPaymentInterest( + xrpl::detail::loanPrincipalFromPeriodicPayment( + env.current()->rules(), + after.periodicPayment, + periodicRate2, + after.paymentRemaining), + periodicRate2, + env.current()->parentCloseTime(), + after.paymentInterval, + prevClamped, + startSecs, + closeInterestRate); + auto const roundedInterestClamped = + roundToAsset(asset.raw(), fullPaymentInterestClamped, after.loanScale); + Number const roundedFullMgmtFeeClamped = computeManagementFee( + asset.raw(), roundedInterestClamped, managementFeeRate, after.loanScale); + Number const roundedFullInterestClamped = + roundedInterestClamped - roundedFullMgmtFeeClamped; + STAmount const fullDueClamped{ + asset, + after.principalOutstanding + roundedFullInterestClamped + roundedFullMgmtFeeClamped + + closePaymentFee}; + + // Collect vault NAV before closing payment + auto const vaultId2 = brokerSle2 ? brokerSle2->at(sfVaultID) : uint256{}; + auto const vaultKey2 = keylet::vault(vaultId2); + auto const vaultBefore = env.le(vaultKey2); + BEAST_EXPECT(vaultBefore); + Number const assetsTotalBefore = vaultBefore ? vaultBefore->at(sfAssetsTotal) : Number{}; + + STAmount const fullDue{ + asset, + after.principalOutstanding + roundedFullInterest + roundedFullMgmtFee + + closePaymentFee}; + + log << "PoC payoff: principalOutstanding=" << after.principalOutstanding + << " roundedFullInterest=" << roundedFullInterest + << " roundedFullMgmtFee=" << roundedFullMgmtFee << " closeFee=" << closePaymentFee + << " fullDue=" << to_string(fullDue.getJson()) << std::endl; + log << "PoC reference (clamped): roundedFullInterestClamped=" << roundedFullInterestClamped + << " roundedFullMgmtFeeClamped=" << roundedFullMgmtFeeClamped + << " fullDueClamped=" << to_string(fullDueClamped.getJson()) << std::endl; + + env(pay(borrower, loanKeylet.key, fullDue), Txflags(tfLoanFullPayment)); + env.close(); + + // Sanity: underflow present (unsigned delta very large relative to + // interval) + BEAST_EXPECT(unsignedDelta > after.paymentInterval); + + // Compare vault NAV before/after the full close + auto const vaultAfter = env.le(vaultKey2); + BEAST_EXPECT(vaultAfter); + if (vaultAfter) + { + auto const assetsTotalAfter = vaultAfter->at(sfAssetsTotal); + log << "PoC NAV: assetsTotalBefore=" << assetsTotalBefore + << " assetsTotalAfter=" << assetsTotalAfter + << " delta=" << (assetsTotalAfter - assetsTotalBefore) << std::endl; + + // Regression check: the underflowed window must be clamped so the + // payoff matches the non-underflow reference, i.e. no overcharge. + BEAST_EXPECT(fullDue == fullDueClamped); + if (fullDue != fullDueClamped) + log << "PoC delta: overcharge (fullDue > clamped)" << std::endl; + } + + // Loan should be paid off + auto const finalLoan = env.le(loanKeylet); + BEAST_EXPECT(finalLoan); + if (finalLoan) + { + BEAST_EXPECT(finalLoan->at(sfPaymentRemaining) == 0); + BEAST_EXPECT(finalLoan->at(sfPrincipalOutstanding) == 0); + } + } + + void + testRIPD3831(FeatureBitset features) + { + using namespace jtx; + + testcase("RIPD-3831"); + + Account const issuer("issuer"); + Account const lender("lender"); + Account const borrower("borrower"); + + BrokerParameters const brokerParams{ + .vaultDeposit = 100000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + // .managementFeeRate = TenthBips16{5919}, + .coverRateLiquidation = TenthBips32{0}}; + LoanParameters const loanParams{ + .account = lender, + .counter = borrower, + .principalRequest = Number{200'000, -6}, + .lateFee = Number{200, -6}, + .interest = TenthBips32{50'000}, + .payTotal = 10, + .payInterval = 150}; + + auto const assetType = AssetType::XRP; + + Env env{*this, features}; + + auto loanResult = + createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); + + if (BEAST_EXPECT(loanResult); !loanResult.has_value()) + return; + + auto broker = std::get(*loanResult); + auto loanKeylet = std::get(*loanResult); + + using tp = NetClock::time_point; + using d = NetClock::duration; + + auto state = getCurrentState(env, broker, loanKeylet); + if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan)) + { + env.close(tp{d{loan->at(sfNextPaymentDueDate) + loan->at(sfGracePeriod) + 1}}); + } + + topUpBorrower(env, broker, issuer, borrower, state, loanParams.serviceFee); + + using namespace jtx::loan; + + auto jv = pay(borrower, loanKeylet.key, drops(XRPAmount(state.totalValue))); + + { + auto const submitParam = to_string(jv); + auto const jr = env.rpc("submit", borrower.name(), submitParam); + + BEAST_EXPECT(jr.isMember(jss::result)); + } + + env.close(); + + // Make sure the system keeps responding + env(noop(borrower)); + env.close(); + env(noop(issuer)); + env.close(); + env(noop(lender)); + env.close(); + } + + void + testRIPD3459(FeatureBitset features) + { + testcase("RIPD-3459 - LoanBroker incorrect debt total"); + + using namespace jtx; + + Account const issuer("issuer"); + Account const lender("lender"); + Account const borrower("borrower"); + + BrokerParameters const brokerParams{ + .vaultDeposit = 200'000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .managementFeeRate = TenthBips16{500}, + .coverRateLiquidation = TenthBips32{0}}; + LoanParameters const loanParams{ + .account = lender, + .counter = borrower, + .principalRequest = Number{100'000, -4}, + .interest = TenthBips32{100'000}, + .payTotal = 10}; + + auto const assetType = AssetType::MPT; + + Env env{*this, features}; + + auto loanResult = + createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); + + if (BEAST_EXPECT(loanResult); !loanResult.has_value()) + return; + + auto broker = std::get(*loanResult); + auto loanKeylet = std::get(*loanResult); + auto pseudoAcct = std::get(*loanResult); + + VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); + + if (auto const brokerSle = env.le(broker.brokerKeylet()); BEAST_EXPECT(brokerSle)) + { + if (auto const loanSle = env.le(loanKeylet); BEAST_EXPECT(loanSle)) + { + BEAST_EXPECT(brokerSle->at(sfDebtTotal) == loanSle->at(sfTotalValueOutstanding)); + } + } + + makeLoanPayments( + env, + broker, + loanParams, + loanKeylet, + verifyLoanStatus, + issuer, + lender, + borrower, + PaymentParameters{.showStepBalances = true}); + + if (auto const brokerSle = env.le(broker.brokerKeylet()); BEAST_EXPECT(brokerSle)) + { + if (auto const loanSle = env.le(loanKeylet); BEAST_EXPECT(loanSle)) + { + BEAST_EXPECT(brokerSle->at(sfDebtTotal) == loanSle->at(sfTotalValueOutstanding)); + BEAST_EXPECT(brokerSle->at(sfDebtTotal) == beast::kZero); + } + } + } + + void + testRIPD3901() + { + testcase("Crash with tfLoanOverpayment"); + using namespace jtx; + using namespace loan; + Account const lender{"lender"}; + Account const issuer{"issuer"}; + Account const borrower{"borrower"}; + Account const depositor{"depositor"}; + auto const txFee = Fee(XRP(100)); + + Env env(*this); + Vault const vault(env); + + env.fund(XRP(10'000), lender, issuer, borrower, depositor); + env.close(); + + auto [tx, vaultKeyLet] = vault.create({.owner = lender, .asset = xrpIssue()}); + env(tx, txFee); + env.close(); + + env(vault.deposit({.depositor = depositor, .id = vaultKeyLet.key, .amount = XRP(1'000)}), + txFee); + env.close(); + + auto const brokerKeyLet = keylet::loanBroker(lender.id(), env.seq(lender)); + + env(loan_broker::set(lender, vaultKeyLet.key), txFee); + env.close(); + + STAmount const debtMaximumRequest = XRPAmount(200'000); + + env(set(borrower, brokerKeyLet.key, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + kInterestRate(TenthBips32(50'000)), + kPaymentTotal(2), + kPaymentInterval(150), + Txflags(tfLoanOverpayment), + txFee); + env.close(); + + std::uint32_t const loanSequence = 1; + auto const loanKeylet = keylet::loan(brokerKeyLet.key, loanSequence); + + if (auto loan = env.le(loanKeylet); env.test.BEAST_EXPECT(loan)) + { + env(loan::pay(borrower, loanKeylet.key, XRPAmount(150'001)), + Txflags(tfLoanOverpayment), + txFee); + env.close(); + } + } + + void + testRIPD3902(FeatureBitset features) + { + testcase("RIPD-3902 - 1 IOU loan payments"); + + using namespace jtx; + + Account const issuer("issuer"); + Account const lender("lender"); + Account const borrower("borrower"); + + BrokerParameters const brokerParams{ + .vaultDeposit = 10, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + LoanParameters const loanParams{ + .account = lender, + .counter = borrower, + .principalRequest = Number{1, 0}, + .interest = TenthBips32{100'000}, + .payTotal = 5, + .payInterval = 150, + .gracePd = 60}; + + auto const assetType = AssetType::IOU; + + Env env{*this, features}; + + auto loanResult = + createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); + + if (BEAST_EXPECT(loanResult); !loanResult.has_value()) + return; + + auto broker = std::get(*loanResult); + auto loanKeylet = std::get(*loanResult); + auto pseudoAcct = std::get(*loanResult); + + VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); + + makeLoanPayments( + env, + broker, + loanParams, + loanKeylet, + verifyLoanStatus, + issuer, + lender, + borrower, + PaymentParameters{.showStepBalances = true}); + } + + void + runAmendmentIndependent() + { + testRIPD3901(); + } + + // Tests run under each entry in amendmentCombinations(). + void + runAmendmentSensitive(FeatureBitset features) + { + testPoCUnsignedUnderflowOnFullPayAfterEarlyPeriodic(features); + testRIPD3831(features); + testRIPD3459(features); + testRIPD3902(features); + } + +public: + void + run() override + { + runAmendmentIndependent(); + for (auto const& features : jtx::amendmentCombinations( + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) + runAmendmentSensitive(features); + } +}; + +BEAST_DEFINE_TESTSUITE(LoanSecurity, tx, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/lending/LoanSet_test.cpp b/src/test/app/lending/LoanSet_test.cpp new file mode 100644 index 0000000000..85528ee9a0 --- /dev/null +++ b/src/test/app/lending/LoanSet_test.cpp @@ -0,0 +1,607 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace xrpl::test { + +class LoanSet_test : public LoanTestBase +{ +private: + void + testLoanSet(FeatureBitset features) + { + using namespace jtx; + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + struct CaseArgs + { + bool requireAuth = false; + bool authorizeBorrower = false; + int initialXRP = 1'000'000; + }; + + auto const testCase = [&, this]( + std::function mptTest, + std::function iouTest, + CaseArgs args = {}) { + Env env(*this, features); + env.fund(XRP(args.initialXRP), issuer, lender, borrower); + env.close(); + if (args.requireAuth) + { + env(fset(issuer, asfRequireAuth)); + env.close(); + } + + // We need two different asset types, MPT and IOU. Prepare MPT + // first + MPTTester mptt{env, issuer, kMptInitNoFund}; + + auto const kNone = LedgerSpecificFlags(0); + mptt.create( + {.flags = tfMPTCanTransfer | tfMPTCanLock | + (args.requireAuth ? tfMPTRequireAuth : kNone)}); + env.close(); + PrettyAsset const mptAsset = mptt.issuanceID(); + mptt.authorize({.account = lender}); + mptt.authorize({.account = borrower}); + env.close(); + if (args.requireAuth) + { + mptt.authorize({.account = issuer, .holder = lender}); + if (args.authorizeBorrower) + mptt.authorize({.account = issuer, .holder = borrower}); + env.close(); + } + + env(pay(issuer, lender, mptAsset(10'000'000))); + env.close(); + + // Prepare IOU + PrettyAsset const iouAsset = issuer[iouCurrency_]; + env(trust(lender, iouAsset(10'000'000))); + env(trust(borrower, iouAsset(10'000'000))); + env.close(); + if (args.requireAuth) + { + env(trust(issuer, iouAsset(0), lender, tfSetfAuth)); + env(pay(issuer, lender, iouAsset(10'000'000))); + if (args.authorizeBorrower) + { + env(trust(issuer, iouAsset(0), borrower, tfSetfAuth)); + env(pay(issuer, borrower, iouAsset(10'000))); + } + } + else + { + env(pay(issuer, lender, iouAsset(10'000'000))); + env(pay(issuer, borrower, iouAsset(10'000))); + } + env.close(); + + // Create vaults and loan brokers + std::array const assets{mptAsset, iouAsset}; + std::vector brokers; + brokers.reserve(assets.size()); + for (auto const& asset : assets) + { + brokers.emplace_back(createVaultAndBroker(env, asset, lender)); + } + + if (mptTest) + mptTest(env, brokers[0], mptt); + if (iouTest) + iouTest(env, brokers[1]); + }; + + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase("MPT issuer is borrower, issuer submits"); + env(set(issuer, broker.brokerID, principalRequest), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5)); + + testcase("MPT issuer is borrower, lender submits"); + env(set(lender, broker.brokerID, principalRequest), + kCounterparty(issuer), + Sig(sfCounterpartySignature, issuer), + Fee(env.current()->fees().base * 5)); + }, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase("IOU issuer is borrower, issuer submits"); + env(set(issuer, broker.brokerID, principalRequest), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5)); + + testcase("IOU issuer is borrower, lender submits"); + env(set(lender, broker.brokerID, principalRequest), + kCounterparty(issuer), + Sig(sfCounterpartySignature, issuer), + Fee(env.current()->fees().base * 5)); + }, + CaseArgs{.requireAuth = true}); + + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase("MPT unauthorized borrower, borrower submits"); + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5), + Ter{tecNO_AUTH}); + + testcase("MPT unauthorized borrower, lender submits"); + env(set(lender, broker.brokerID, principalRequest), + kCounterparty(borrower), + Sig(sfCounterpartySignature, borrower), + Fee(env.current()->fees().base * 5), + Ter{tecNO_AUTH}); + }, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase("IOU unauthorized borrower, borrower submits"); + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5), + Ter{tecNO_AUTH}); + + testcase("IOU unauthorized borrower, lender submits"); + env(set(lender, broker.brokerID, principalRequest), + kCounterparty(borrower), + Sig(sfCounterpartySignature, borrower), + Fee(env.current()->fees().base * 5), + Ter{tecNO_AUTH}); + }, + CaseArgs{.requireAuth = true}); + + auto const [acctReserve, incReserve] = [this]() -> std::pair { + Env const env{*this, testableAmendments()}; + return { + env.current()->fees().accountReserve(0, 1).drops() / kDropsPerXrp.drops(), + env.current()->fees().increment.drops() / kDropsPerXrp.drops()}; + }(); + + testCase( + [&, this](Env& env, BrokerInfo const& broker, MPTTester& mptt) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase( + "MPT authorized borrower, borrower submits, borrower has " + "no reserve"); + mptt.authorize({.account = borrower, .flags = tfMPTUnauthorize}); + env.close(); + + auto const mptoken = keylet::mptoken(mptt.issuanceID(), borrower); + auto const sleMPT1 = env.le(mptoken); + BEAST_EXPECT(sleMPT1 == nullptr); + + // Burn some XRP + env(noop(borrower), Fee(XRP((acctReserve * 2) + (incReserve * 2)))); + env.close(); + + // Cannot create loan, not enough reserve to create MPToken + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5), + Ter{tecINSUFFICIENT_RESERVE}); + env.close(); + + // Can create loan now, will implicitly create MPToken + env(pay(issuer, borrower, XRP(incReserve))); + env.close(); + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5)); + env.close(); + + auto const sleMPT2 = env.le(mptoken); + BEAST_EXPECT(sleMPT2 != nullptr); + }, + {}, + CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1}); + + testCase( + {}, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase( + "IOU authorized borrower, borrower submits, borrower has " + "no reserve"); + // Remove trust line from borrower to issuer + env.trust(broker.asset(0), borrower); + env.close(); + + env(pay(borrower, issuer, broker.asset(10'000))); + env.close(); + auto const trustline = keylet::trustLine(borrower, broker.asset.raw().get()); + auto const sleLine1 = env.le(trustline); + BEAST_EXPECT(sleLine1 == nullptr); + + // Burn some XRP + env(noop(borrower), Fee(XRP((acctReserve * 2) + (incReserve * 2)))); + env.close(); + + // Cannot create loan, not enough reserve to create trust line + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5), + Ter{tecNO_LINE_INSUF_RESERVE}); + env.close(); + + // Can create loan now, will implicitly create trust line + env(pay(issuer, borrower, XRP(incReserve))); + env.close(); + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5)); + env.close(); + + auto const sleLine2 = env.le(trustline); + BEAST_EXPECT(sleLine2 != nullptr); + }, + CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1}); + + testCase( + [&, this](Env& env, BrokerInfo const& broker, MPTTester& mptt) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase( + "MPT authorized borrower, borrower submits, lender has " + "no reserve"); + auto const mptoken = keylet::mptoken(mptt.issuanceID(), lender); + auto const sleMPT1 = env.le(mptoken); + BEAST_EXPECT(sleMPT1 != nullptr); + + env(pay(lender, issuer, broker.asset(sleMPT1->at(sfMPTAmount)))); + env.close(); + + mptt.authorize({.account = lender, .flags = tfMPTUnauthorize}); + env.close(); + + auto const sleMPT2 = env.le(mptoken); + BEAST_EXPECT(sleMPT2 == nullptr); + + // Burn some XRP + env(noop(lender), Fee(XRP(incReserve))); + env.close(); + + // Cannot create loan, not enough reserve to create MPToken + env(set(borrower, broker.brokerID, principalRequest), + kLoanOriginationFee(broker.asset(1).value()), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5), + Ter{tecINSUFFICIENT_RESERVE}); + env.close(); + + // Can create loan now, will implicitly create MPToken + env(pay(issuer, lender, XRP(incReserve))); + env.close(); + env(set(borrower, broker.brokerID, principalRequest), + kLoanOriginationFee(broker.asset(1).value()), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5)); + env.close(); + + auto const sleMPT3 = env.le(mptoken); + BEAST_EXPECT(sleMPT3 != nullptr); + }, + {}, + CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1}); + + testCase( + {}, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase( + "IOU authorized borrower, borrower submits, lender has no " + "reserve"); + // Remove trust line from lender to issuer + env.trust(broker.asset(0), lender); + env.close(); + + auto const trustline = keylet::trustLine(lender, broker.asset.raw().get()); + auto const sleLine1 = env.le(trustline); + BEAST_EXPECT(sleLine1 != nullptr); + + env(pay(lender, issuer, broker.asset(abs(sleLine1->at(sfBalance).value())))); + env.close(); + auto const sleLine2 = env.le(trustline); + BEAST_EXPECT(sleLine2 == nullptr); + + // Burn some XRP + env(noop(lender), Fee(XRP(incReserve))); + env.close(); + + // Cannot create loan, not enough reserve to create trust line + env(set(borrower, broker.brokerID, principalRequest), + kLoanOriginationFee(broker.asset(1).value()), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5), + Ter{tecNO_LINE_INSUF_RESERVE}); + env.close(); + + // Can create loan now, will implicitly create trust line + env(pay(issuer, lender, XRP(incReserve))); + env.close(); + env(set(borrower, broker.brokerID, principalRequest), + kLoanOriginationFee(broker.asset(1).value()), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5)); + env.close(); + + auto const sleLine3 = env.le(trustline); + BEAST_EXPECT(sleLine3 != nullptr); + }, + CaseArgs{.initialXRP = (acctReserve * 2) + (incReserve * 8) + 1}); + + testCase( + [&, this](Env& env, BrokerInfo const& broker, MPTTester& mptt) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase("MPT authorized borrower, unauthorized lender"); + auto const mptoken = keylet::mptoken(mptt.issuanceID(), lender); + auto const sleMPT1 = env.le(mptoken); + BEAST_EXPECT(sleMPT1 != nullptr); + + env(pay(lender, issuer, broker.asset(sleMPT1->at(sfMPTAmount)))); + env.close(); + + mptt.authorize({.account = lender, .flags = tfMPTUnauthorize}); + env.close(); + + auto const sleMPT2 = env.le(mptoken); + BEAST_EXPECT(sleMPT2 == nullptr); + + // Cannot create loan, lender not authorized to receive fee + env(set(borrower, broker.brokerID, principalRequest), + kLoanOriginationFee(broker.asset(1).value()), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5), + Ter{tecNO_AUTH}); + env.close(); + + // Cannot create loan, even without an origination fee + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5), + Ter{tecNO_AUTH}); + env.close(); + + // No MPToken for lender - no authorization and no payment + auto const sleMPT3 = env.le(mptoken); + BEAST_EXPECT(sleMPT3 == nullptr); + }, + {}, + CaseArgs{.requireAuth = true, .authorizeBorrower = true}); + + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase("MPT authorized borrower, borrower submits"); + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5)); + }, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase("IOU authorized borrower, borrower submits"); + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5)); + }, + CaseArgs{.requireAuth = true, .authorizeBorrower = true}); + + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase("MPT authorized borrower, lender submits"); + env(set(lender, broker.brokerID, principalRequest), + kCounterparty(borrower), + Sig(sfCounterpartySignature, borrower), + Fee(env.current()->fees().base * 5)); + }, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + + testcase("IOU authorized borrower, lender submits"); + env(set(lender, broker.brokerID, principalRequest), + kCounterparty(borrower), + Sig(sfCounterpartySignature, borrower), + Fee(env.current()->fees().base * 5)); + }, + CaseArgs{.requireAuth = true, .authorizeBorrower = true}); + + jtx::Account const alice{"alice"}; + jtx::Account const bella{"bella"}; + auto const msigSetup = [&](Env& env, Account const& account) { + json::Value const tx1 = signers(account, 2, {{alice, 1}, {bella, 1}}); + env(tx1); + env.close(); + }; + + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + msigSetup(env, lender); + Number const principalRequest = broker.asset(1'000).value(); + + testcase( + "MPT authorized borrower, borrower submits, lender " + "multisign"); + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Msig(sfCounterpartySignature, alice, bella), + Fee(env.current()->fees().base * 5)); + }, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + msigSetup(env, lender); + Number const principalRequest = broker.asset(1'000).value(); + + testcase( + "IOU authorized borrower, borrower submits, lender " + "multisign"); + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Msig(sfCounterpartySignature, alice, bella), + Fee(env.current()->fees().base * 5)); + }, + CaseArgs{.requireAuth = true, .authorizeBorrower = true}); + + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + msigSetup(env, borrower); + Number const principalRequest = broker.asset(1'000).value(); + + testcase( + "MPT authorized borrower, lender submits, borrower " + "multisign"); + env(set(lender, broker.brokerID, principalRequest), + kCounterparty(borrower), + Msig(sfCounterpartySignature, alice, bella), + Fee(env.current()->fees().base * 5)); + }, + [&, this](Env& env, BrokerInfo const& broker) { + using namespace loan; + msigSetup(env, borrower); + Number const principalRequest = broker.asset(1'000).value(); + + testcase( + "IOU authorized borrower, lender submits, borrower " + "multisign"); + env(set(lender, broker.brokerID, principalRequest), + kCounterparty(borrower), + Msig(sfCounterpartySignature, alice, bella), + Fee(env.current()->fees().base * 5)); + }, + CaseArgs{.requireAuth = true, .authorizeBorrower = true}); + + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + Vault const vault{env}; + auto tx = vault.set({.owner = lender, .id = broker.vaultID}); + tx[sfAssetsMaximum] = BrokerParameters::defaults().vaultDeposit; + env(tx); + env.close(); + + testcase("Vault at maximum value"); + env(set(issuer, broker.brokerID, principalRequest), + kCounterparty(lender), + kInterestRate(TenthBips32(10'000)), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5), + Ter(tecLIMIT_EXCEEDED)); + }, + nullptr); + + testCase( + [&, this](Env& env, BrokerInfo const& broker, auto&) { + using namespace loan; + Number const principalRequest = broker.asset(1'000).value(); + Vault const vault{env}; + auto tx = vault.set({.owner = lender, .id = broker.vaultID}); + tx[sfAssetsMaximum] = + BrokerParameters::defaults().vaultDeposit + broker.asset(1).number(); + env(tx); + env.close(); + + testcase("Vault maximum value exceeded"); + env(set(issuer, broker.brokerID, principalRequest), + kCounterparty(lender), + kInterestRate(TenthBips32(100'000)), + Sig(sfCounterpartySignature, lender), + Fee(env.current()->fees().base * 5), + kPaymentTotal(2), + kPaymentInterval(3600 * 24), + Ter(tecLIMIT_EXCEEDED)); + }, + nullptr); + } + +public: + void + run() override + { + for (auto const& features : jtx::amendmentCombinations( + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) + testLoanSet(features); + } +}; + +BEAST_DEFINE_TESTSUITE(LoanSet, tx, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/lending/LoanTestBase.h b/src/test/app/lending/LoanTestBase.h new file mode 100644 index 0000000000..35aa1e26cb --- /dev/null +++ b/src/test/app/lending/LoanTestBase.h @@ -0,0 +1,2949 @@ +#pragma once + +#include +// +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl::test { + +class LoanTestBase : public beast::unit_test::Suite +{ +protected: + // Ensure that all the features needed for Lending Protocol are included, + // even if they are set to unsupported. + // + // featureLendingProtocolV1_1 is excluded from the default set: it changes + // Vault/LoanBroker accounting (AssetsTotal/DebtTotal/LossUnrealized), and + // most of this file's tests assert whole-life-specific expected values + // for those fields. Tests that specifically exercise the amendment opt + // it back in explicitly (e.g. `all_ | featureLendingProtocolV1_1`). + FeatureBitset const all_{jtx::testableAmendments() - featureLendingProtocolV1_1}; + std::string const iouCurrency_{"IOU"}; + + struct BrokerParameters + { + Number vaultDeposit = 1'000'000; + Number debtMax = 25'000; + TenthBips32 coverRateMin = percentageToTenthBips(10); + int coverDeposit = 1000; + TenthBips16 managementFeeRate{100}; + TenthBips32 coverRateLiquidation = percentageToTenthBips(25); + std::string data = {}; // NOLINT(readability-redundant-member-init) + std::uint32_t flags = 0; + // If set, the vault is created with this sfScale value. Useful for + // tests that need finer loanScale to exercise rounding edge cases. + std::optional vaultScale = + std::nullopt; // NOLINT(readability-redundant-member-init) + + [[nodiscard]] Number + maxCoveredLoanValue(Number const& currentDebt) const + { + NumberRoundModeGuard const mg(Number::RoundingMode::Downward); + auto debtLimit = coverDeposit * kTenthBipsPerUnity.value() / coverRateMin.value(); + + return debtLimit - currentDebt; + } + + static BrokerParameters const& + defaults() + { + static BrokerParameters const kResult{}; + return kResult; + } + + // TODO: create an operator() which returns a transaction similar to + // LoanParameters + }; + + struct BrokerInfo + { + jtx::PrettyAsset asset; + uint256 brokerID; + uint256 vaultID; + BrokerParameters params; + BrokerInfo( + jtx::PrettyAsset const& asset, + Keylet const& brokerKeylet, + Keylet const& vaultKeylet, + BrokerParameters p) + : asset(asset) + , brokerID(brokerKeylet.key) + , vaultID(vaultKeylet.key) + , params(std::move(p)) + { + } + + [[nodiscard]] Keylet + brokerKeylet() const + { + return keylet::loanBroker(brokerID); + } + [[nodiscard]] Keylet + vaultKeylet() const + { + return keylet::vault(vaultID); + } + + [[nodiscard]] int + vaultScale(jtx::Env const& env) const + { + using namespace jtx; + + auto const vaultSle = env.le(keylet::vault(vaultID)); + return getAssetsTotalScale(vaultSle); + } + }; + + struct LoanParameters + { + // The account submitting the transaction. May be borrower or broker. + jtx::Account account; + // The counterparty. Should be the other of borrower or broker. + jtx::Account counter; + // Whether the counterparty is specified in the `counterparty` field, or + // only signs. + bool counterpartyExplicit = true; + Number principalRequest; + // NOLINTBEGIN(readability-redundant-member-init) + std::optional setFee = std::nullopt; + std::optional originationFee = std::nullopt; + std::optional serviceFee = std::nullopt; + std::optional lateFee = std::nullopt; + std::optional closeFee = std::nullopt; + std::optional overFee = std::nullopt; + std::optional interest = std::nullopt; + std::optional lateInterest = std::nullopt; + std::optional closeInterest = std::nullopt; + std::optional overpaymentInterest = std::nullopt; + std::optional payTotal = std::nullopt; + std::optional payInterval = std::nullopt; + std::optional gracePd = std::nullopt; + std::optional flags = std::nullopt; + // NOLINTEND(readability-redundant-member-init) + + template + jtx::JTx + operator()(jtx::Env& env, BrokerInfo const& broker, FN const&... fN) const + { + using namespace jtx; + using namespace jtx::loan; + + JTx jt{loan::set( + account, + broker.brokerID, + broker.asset(principalRequest).number(), + flags.value_or(0))}; + + Sig(sfCounterpartySignature, counter)(env, jt); + + Fee{setFee.value_or(env.current()->fees().base * 2)}(env, jt); + + if (counterpartyExplicit) + kCounterparty(counter)(env, jt); + if (originationFee) + kLoanOriginationFee(broker.asset(*originationFee).number())(env, jt); + if (serviceFee) + kLoanServiceFee(broker.asset(*serviceFee).number())(env, jt); + if (lateFee) + kLatePaymentFee(broker.asset(*lateFee).number())(env, jt); + if (closeFee) + kClosePaymentFee(broker.asset(*closeFee).number())(env, jt); + if (overFee) + kOverpaymentFee (*overFee)(env, jt); + if (interest) + kInterestRate (*interest)(env, jt); + if (lateInterest) + kLateInterestRate (*lateInterest)(env, jt); + if (closeInterest) + kCloseInterestRate (*closeInterest)(env, jt); + if (overpaymentInterest) + kOverpaymentInterestRate (*overpaymentInterest)(env, jt); + if (payTotal) + kPaymentTotal (*payTotal)(env, jt); + if (payInterval) + kPaymentInterval (*payInterval)(env, jt); + if (gracePd) + kGracePeriod (*gracePd)(env, jt); + + return env.jt(jt, fN...); + } + }; + + struct PaymentParameters + { + Number overpaymentFactor = Number{1}; + std::optional overpaymentExtra = std::nullopt; + std::uint32_t flags = 0; + bool showStepBalances = false; + bool validateBalances = true; + + static PaymentParameters const& + defaults() + { + static PaymentParameters const kResult{}; + return kResult; + } + }; + + struct LoanState + { + std::uint32_t previousPaymentDate = 0; + NetClock::time_point startDate; + std::uint32_t nextPaymentDate = 0; + std::uint32_t paymentRemaining = 0; + std::int32_t const loanScale = 0; + Number totalValue = 0; + Number principalOutstanding = 0; + Number managementFeeOutstanding = 0; + Number periodicPayment = 0; + std::uint32_t flags = 0; + std::uint32_t const paymentInterval = 0; + TenthBips32 const interestRate{}; + }; + + /** + * Helper class to compare the expected state of a loan and loan broker + * against the data in the ledger. + */ + struct VerifyLoanStatus + { + public: + jtx::Env const& env; + BrokerInfo const& broker; + jtx::Account const& pseudoAccount; + Keylet const& loanKeylet; + + VerifyLoanStatus( + jtx::Env const& env, + BrokerInfo const& broker, + jtx::Account const& pseudo, + Keylet const& keylet) + : env(env), broker(broker), pseudoAccount(pseudo), loanKeylet(keylet) + { + } + + /** + * Checks the expected broker state against the ledger + */ + void + checkBroker( + Number const& principalOutstanding, + Number const& interestOwed, + TenthBips32 interestRate, + std::uint32_t paymentInterval, + std::uint32_t paymentsRemaining, + std::uint32_t ownerCount) const + { + using namespace jtx; + if (auto brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + env.test.BEAST_EXPECT(brokerSle)) + { + TenthBips16 const managementFeeRate{brokerSle->at(sfManagementFeeRate)}; + auto const brokerDebt = brokerSle->at(sfDebtTotal); + + if (auto vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID))); + env.test.BEAST_EXPECT(vaultSle)) + { + auto const expectedDebt = + env.current()->rules().enabled(featureLendingProtocolV1_1) && + getVaultVersion(vaultSle) == VaultVersion::CashBasis + ? principalOutstanding + : principalOutstanding + interestOwed; + env.test.BEAST_EXPECT(brokerDebt == expectedDebt); + env.test.BEAST_EXPECT( + env.balance(pseudoAccount, broker.asset).number() == + brokerSle->at(sfCoverAvailable)); + env.test.BEAST_EXPECT(brokerSle->at(sfOwnerCount) == ownerCount); + + Account const vaultPseudo{"vaultPseudoAccount", vaultSle->at(sfAccount)}; + env.test.BEAST_EXPECT( + vaultSle->at(sfAssetsAvailable) == + env.balance(vaultPseudo, broker.asset).number()); + if (ownerCount == 0) + { + // The Vault must be perfectly balanced if there + // are no loans outstanding + auto const total = vaultSle->at(sfAssetsTotal); + auto const available = vaultSle->at(sfAssetsAvailable); + env.test.BEAST_EXPECT(total == available); + env.test.BEAST_EXPECT(vaultSle->at(sfLossUnrealized) == 0); + } + } + } + } + + void + checkPayment( + std::int32_t loanScale, + jtx::Account const& account, + jtx::PrettyAmount const& balanceBefore, + STAmount const& expectedPayment, + jtx::PrettyAmount const& adjustment) const + { + auto const borrowerScale = std::max(loanScale, balanceBefore.number().exponent()); + + STAmount const balanceChangeAmount{ + broker.asset, + roundToAsset(broker.asset, expectedPayment + adjustment, borrowerScale)}; + { + auto const difference = roundToScale( + env.balance(account, broker.asset) - (balanceBefore - balanceChangeAmount), + borrowerScale); + env.test.expect( + roundToScale(difference, loanScale) >= beast::kZero, + "Balance before: " + to_string(balanceBefore.value()) + + ", expected change: " + to_string(balanceChangeAmount) + + ", difference (balance after - expected): " + to_string(difference), + __FILE__, + __LINE__); + } + } + + /** + * Checks both the loan and broker expect states against the ledger + */ + void + operator()( + std::uint32_t previousPaymentDate, + std::uint32_t nextPaymentDate, + std::uint32_t paymentRemaining, + Number const& loanScale, + Number const& totalValue, + Number const& principalOutstanding, + Number const& managementFeeOutstanding, + Number const& periodicPayment, + std::uint32_t flags) const + { + using namespace jtx; + if (auto loan = env.le(loanKeylet); env.test.BEAST_EXPECT(loan)) + { + env.test.BEAST_EXPECT(loan->at(sfPreviousPaymentDueDate) == previousPaymentDate); + env.test.BEAST_EXPECT(loan->at(sfPaymentRemaining) == paymentRemaining); + env.test.BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == nextPaymentDate); + env.test.BEAST_EXPECT(loan->at(sfLoanScale) == loanScale); + env.test.BEAST_EXPECT(loan->at(sfTotalValueOutstanding) == totalValue); + env.test.BEAST_EXPECT(loan->at(sfPrincipalOutstanding) == principalOutstanding); + env.test.BEAST_EXPECT( + loan->at(sfManagementFeeOutstanding) == managementFeeOutstanding); + env.test.BEAST_EXPECT(loan->at(sfPeriodicPayment) == periodicPayment); + env.test.BEAST_EXPECT(loan->at(sfFlags) == flags); + + auto const ls = constructLoanState(loan); + + auto const interestRate = TenthBips32{loan->at(sfInterestRate)}; + auto const paymentInterval = loan->at(sfPaymentInterval); + checkBroker( + principalOutstanding, + ls.interestDue, + interestRate, + paymentInterval, + paymentRemaining, + 1); + + if (auto brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + env.test.BEAST_EXPECT(brokerSle)) + { + if (auto vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID))); + env.test.BEAST_EXPECT(vaultSle)) + { + if (((flags & lsfLoanImpaired) != 0u) && ((flags & lsfLoanDefault) == 0u)) + { + env.test.BEAST_EXPECT( + vaultSle->at(sfLossUnrealized) == + (env.current()->rules().enabled(featureLendingProtocolV1_1) && + getVaultVersion(vaultSle) == VaultVersion::CashBasis + ? principalOutstanding + : totalValue - managementFeeOutstanding)); + } + else + { + env.test.BEAST_EXPECT(vaultSle->at(sfLossUnrealized) == 0); + } + } + } + } + } + + /** + * Checks both the loan and broker expect states against the ledger + */ + void + operator()(LoanState const& state) const + { + operator()( + state.previousPaymentDate, + state.nextPaymentDate, + state.paymentRemaining, + state.loanScale, + state.totalValue, + state.principalOutstanding, + state.managementFeeOutstanding, + state.periodicPayment, + state.flags); + }; + }; + + BrokerInfo + createVaultAndBroker( + jtx::Env& env, + jtx::PrettyAsset const& asset, + jtx::Account const& lender, + BrokerParameters const& params = BrokerParameters::defaults()) + { + using namespace jtx; + + Vault const vault{env}; + + auto const deposit = asset(params.vaultDeposit); + auto const debtMaximumValue = asset(params.debtMax).value(); + auto const coverDepositValue = asset(params.coverDeposit).value(); + + auto const coverRateMinValue = params.coverRateMin; + + auto [tx, vaultKeylet] = vault.create({.owner = lender, .asset = asset}); + if (params.vaultScale) + tx[sfScale] = *params.vaultScale; + env(tx); + env.close(); + BEAST_EXPECT(env.le(vaultKeylet)); + + env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = deposit})); + env.close(); + if (auto const vault = env.le(keylet::vault(vaultKeylet.key)); BEAST_EXPECT(vault)) + { + BEAST_EXPECT(vault->at(sfAssetsAvailable) == deposit.value()); + } + + auto const keylet = keylet::loanBroker(lender.id(), env.seq(lender)); + + using namespace loan_broker; + env(set(lender, vaultKeylet.key, params.flags), + kData(params.data), + kManagementFeeRate(params.managementFeeRate), + kDebtMaximum(debtMaximumValue), + kCoverRateMinimum(coverRateMinValue), + kCoverRateLiquidation(TenthBips32(params.coverRateLiquidation))); + + if (coverDepositValue != beast::kZero) + env(coverDeposit(lender, keylet.key, coverDepositValue)); + + env.close(); + + return {asset, keylet, vaultKeylet, params}; + } + + /** + * Get the state without checking anything + */ + LoanState + getCurrentState(jtx::Env const& env, BrokerInfo const& broker, Keylet const& loanKeylet) + { + using d = NetClock::duration; + using tp = NetClock::time_point; + + // Lookup the current loan state + if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan)) + { + return LoanState{ + .previousPaymentDate = loan->at(sfPreviousPaymentDueDate), + .startDate = tp{d{loan->at(sfStartDate)}}, + .nextPaymentDate = loan->at(sfNextPaymentDueDate), + .paymentRemaining = loan->at(sfPaymentRemaining), + .loanScale = loan->at(sfLoanScale), + .totalValue = loan->at(sfTotalValueOutstanding), + .principalOutstanding = loan->at(sfPrincipalOutstanding), + .managementFeeOutstanding = loan->at(sfManagementFeeOutstanding), + .periodicPayment = loan->at(sfPeriodicPayment), + .flags = loan->at(sfFlags), + .paymentInterval = loan->at(sfPaymentInterval), + .interestRate = TenthBips32{loan->at(sfInterestRate)}, + }; + } + return LoanState{}; + } + + /** + * Get the state and check the values against the parameters used in + * `lifecycle` + */ + LoanState + getCurrentState( + jtx::Env const& env, + BrokerInfo const& broker, + Keylet const& loanKeylet, + VerifyLoanStatus const& verifyLoanStatus) + { + using namespace std::chrono_literals; + using d = NetClock::duration; + using tp = NetClock::time_point; + + auto const state = getCurrentState(env, broker, loanKeylet); + BEAST_EXPECT(state.previousPaymentDate == 0); + BEAST_EXPECT(tp{d{state.nextPaymentDate}} == state.startDate + 600s); + BEAST_EXPECT(state.paymentRemaining == 12); + BEAST_EXPECT(state.principalOutstanding == broker.asset(1000).value()); + BEAST_EXPECT( + state.loanScale >= + (broker.asset.integral() + ? 0 + : std::max(broker.vaultScale(env), state.principalOutstanding.exponent()))); + BEAST_EXPECT(state.paymentInterval == 600); + { + NumberRoundModeGuard const mg(Number::RoundingMode::Upward); + BEAST_EXPECT( + state.totalValue == + roundToAsset( + broker.asset, state.periodicPayment * state.paymentRemaining, state.loanScale)); + } + BEAST_EXPECT( + state.managementFeeOutstanding == + computeManagementFee( + broker.asset, + state.totalValue - state.principalOutstanding, + broker.params.managementFeeRate, + state.loanScale)); + + verifyLoanStatus(state); + + return state; + } + + bool + canImpairLoan(jtx::Env const& env, BrokerInfo const& broker, LoanState const& state) + { + if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + BEAST_EXPECT(brokerSle)) + { + if (auto const vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID))); + BEAST_EXPECT(vaultSle)) + { + // log << vaultSle->getJson() << std::endl; + auto const assetsUnavailable = + vaultSle->at(sfAssetsTotal) - vaultSle->at(sfAssetsAvailable); + auto const unrealizedLoss = vaultSle->at(sfLossUnrealized) + + (env.current()->rules().enabled(featureLendingProtocolV1_1) && + getVaultVersion(vaultSle) == VaultVersion::CashBasis + ? state.principalOutstanding + : state.totalValue - state.managementFeeOutstanding); + + if (!BEAST_EXPECT(unrealizedLoss <= assetsUnavailable)) + { + return false; + } + } + } + return true; + } + + enum class AssetType { XRP = 0, IOU = 1, MPT = 2 }; + + // Specify the accounts as params to allow other accounts to be used + jtx::PrettyAsset + createAsset( + jtx::Env& env, + AssetType assetType, + BrokerParameters const& brokerParams, + jtx::Account const& issuer, + jtx::Account const& lender, + jtx::Account const& borrower) + { + using namespace jtx; + + switch (assetType) + { + case AssetType::XRP: + // TODO: remove the factor, and set up loans in drops + return PrettyAsset{xrpIssue(), 1'000'000}; + + case AssetType::IOU: { + PrettyAsset const asset{issuer[iouCurrency_]}; + + auto const limit = + asset(100 * (brokerParams.vaultDeposit + brokerParams.coverDeposit)); + if (lender != issuer) + env(trust(lender, limit)); + if (borrower != issuer) + env(trust(borrower, limit)); + + return asset; + } + + case AssetType::MPT: { + // Enough to cover initial fees + if (!env.le(keylet::account(issuer))) + env.fund(env.current()->fees().accountReserve(10, 1) * 10, issuer); + if (!env.le(keylet::account(lender))) + env.fund(env.current()->fees().accountReserve(10, 1) * 10, noripple(lender)); + if (!env.le(keylet::account(borrower))) + env.fund(env.current()->fees().accountReserve(10, 1) * 10, noripple(borrower)); + + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); + // Scale the MPT asset so interest is interesting + PrettyAsset const asset{mptt.issuanceID(), 10'000}; + // Need to do the authorization here because mptt isn't + // accessible outside + if (lender != issuer) + mptt.authorize({.account = lender}); + if (borrower != issuer) + mptt.authorize({.account = borrower}); + + env.close(); + + return asset; + } + + default: + throw std::runtime_error("Unknown asset type"); + } + } + + // Predicts the keylet of the next loan `broker` will originate, before + // that loan exists, by reading the broker's current LoanSequence. + Keylet + nextLoanKeylet(jtx::Env const& env, BrokerInfo const& broker) + { + auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID)); + if (!BEAST_EXPECT(brokerStateBefore)) + return keylet::loan(broker.brokerID, 0); + auto const loanSequence = brokerStateBefore->at(sfLoanSequence); + return keylet::loan(broker.brokerID, loanSequence); + } + + // Funds issuer/lender/borrower with XRP, creates an IOU asset issued by + // `issuer`, establishes trustlines for lender and borrower, and pays + // them starting balances. This is the exact setup shared by several of + // the fuzzer-derived regression tests below. + jtx::PrettyAsset + createFundedIouAsset( + jtx::Env& env, + jtx::Account const& issuer, + jtx::Account const& lender, + jtx::Account const& borrower, + Number const& lenderPay = 100'000'000, + Number const& borrowerPay = 1'000'000) + { + using namespace jtx; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + + PrettyAsset const iouAsset = issuer[iouCurrency_]; + auto trustLenderTx = env.json(trust(lender, iouAsset(1'000'000'000))); + env(trustLenderTx); + auto trustBorrowerTx = env.json(trust(borrower, iouAsset(1'000'000'000))); + env(trustBorrowerTx); + auto payLenderTx = pay(issuer, lender, iouAsset(lenderPay)); + env(payLenderTx); + auto payIssuerTx = pay(issuer, borrower, iouAsset(borrowerPay)); + env(payIssuerTx); + env.close(); + + return iouAsset; + } + + // Funds issuer/lender/borrower with XRP, sets DefaultRipple on the + // issuer, creates a "USD" IOU asset with a large trust limit, and pays + // lender/borrower starting balances. Shared setup for several + // overpayment/rounding regression tests below. + static jtx::PrettyAsset + createFundedRippleIouAsset( + jtx::Env& env, + jtx::Account const& issuer, + jtx::Account const& lender, + jtx::Account const& borrower, + Number const& lenderPay = 1'000'000, + Number const& borrowerPay = 1'000'000) + { + using namespace jtx; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env(fset(issuer, asfDefaultRipple)); + env.close(); + + PrettyAsset const iouAsset = issuer["USD"]; + STAmount const iouLimit{iouAsset.raw(), Number{9'999'999'999'999'999LL}}; + env(trust(lender, iouLimit)); + env(trust(borrower, iouLimit)); + env(pay(issuer, lender, iouAsset(lenderPay))); + env(pay(issuer, borrower, iouAsset(borrowerPay))); + env.close(); + + return iouAsset; + } + + // Returns the broker's pseudo-account, or `fallback` if the broker's + // ledger entry cannot be read. + jtx::Account + brokerPseudoAccount(jtx::Env const& env, BrokerInfo const& broker, jtx::Account const& fallback) + { + auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + return fallback; + auto const brokerPseudo = brokerSle->at(sfAccount); + return jtx::Account("Broker pseudo-account", brokerPseudo); + } + + void + describeLoan( + jtx::Env& env, + BrokerParameters const& brokerParams, + LoanParameters const& loanParams, + AssetType assetType, + jtx::Account const& issuer, + jtx::Account const& lender, + jtx::Account const& borrower) + { + using namespace jtx; + + auto const asset = createAsset(env, assetType, brokerParams, issuer, lender, borrower); + auto const principal = asset(loanParams.principalRequest).number(); + auto const interest = loanParams.interest.value_or(TenthBips32{}); + auto const interval = loanParams.payInterval.value_or(LoanSet::kDefaultPaymentInterval); + auto const total = loanParams.payTotal.value_or(LoanSet::kDefaultPaymentTotal); + auto const feeRate = brokerParams.managementFeeRate; + auto const props = computeLoanProperties( + env.current()->rules(), + asset, + principal, + interest, + interval, + total, + feeRate, + asset(brokerParams.vaultDeposit).number().exponent()); + log << "Loan properties:\n" + << "\tPrincipal: " << principal << std::endl + << "\tInterest rate: " << interest << std::endl + << "\tPayment interval: " << interval << std::endl + << "\tManagement Fee Rate: " << feeRate << std::endl + << "\tTotal Payments: " << total << std::endl + << "\tPeriodic Payment: " << props.periodicPayment << std::endl + << "\tTotal Value: " << props.loanState.valueOutstanding << std::endl + << "\tManagement Fee: " << props.loanState.managementFeeDue << std::endl + << "\tLoan Scale: " << props.loanScale << std::endl + << "\tFirst payment principal: " << props.firstPaymentPrincipal << std::endl; + + // checkGuards returns a TER, so success is 0 + BEAST_EXPECT(!checkLoanGuards( + asset, + asset(loanParams.principalRequest).number(), + loanParams.interest.value_or(TenthBips32{}) != beast::kZero, + loanParams.payTotal.value_or(LoanSet::kDefaultPaymentTotal), + props, + env.journal)); + } + + std::optional> + createLoan( + jtx::Env& env, + AssetType assetType, + BrokerParameters const& brokerParams, + LoanParameters const& loanParams, + jtx::Account const& issuer, + jtx::Account const& lender, + jtx::Account const& borrower) + { + using namespace jtx; + + // Enough to cover initial fees + env.fund(env.current()->fees().accountReserve(10, 1) * 10, issuer); + if (lender != issuer) + env.fund(env.current()->fees().accountReserve(10, 1) * 10, noripple(lender)); + if (borrower != issuer && borrower != lender) + env.fund(env.current()->fees().accountReserve(10, 1) * 10, noripple(borrower)); + + describeLoan(env, brokerParams, loanParams, assetType, issuer, lender, borrower); + + // Make the asset + auto const asset = createAsset(env, assetType, brokerParams, issuer, lender, borrower); + + env.close(); + if (asset.native() || lender != issuer) + { + env( + pay((asset.native() ? env.master : issuer), + lender, + asset(brokerParams.vaultDeposit + brokerParams.coverDeposit))); + } + // Fund the borrower later once we know the total loan + // size + + BrokerInfo const broker = createVaultAndBroker(env, asset, lender, brokerParams); + + auto const pseudoAcctOpt = [&]() -> std::optional { + auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + return std::nullopt; + auto const brokerPseudo = brokerSle->at(sfAccount); + return Account("Broker pseudo-account", brokerPseudo); + }(); + if (!pseudoAcctOpt) + return std::nullopt; + Account const& pseudoAcct = *pseudoAcctOpt; + + auto const loanKeyletOpt = [&]() -> std::optional { + auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + return std::nullopt; + + // Broker has no loans + BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); + + // The loan keylet is based on the LoanSequence of the + // _LOAN_BROKER_ object. + auto const loanSequence = brokerSle->at(sfLoanSequence); + return keylet::loan(broker.brokerID, loanSequence); + }(); + if (!loanKeyletOpt) + return std::nullopt; + Keylet const& loanKeylet = *loanKeyletOpt; + + env(loanParams(env, broker)); + + env.close(); + + return std::make_tuple(broker, loanKeylet, pseudoAcct); + } + + static void + topUpBorrower( + jtx::Env& env, + BrokerInfo const& broker, + jtx::Account const& issuer, + jtx::Account const& borrower, + LoanState const& state, + std::optional const& servFee) + { + using namespace jtx; + + STAmount const serviceFee = broker.asset(servFee.value_or(0)); + + // Ensure the borrower has enough funds to make the payments + // (including tx fees, if necessary) + auto const borrowerBalance = env.balance(borrower, broker.asset); + + auto const baseFee = env.current()->fees().base; + + // Add extra for transaction fees and reserves, if appropriate, or a + // tiny amount for the extra paid in each transaction + auto const totalNeeded = state.totalValue + (serviceFee * state.paymentRemaining) + + (broker.asset.native() ? Number( + baseFee * state.paymentRemaining + + accountReserve(*env.current(), borrower.id(), env.journal)) + : broker.asset(15).number()); + + auto const shortage = totalNeeded - borrowerBalance.number(); + + if (shortage > beast::kZero && (broker.asset.native() || issuer != borrower)) + { + env( + pay((broker.asset.native() ? env.master : issuer), + borrower, + STAmount{broker.asset, shortage})); + } + } + + void + makeLoanPayments( + jtx::Env& env, + BrokerInfo const& broker, + LoanParameters const& loanParams, + Keylet const& loanKeylet, + VerifyLoanStatus const& verifyLoanStatus, + jtx::Account const& issuer, + jtx::Account const& lender, + jtx::Account const& borrower, + PaymentParameters const& paymentParams = PaymentParameters::defaults()) + { + // Make all the individual payments + using namespace jtx; + using namespace jtx::loan; + using namespace std::chrono_literals; + using d = NetClock::duration; + + bool const showStepBalances = paymentParams.showStepBalances; + + auto const currencyLabel = getCurrencyLabel(broker.asset); + + auto const baseFee = env.current()->fees().base; + + env.close(); + auto state = getCurrentState(env, broker, loanKeylet); + + verifyLoanStatus(state); + + STAmount const serviceFee = broker.asset(loanParams.serviceFee.value_or(0)); + + topUpBorrower(env, broker, issuer, borrower, state, loanParams.serviceFee); + + // Periodic payment amount will consist of + // 1. principal outstanding (1000) + // 2. interest interest rate (at 12%) + // 3. payment interval (600s) + // 4. loan service fee (2) + // Calculate these values without the helper functions + // to verify they're working correctly The numbers in + // the below BEAST_EXPECTs may not hold across assets. + auto const periodicRate = loanPeriodicRate(state.interestRate, state.paymentInterval); + STAmount const roundedPeriodicPayment{ + broker.asset, + roundPeriodicPayment(broker.asset, state.periodicPayment, state.loanScale)}; + + if (!showStepBalances) + { + log << currencyLabel << " Payment components: " + << "Payments remaining, " + << "rawInterest, rawPrincipal, " + "rawMFee, " + << "trackedValueDelta, trackedPrincipalDelta, " + "trackedInterestDelta, trackedMgmtFeeDelta, special" + << std::endl; + } + + // Include the service fee + STAmount const totalDue = roundToScale( + roundedPeriodicPayment + serviceFee, state.loanScale, Number::RoundingMode::Upward); + + auto currentRoundedState = constructLoanState( + state.totalValue, state.principalOutstanding, state.managementFeeOutstanding); + { + auto const raw = computeTheoreticalLoanState( + env.current()->rules(), + state.periodicPayment, + periodicRate, + state.paymentRemaining, + broker.params.managementFeeRate); + + if (showStepBalances) + { + log << currencyLabel << " Starting loan balances: " + << "\n\tTotal value: " << currentRoundedState.valueOutstanding + << "\n\tPrincipal: " << currentRoundedState.principalOutstanding + << "\n\tInterest: " << currentRoundedState.interestDue + << "\n\tMgmt fee: " << currentRoundedState.managementFeeDue + << "\n\tPayments remaining " << state.paymentRemaining << std::endl; + } + else + { + log << currencyLabel << " Loan starting state: " << state.paymentRemaining << ", " + << raw.interestDue << ", " << raw.principalOutstanding << ", " + << raw.managementFeeDue << ", " << currentRoundedState.valueOutstanding << ", " + << currentRoundedState.principalOutstanding << ", " + << currentRoundedState.interestDue << ", " + << currentRoundedState.managementFeeDue << std::endl; + } + } + + // Try to pay a little extra to show that it's _not_ + // taken + auto const extraAmount = paymentParams.overpaymentExtra + ? broker.asset(*paymentParams.overpaymentExtra).value() + : std::min(broker.asset(10).value(), STAmount{broker.asset, totalDue / 20}); + + STAmount const transactionAmount = + STAmount{broker.asset, totalDue * paymentParams.overpaymentFactor} + extraAmount; + + auto const borrowerInitialBalance = env.balance(borrower, broker.asset).number(); + auto const initialState = state; + xrpl::detail::PaymentComponents totalPaid{ + .trackedValueDelta = 0, .trackedPrincipalDelta = 0, .trackedManagementFeeDelta = 0}; + Number totalInterestPaid = 0; + Number totalFeesPaid = 0; + std::size_t totalPaymentsMade = 0; + + xrpl::LoanState currentTrueState = computeTheoreticalLoanState( + env.current()->rules(), + state.periodicPayment, + periodicRate, + state.paymentRemaining, + broker.params.managementFeeRate); + + auto validateBorrowerBalance = [&]() { + if (borrower == issuer || !paymentParams.validateBalances) + return; + auto const totalSpent = + (totalPaid.trackedValueDelta + totalFeesPaid + + (broker.asset.native() ? Number(baseFee) * totalPaymentsMade : kNumZero)); + BEAST_EXPECT( + env.balance(borrower, broker.asset).number() == + borrowerInitialBalance - totalSpent); + }; + + auto const defaultRound = broker.asset.integral() ? 3 : 0; + auto truncate = [defaultRound](Number const& n, std::optional places = std::nullopt) { + auto const p = places.value_or(defaultRound); + if (p == 0) + return n; + auto const factor = Number{1, p}; + return (n * factor).truncate() / factor; + }; + while (state.paymentRemaining > 0) + { + validateBorrowerBalance(); + // Compute the expected principal amount + auto const paymentComponents = xrpl::detail::computePaymentComponents( + env.current()->rules(), + broker.asset.raw(), + state.loanScale, + state.totalValue, + state.principalOutstanding, + state.managementFeeOutstanding, + state.periodicPayment, + periodicRate, + state.paymentRemaining, + broker.params.managementFeeRate); + + BEAST_EXPECT( + paymentComponents.trackedValueDelta <= roundedPeriodicPayment || + (paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final && + paymentComponents.trackedValueDelta >= roundedPeriodicPayment)); + BEAST_EXPECT( + paymentComponents.trackedValueDelta == + paymentComponents.trackedPrincipalDelta + paymentComponents.trackedInterestPart() + + paymentComponents.trackedManagementFeeDelta); + + xrpl::LoanState const nextTrueState = computeTheoreticalLoanState( + env.current()->rules(), + state.periodicPayment, + periodicRate, + state.paymentRemaining - 1, + broker.params.managementFeeRate); + xrpl::detail::LoanStateDeltas const deltas = currentTrueState - nextTrueState; + BEAST_EXPECT( + deltas.total() == deltas.principal + deltas.interest + deltas.managementFee); + BEAST_EXPECT( + paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final || + deltas.total() == state.periodicPayment || + (state.loanScale - (deltas.total() - state.periodicPayment).exponent()) > 14); + + if (!showStepBalances) + { + log << currencyLabel << " Payment components: " << state.paymentRemaining << ", " + + << deltas.interest << ", " << deltas.principal << ", " << deltas.managementFee + << ", " << paymentComponents.trackedValueDelta << ", " + << paymentComponents.trackedPrincipalDelta << ", " + << paymentComponents.trackedInterestPart() << ", " + << paymentComponents.trackedManagementFeeDelta << ", " << [&]() -> char const* { + if (paymentComponents.specialCase == ::xrpl::detail::PaymentSpecialCase::Final) + return "final"; + if (paymentComponents.specialCase == ::xrpl::detail::PaymentSpecialCase::Extra) + return "extra"; + return "none"; + }() << std::endl; + } + + auto const totalDueAmount = + STAmount{broker.asset, paymentComponents.trackedValueDelta + serviceFee}; + + if (paymentParams.validateBalances) + { + // Due to the rounding algorithms to keep the interest and + // principal in sync with "true" values, the computed amount + // may be a little less than the rounded fixed payment + // amount. For integral types, the difference should be < 3 + // (1 unit for each of the interest and management fee). For + // IOUs, the difference should be dust. + Number const diff = totalDue - totalDueAmount; + BEAST_EXPECT( + paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final || + diff == beast::kZero || + (diff > beast::kZero && + ((broker.asset.integral() && (static_cast(diff) < 3)) || + (state.loanScale - diff.exponent() > 13)))); + + BEAST_EXPECT( + paymentComponents.trackedPrincipalDelta >= beast::kZero && + paymentComponents.trackedPrincipalDelta <= state.principalOutstanding); + BEAST_EXPECT( + paymentComponents.specialCase != xrpl::detail::PaymentSpecialCase::Final || + paymentComponents.trackedPrincipalDelta == state.principalOutstanding); + } + + auto const borrowerBalanceBeforePayment = env.balance(borrower, broker.asset); + + // Make the payment + env(pay(borrower, loanKeylet.key, transactionAmount, paymentParams.flags)); + + env.close(d{state.paymentInterval / 2}); + + if (paymentParams.validateBalances) + { + // Need to account for fees if the loan is in XRP + PrettyAmount adjustment = broker.asset(0); + if (broker.asset.native()) + { + adjustment = env.current()->fees().base; + } + + // Check the result + verifyLoanStatus.checkPayment( + state.loanScale, + borrower, + borrowerBalanceBeforePayment, + totalDueAmount, + adjustment); + } + + if (showStepBalances) + { + auto const loanSle = env.le(loanKeylet); + if (!BEAST_EXPECT(loanSle)) + { + // No reason for this not to exist + return; + } + auto const current = constructLoanState(loanSle); + auto const errors = nextTrueState - current; + log << currencyLabel << " Loan balances: " + << "\n\tAmount taken: " << paymentComponents.trackedValueDelta + << "\n\tTotal value: " << current.valueOutstanding + << " (true: " << truncate(nextTrueState.valueOutstanding) + << ", error: " << truncate(errors.total()) + << ")\n\tPrincipal: " << current.principalOutstanding + << " (true: " << truncate(nextTrueState.principalOutstanding) + << ", error: " << truncate(errors.principal) + << ")\n\tInterest: " << current.interestDue + << " (true: " << truncate(nextTrueState.interestDue) + << ", error: " << truncate(errors.interest) + << ")\n\tMgmt fee: " << current.managementFeeDue + << " (true: " << truncate(nextTrueState.managementFeeDue) + << ", error: " << truncate(errors.managementFee) << ")\n\tPayments remaining " + << loanSle->at(sfPaymentRemaining) << std::endl; + + currentRoundedState = current; + } + + --state.paymentRemaining; + state.previousPaymentDate = state.nextPaymentDate; + if (paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final) + { + state.paymentRemaining = 0; + state.nextPaymentDate = 0; + } + else + { + state.nextPaymentDate += state.paymentInterval; + } + state.principalOutstanding -= paymentComponents.trackedPrincipalDelta; + state.managementFeeOutstanding -= paymentComponents.trackedManagementFeeDelta; + state.totalValue -= paymentComponents.trackedValueDelta; + + if (paymentParams.validateBalances) + verifyLoanStatus(state); + + totalPaid.trackedValueDelta += paymentComponents.trackedValueDelta; + totalPaid.trackedPrincipalDelta += paymentComponents.trackedPrincipalDelta; + totalPaid.trackedManagementFeeDelta += paymentComponents.trackedManagementFeeDelta; + totalInterestPaid += paymentComponents.trackedInterestPart(); + totalFeesPaid += serviceFee; + ++totalPaymentsMade; + + currentTrueState = nextTrueState; + } + validateBorrowerBalance(); + + // Loan is paid off + BEAST_EXPECT(state.paymentRemaining == 0); + BEAST_EXPECT(state.principalOutstanding == 0); + + auto const initialInterestDue = initialState.totalValue - + (initialState.principalOutstanding + initialState.managementFeeOutstanding); + if (paymentParams.validateBalances) + { + // Make sure all the payments add up + BEAST_EXPECT(totalPaid.trackedValueDelta == initialState.totalValue); + BEAST_EXPECT(totalPaid.trackedPrincipalDelta == initialState.principalOutstanding); + BEAST_EXPECT( + totalPaid.trackedManagementFeeDelta == initialState.managementFeeOutstanding); + // This is almost a tautology given the previous checks, but + // check it anyway for completeness. + BEAST_EXPECT(totalInterestPaid == initialInterestDue); + BEAST_EXPECT(totalPaymentsMade == initialState.paymentRemaining); + } + + if (showStepBalances) + { + auto const loanSle = env.le(loanKeylet); + if (!BEAST_EXPECT(loanSle)) + { + // No reason for this not to exist + return; + } + log << currencyLabel << " Total amounts paid: " + << "\n\tTotal value: " << totalPaid.trackedValueDelta + << " (initial: " << truncate(initialState.totalValue) + << ", error: " << truncate(initialState.totalValue - totalPaid.trackedValueDelta) + << ")\n\tPrincipal: " << totalPaid.trackedPrincipalDelta + << " (initial: " << truncate(initialState.principalOutstanding) << ", error: " + << truncate(initialState.principalOutstanding - totalPaid.trackedPrincipalDelta) + << ")\n\tInterest: " << totalInterestPaid + << " (initial: " << truncate(initialInterestDue) + << ", error: " << truncate(initialInterestDue - totalInterestPaid) + << ")\n\tMgmt fee: " << totalPaid.trackedManagementFeeDelta + << " (initial: " << truncate(initialState.managementFeeOutstanding) << ", error: " + << truncate( + initialState.managementFeeOutstanding - totalPaid.trackedManagementFeeDelta) + << ")\n\tTotal payments made: " << totalPaymentsMade << std::endl; + } + } + + void + runLoan( + AssetType assetType, + BrokerParameters const& brokerParams, + LoanParameters const& loanParams, + FeatureBitset features) + { + using namespace jtx; + + Account const issuer("issuer"); + Account const lender("lender"); + Account const borrower("borrower"); + + Env env(*this, features); + + auto loanResult = + createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); + if (BEAST_EXPECT(loanResult); !loanResult.has_value()) + return; + + auto broker = std::get(*loanResult); + auto loanKeylet = std::get(*loanResult); + auto pseudoAcct = std::get(*loanResult); + + VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); + + makeLoanPayments( + env, + broker, + loanParams, + loanKeylet, + verifyLoanStatus, + issuer, + lender, + borrower, + PaymentParameters{.showStepBalances = true}); + } + + /** + * Runs through the complete lifecycle of a loan + * + * 1. Create a loan. + * 2. Test a bunch of transaction failure conditions. + * 3. Use the `toEndOfLife` callback to take the loan to 0. How that is done + * depends on the callback. e.g. Default, Early payoff, make all the + * normal payments, etc. + * 4. Delete the loan. The loan will alternate between being deleted by the + * lender and the borrower. + */ + void + lifecycle( + std::string const& caseLabel, + char const* label, + jtx::Env& env, + Number const& loanAmount, + int interestExponent, + jtx::Account const& lender, + jtx::Account const& borrower, + jtx::Account const& evan, + BrokerInfo const& broker, + jtx::Account const& pseudoAcct, + std::uint32_t flags, + // The end of life callback is expected to take the loan to 0 payments + // remaining, one way or another + std::function + toEndOfLife) + { + auto const [keylet, loanSequence] = [&]() { + auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + { + // will be invalid + return std::make_pair(keylet::loan(broker.brokerID), std::uint32_t(0)); + } + + // Broker has no loans + BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); + + // The loan keylet is based on the LoanSequence of the _LOAN_BROKER_ + // object. + auto const loanSequence = brokerSle->at(sfLoanSequence); + return std::make_pair(keylet::loan(broker.brokerID, loanSequence), loanSequence); + }(); + + VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, keylet); + + // No loans yet + verifyLoanStatus.checkBroker(0, 0, TenthBips32{0}, 1, 0, 0); + + if (!BEAST_EXPECT(loanSequence != 0)) + return; + + testcase << caseLabel << " " << label; + + using namespace jtx; + using namespace loan; + using namespace std::chrono_literals; + + auto applyExponent = [interestExponent, this](TenthBips32 value) mutable { + BEAST_EXPECT(value > TenthBips32(0)); + while (interestExponent > 0) + { + auto const oldValue = value; + value *= 10; + --interestExponent; + BEAST_EXPECT(value / 10 == oldValue); + } + while (interestExponent < 0) + { + auto const oldValue = value; + value /= 10; + ++interestExponent; + BEAST_EXPECT(value * 10 == oldValue); + } + return value; + }; + + auto const borrowerOwnerCount = env.ownerCount(borrower); + + auto const loanSetFee = env.current()->fees().base * 2; + LoanParameters const loanParams{ + .account = borrower, + .counter = lender, + .counterpartyExplicit = false, + .principalRequest = loanAmount, + .setFee = loanSetFee, + .originationFee = 1, + .serviceFee = 2, + .lateFee = 3, + .closeFee = 4, + .overFee = applyExponent(percentageToTenthBips(5) / 10), + .interest = applyExponent(percentageToTenthBips(12)), + // 2.4% + .lateInterest = applyExponent(percentageToTenthBips(24) / 10), + .closeInterest = applyExponent(percentageToTenthBips(36) / 10), + .overpaymentInterest = applyExponent(percentageToTenthBips(48) / 10), + .payTotal = 12, + .payInterval = 600, + .gracePd = 60, + .flags = flags, + }; + Number const principalRequestAmount = broker.asset(loanParams.principalRequest).value(); + auto const originationFeeAmount = broker.asset(*loanParams.originationFee).value(); + auto const serviceFeeAmount = broker.asset(*loanParams.serviceFee).value(); + auto const lateFeeAmount = broker.asset(*loanParams.lateFee).value(); + auto const closeFeeAmount = broker.asset(*loanParams.closeFee).value(); + + auto const borrowerStartbalance = env.balance(borrower, broker.asset); + + auto createJtx = loanParams(env, broker); + // Successfully create a Loan + env(createJtx); + + env.close(); + + auto const startDate = env.current()->header().parentCloseTime.time_since_epoch().count(); + + if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + BEAST_EXPECT(brokerSle)) + { + BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 1); + } + + { + // Need to account for fees if the loan is in XRP + PrettyAmount adjustment = broker.asset(0); + if (broker.asset.native()) + { + adjustment = 2 * env.current()->fees().base; + } + + BEAST_EXPECT( + env.balance(borrower, broker.asset).value() == + borrowerStartbalance.value() + principalRequestAmount - originationFeeAmount - + adjustment.value()); + } + + auto const loanFlags = + createJtx.stx->isFlag(tfLoanOverpayment) ? lsfLoanOverpayment : LedgerSpecificFlags(0); + + if (auto loan = env.le(keylet); BEAST_EXPECT(loan)) + { + // log << "loan after create: " << to_string(loan->getJson()) + // << std::endl; + BEAST_EXPECT( + loan->isFlag(lsfLoanOverpayment) == createJtx.stx->isFlag(tfLoanOverpayment)); + BEAST_EXPECT(loan->at(sfLoanSequence) == loanSequence); + BEAST_EXPECT(loan->at(sfBorrower) == borrower.id()); + BEAST_EXPECT(loan->at(sfLoanBrokerID) == broker.brokerID); + BEAST_EXPECT(loan->at(sfLoanOriginationFee) == originationFeeAmount); + BEAST_EXPECT(loan->at(sfLoanServiceFee) == serviceFeeAmount); + BEAST_EXPECT(loan->at(sfLatePaymentFee) == lateFeeAmount); + BEAST_EXPECT(loan->at(sfClosePaymentFee) == closeFeeAmount); + BEAST_EXPECT(loan->at(sfOverpaymentFee) == *loanParams.overFee); + BEAST_EXPECT(loan->at(sfInterestRate) == *loanParams.interest); + BEAST_EXPECT(loan->at(sfLateInterestRate) == *loanParams.lateInterest); + BEAST_EXPECT(loan->at(sfCloseInterestRate) == *loanParams.closeInterest); + BEAST_EXPECT(loan->at(sfOverpaymentInterestRate) == *loanParams.overpaymentInterest); + BEAST_EXPECT(loan->at(sfStartDate) == startDate); + BEAST_EXPECT(loan->at(sfPaymentInterval) == *loanParams.payInterval); + BEAST_EXPECT(loan->at(sfGracePeriod) == *loanParams.gracePd); + BEAST_EXPECT(loan->at(sfPreviousPaymentDueDate) == 0); + BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == startDate + *loanParams.payInterval); + BEAST_EXPECT(loan->at(sfPaymentRemaining) == *loanParams.payTotal); + BEAST_EXPECT( + loan->at(sfLoanScale) >= + (broker.asset.integral() + ? 0 + : std::max(broker.vaultScale(env), principalRequestAmount.exponent()))); + BEAST_EXPECT(loan->at(sfPrincipalOutstanding) == principalRequestAmount); + } + + auto state = getCurrentState(env, broker, keylet, verifyLoanStatus); + + auto const loanProperties = computeLoanProperties( + env.current()->rules(), + broker.asset.raw(), + state.principalOutstanding, + state.interestRate, + state.paymentInterval, + state.paymentRemaining, + broker.params.managementFeeRate, + state.loanScale); + + verifyLoanStatus( + 0, + startDate + *loanParams.payInterval, + *loanParams.payTotal, + state.loanScale, + loanProperties.loanState.valueOutstanding, + principalRequestAmount, + loanProperties.loanState.managementFeeDue, + loanProperties.periodicPayment, + loanFlags | 0); + + // Manage the loan + // no-op + env(manage(lender, keylet.key, 0)); + { + // no flags + auto jt = manage(lender, keylet.key, 0); + jt.removeMember(sfFlags.getName()); + env(jt); + } + // Only the lender can manage + env(manage(evan, keylet.key, 0), Ter(tecNO_PERMISSION)); + // unknown flags + env(manage(lender, keylet.key, tfLoanManageMask), Ter(temINVALID_FLAG)); + // combinations of flags are not allowed + env(manage(lender, keylet.key, tfLoanUnimpair | tfLoanImpair), Ter(temINVALID_FLAG)); + env(manage(lender, keylet.key, tfLoanImpair | tfLoanDefault), Ter(temINVALID_FLAG)); + env(manage(lender, keylet.key, tfLoanUnimpair | tfLoanDefault), Ter(temINVALID_FLAG)); + env(manage(lender, keylet.key, tfLoanUnimpair | tfLoanImpair | tfLoanDefault), + Ter(temINVALID_FLAG)); + // invalid loan ID + env(manage(lender, broker.brokerID, tfLoanImpair), Ter(tecNO_ENTRY)); + // Loan is unimpaired, can't unimpair it again + env(manage(lender, keylet.key, tfLoanUnimpair), Ter(tecNO_PERMISSION)); + // Loan is unimpaired, it can go into default, but only after it's past + // due + env(manage(lender, keylet.key, tfLoanDefault), Ter(tecTOO_SOON)); + + // Check the vault + bool const canImpair = canImpairLoan(env, broker, state); + // Impair the loan, if possible + env(manage(lender, keylet.key, tfLoanImpair), + canImpair ? Ter(tesSUCCESS) : Ter(tecLIMIT_EXCEEDED)); + // Unimpair the loan + env(manage(lender, keylet.key, tfLoanUnimpair), + canImpair ? Ter(tesSUCCESS) : Ter(tecNO_PERMISSION)); + + auto const nextDueDate = startDate + *loanParams.payInterval; + + env.close(); + + verifyLoanStatus( + 0, + nextDueDate, + *loanParams.payTotal, + loanProperties.loanScale, + loanProperties.loanState.valueOutstanding, + principalRequestAmount, + loanProperties.loanState.managementFeeDue, + loanProperties.periodicPayment, + loanFlags | 0); + + // Can't delete the loan yet. It has payments remaining. + env(del(lender, keylet.key), Ter(tecHAS_OBLIGATIONS)); + + if (BEAST_EXPECT(toEndOfLife)) + toEndOfLife(keylet, verifyLoanStatus); + env.close(); + + // Verify the loan is at EOL + if (auto loan = env.le(keylet); BEAST_EXPECT(loan)) + { + BEAST_EXPECT(loan->at(sfPaymentRemaining) == 0); + BEAST_EXPECT(loan->at(sfPrincipalOutstanding) == 0); + } + auto const borrowerStartingBalance = env.balance(borrower, broker.asset); + + // Try to delete the loan broker with an active loan + env(loan_broker::del(lender, broker.brokerID), Ter(tecHAS_OBLIGATIONS)); + // Ensure the above tx doesn't get ordered after the LoanDelete and + // delete our broker! + env.close(); + + // Test failure cases + env(del(lender, keylet.key, tfLoanOverpayment), Ter(temINVALID_FLAG)); + env(del(evan, keylet.key), Ter(tecNO_PERMISSION)); + env(del(lender, broker.brokerID), Ter(tecNO_ENTRY)); + + // Delete the loan + // Either the borrower or the lender can delete the loan. Alternate + // between who does it across tests. + static unsigned kDeleteCounter = 0; + auto const deleter = ((++kDeleteCounter % 2) != 0u) ? lender : borrower; + env(del(deleter, keylet.key)); + env.close(); + + PrettyAmount adjustment = broker.asset(0); + if (deleter == borrower) + { + // Need to account for fees if the loan is in XRP + if (broker.asset.native()) + { + adjustment = env.current()->fees().base; + } + } + + // No loans left + verifyLoanStatus.checkBroker(0, 0, *loanParams.interest, 1, 0, 0); + + BEAST_EXPECT( + env.balance(borrower, broker.asset).value() == + borrowerStartingBalance.value() - adjustment); + BEAST_EXPECT(env.ownerCount(borrower) == borrowerOwnerCount); + + if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + BEAST_EXPECT(brokerSle)) + { + BEAST_EXPECT(brokerSle->at(sfOwnerCount) == 0); + } + } + + static std::string + getCurrencyLabel(Asset const& asset) + { + if (asset.native()) + return "XRP"; + if (asset.holds()) + return "IOU"; + if (asset.holds()) + return "MPT"; + return "Unknown"; + } + + /** + * Wrapper to run a series of lifecycle tests for a given asset and loan + * amount + * + * Will be used in the future to vary the loan parameters. For now, it is + * only called once. + * + * Tests a bunch of LoanSet failure conditions before lifecycle. + */ + template + void + testCaseWrapper( + jtx::Env& env, + jtx::MPTTester& mptt, + std::array const& assets, + BrokerInfo const& broker, + Number const& loanAmount, + int interestExponent) + { + using namespace jtx; + using namespace lending; + + auto const& asset = broker.asset.raw(); + auto const currencyLabel = getCurrencyLabel(asset); + auto const caseLabel = [&]() { + std::stringstream ss; + ss << "Lifecycle: " << loanAmount << " " << currencyLabel + << " Scale interest to: " << interestExponent << " "; + return ss.str(); + }(); + testcase << caseLabel; + + using namespace loan; + using namespace std::chrono_literals; + using d = NetClock::duration; + using tp = NetClock::time_point; + + Account const issuer{"issuer"}; + // For simplicity, lender will be the sole actor for the vault & + // brokers. + Account const lender{"lender"}; + // Borrower only wants to borrow + Account const borrower{"borrower"}; + // Evan will attempt to be naughty + Account const evan{"evan"}; + // Do not fund alice + Account const alice{"alice"}; + + Number const principalRequest = broker.asset(loanAmount).value(); + Number const maxCoveredLoanValue = broker.params.maxCoveredLoanValue(0); + BEAST_EXPECT(maxCoveredLoanValue == 1000 * 100 / 10); + Number const maxCoveredLoanRequest = broker.asset(maxCoveredLoanValue).value(); + Number const totalVaultRequest = broker.asset(broker.params.vaultDeposit).value(); + Number const debtMaximumRequest = broker.asset(broker.params.debtMax).value(); + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + + auto const pseudoAcct = brokerPseudoAccount(env, broker, lender); + + auto const baseFee = env.current()->fees().base; + + auto badKeylet = keylet::vault(lender.id(), env.seq(lender)); + // Try some failure cases + // flags are checked first + env(set(evan, broker.brokerID, principalRequest, tfLoanSetMask), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(temINVALID_FLAG)); + + // field length validation + // sfData: good length, bad account + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kData(std::string(kMaxDataPayloadLength, 'X')), + loanSetFee, + Ter(tefBAD_AUTH)); + // sfData: too long + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kData(std::string(kMaxDataPayloadLength + 1, 'Y')), + loanSetFee, + Ter(temINVALID)); + + // field range validation + // sfOverpaymentFee: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kOverpaymentFee(kMaxOverpaymentFee), + loanSetFee, + Ter(tefBAD_AUTH)); + // sfOverpaymentFee: too big + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kOverpaymentFee(kMaxOverpaymentFee + 1), + loanSetFee, + Ter(temINVALID)); + + // sfInterestRate: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kInterestRate(kMaxInterestRate), + loanSetFee, + Ter(tefBAD_AUTH)); + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kInterestRate(TenthBips32(0)), + loanSetFee, + Ter(tefBAD_AUTH)); + // sfInterestRate: too big + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kInterestRate(kMaxInterestRate + 1), + loanSetFee, + Ter(temINVALID)); + // sfInterestRate: too small + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kInterestRate(TenthBips32(-1)), + loanSetFee, + Ter(temINVALID)); + + // sfLateInterestRate: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kLateInterestRate(kMaxLateInterestRate), + loanSetFee, + Ter(tefBAD_AUTH)); + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kLateInterestRate(TenthBips32(0)), + loanSetFee, + Ter(tefBAD_AUTH)); + // sfLateInterestRate: too big + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kLateInterestRate(kMaxLateInterestRate + 1), + loanSetFee, + Ter(temINVALID)); + // sfLateInterestRate: too small + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kLateInterestRate(TenthBips32(-1)), + loanSetFee, + Ter(temINVALID)); + + // sfCloseInterestRate: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kCloseInterestRate(kMaxCloseInterestRate), + loanSetFee, + Ter(tefBAD_AUTH)); + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kCloseInterestRate(TenthBips32(0)), + loanSetFee, + Ter(tefBAD_AUTH)); + // sfCloseInterestRate: too big + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kCloseInterestRate(kMaxCloseInterestRate + 1), + loanSetFee, + Ter(temINVALID)); + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kCloseInterestRate(TenthBips32(-1)), + loanSetFee, + Ter(temINVALID)); + + // sfOverpaymentInterestRate: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kOverpaymentInterestRate(kMaxOverpaymentInterestRate), + loanSetFee, + Ter(tefBAD_AUTH)); + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kOverpaymentInterestRate(TenthBips32(0)), + loanSetFee, + Ter(tefBAD_AUTH)); + // sfOverpaymentInterestRate: too big + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kOverpaymentInterestRate(kMaxOverpaymentInterestRate + 1), + loanSetFee, + Ter(temINVALID)); + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kOverpaymentInterestRate(TenthBips32(-1)), + loanSetFee, + Ter(temINVALID)); + + // sfPaymentTotal: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kPaymentTotal(LoanSet::kMinPaymentTotal), + loanSetFee, + Ter(tefBAD_AUTH)); + // sfPaymentTotal: too small (there is no max) + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kPaymentTotal(LoanSet::kMinPaymentTotal - 1), + loanSetFee, + Ter(temINVALID)); + + // sfPaymentInterval: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kPaymentInterval(LoanSet::kMinPaymentInterval), + loanSetFee, + Ter(tefBAD_AUTH)); + // sfPaymentInterval: too small (there is no max) + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kPaymentInterval(LoanSet::kMinPaymentInterval - 1), + loanSetFee, + Ter(temINVALID)); + + // sfGracePeriod: good value, bad account + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, borrower), + kPaymentInterval(LoanSet::kMinPaymentInterval * 2), + kGracePeriod(LoanSet::kMinPaymentInterval * 2), + loanSetFee, + Ter(tefBAD_AUTH)); + // sfGracePeriod: larger than paymentInterval + env(set(evan, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + kPaymentInterval(LoanSet::kMinPaymentInterval * 2), + kGracePeriod(LoanSet::kMinPaymentInterval * 3), + loanSetFee, + Ter(temINVALID)); + + // insufficient fee - single sign + env(set(borrower, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, lender), + Ter(telINSUF_FEE_P)); + // insufficient fee - multisign + env(signers(lender, 2, {{evan, 1}, {borrower, 1}})); + env(signers(borrower, 2, {{evan, 1}, {lender, 1}})); + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Msig(evan, lender), + Msig(sfCounterpartySignature, evan, borrower), + Fee(env.current()->fees().base * 5 - 1), + Ter(telINSUF_FEE_P)); + // Bad multisign signatures for borrower (Account) + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Msig(alice, issuer), + Msig(sfCounterpartySignature, evan, borrower), + Fee(env.current()->fees().base * 5), + Ter(tefBAD_SIGNATURE)); + // Bad multisign signatures for issuer (Counterparty) + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Msig(evan, lender), + Msig(sfCounterpartySignature, alice, issuer), + Fee(env.current()->fees().base * 5 - 1), + Ter(tefBAD_SIGNATURE)); + env(signers(lender, kNone)); + env(signers(borrower, kNone)); + // multisign sufficient fee, but no signers set up + env(set(borrower, broker.brokerID, principalRequest), + kCounterparty(lender), + Msig(evan, lender), + Msig(sfCounterpartySignature, evan, borrower), + Fee(env.current()->fees().base * 5), + Ter(tefNOT_MULTI_SIGNING)); + // not the broker owner, no counterparty, not signed by broker + // owner + env(set(borrower, broker.brokerID, principalRequest), + Sig(sfCounterpartySignature, evan), + loanSetFee, + Ter(tefBAD_AUTH)); + // not the broker owner, counterparty is borrower + env(set(evan, broker.brokerID, principalRequest), + kCounterparty(borrower), + Sig(sfCounterpartySignature, borrower), + loanSetFee, + Ter(tecNO_PERMISSION)); + // not a LoanBroker object, no counterparty + env(set(lender, badKeylet.key, principalRequest), + Sig(sfCounterpartySignature, evan), + loanSetFee, + Ter(temBAD_SIGNER)); + // not a LoanBroker object, counterparty is valid + env(set(lender, badKeylet.key, principalRequest), + kCounterparty(borrower), + Sig(sfCounterpartySignature, borrower), + loanSetFee, + Ter(tecNO_ENTRY)); + // borrower doesn't exist + env(set(lender, broker.brokerID, principalRequest), + kCounterparty(alice), + Sig(sfCounterpartySignature, alice), + loanSetFee, + Ter(terNO_ACCOUNT)); + + // Request more funds than the vault has available + env(set(evan, broker.brokerID, totalVaultRequest + 1), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(tecINSUFFICIENT_FUNDS)); + + // Request more funds than the broker's first-loss capital can + // cover. + env(set(evan, broker.brokerID, maxCoveredLoanRequest + 1), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(tecINSUFFICIENT_FUNDS)); + + // Frozen trust line / locked MPT issuance + // XRP can not be frozen, but run through the loop anyway to test + // the tecLIMIT_EXCEEDED case + { + auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + if (!BEAST_EXPECT(brokerSle)) + return; + + auto const vaultPseudo = [&]() { + auto const vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID))); + if (!BEAST_EXPECT(vaultSle)) + { + // This will be wrong, but the test has failed anyway. + return Account{lender}; + } + auto vaultPseudo = Account("Vault pseudo-account", vaultSle->at(sfAccount)); + return vaultPseudo; + }(); + + auto const [freeze, deepfreeze, unfreeze, expectedResult] = + [&]() -> std::tuple< + std::function, + std::function, + std::function, + TER> { + // Freeze / lock the asset + std::function const empty; + if (broker.asset.native()) + { + // XRP can't be frozen + return std::make_tuple(empty, empty, empty, tesSUCCESS); + } + if (broker.asset.holds()) + { + auto freeze = [&](Account const& holder) { + env(trust(issuer, holder[iouCurrency_](0), tfSetFreeze)); + }; + auto deepfreeze = [&](Account const& holder) { + env(trust(issuer, holder[iouCurrency_](0), tfSetFreeze | tfSetDeepFreeze)); + }; + auto unfreeze = [&](Account const& holder) { + env(trust( + issuer, holder[iouCurrency_](0), tfClearFreeze | tfClearDeepFreeze)); + }; + return std::make_tuple(freeze, deepfreeze, unfreeze, tecFROZEN); + } + + auto freeze = [&](Account const& holder) { + mptt.set({.account = issuer, .holder = holder, .flags = tfMPTLock}); + }; + auto unfreeze = [&](Account const& holder) { + mptt.set({.account = issuer, .holder = holder, .flags = tfMPTUnlock}); + }; + return std::make_tuple(freeze, empty, unfreeze, tecLOCKED); + }(); + + // Try freezing the accounts that can't be frozen + if (freeze) + { + for (auto const& account : {vaultPseudo, evan}) + { + // Freeze the account + freeze(account); + + // Try to create a loan with a frozen line + env(set(evan, broker.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(expectedResult)); + + // Unfreeze the account + BEAST_EXPECT(unfreeze); + unfreeze(account); + + // Ensure the line is unfrozen with a request that is fine + // except too it requests more principal than the broker can + // carry + env(set(evan, broker.brokerID, debtMaximumRequest + 1), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(tecLIMIT_EXCEEDED)); + } + } + + // Deep freeze the borrower, which prevents them from receiving + // funds + if (deepfreeze) + { + // Make sure evan has a trust line that so the issuer can + // freeze it. (Don't need to do this for the borrower, + // because LoanSet will create a line to the borrower + // automatically.) + env(trust(evan, issuer[iouCurrency_](100'000))); + + for (auto const& account : {// these accounts can't be frozen, which deep freeze + // implies + vaultPseudo, + evan, + // these accounts can't be deep frozen + lender}) + { + // Freeze evan + deepfreeze(account); + + // Try to create a loan with a deep frozen line + env(set(evan, broker.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(expectedResult)); + + // Unfreeze evan + BEAST_EXPECT(unfreeze); + unfreeze(account); + + // Ensure the line is unfrozen with a request that is fine + // except too it requests more principal than the broker can + // carry + env(set(evan, broker.brokerID, debtMaximumRequest + 1), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(tecLIMIT_EXCEEDED)); + } + } + } + + // Finally! Create a loan + + auto coverAvailable = [&env, this](uint256 const& brokerID, Number const& expected) { + if (auto const brokerSle = env.le(keylet::loanBroker(brokerID)); + BEAST_EXPECT(brokerSle)) + { + auto const available = brokerSle->at(sfCoverAvailable); + BEAST_EXPECT(available == expected); + return available; + } + return Number{}; + }; + auto getDefaultInfo = [&env, this](LoanState const& state, BrokerInfo const& broker) { + if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + BEAST_EXPECT(brokerSle)) + { + BEAST_EXPECT( + state.loanScale >= + (broker.asset.integral() + ? 0 + : std::max( + broker.vaultScale(env), state.principalOutstanding.exponent()))); + NumberRoundModeGuard const mg(Number::RoundingMode::Upward); + auto const defaultAmount = roundToAsset( + broker.asset, + std::min( + tenthBipsOfValue( + tenthBipsOfValue( + brokerSle->at(sfDebtTotal), broker.params.coverRateMin), + broker.params.coverRateLiquidation), + state.totalValue - state.managementFeeOutstanding), + state.loanScale); + return std::make_pair(defaultAmount, brokerSle->at(sfOwner)); + } + return std::make_pair(Number{}, AccountID{}); + }; + auto replenishCover = [&env, &coverAvailable]( + BrokerInfo const& broker, + AccountID const& brokerAcct, + Number const& startingCoverAvailable, + Number const& amountToBeCovered) { + coverAvailable(broker.brokerID, startingCoverAvailable - amountToBeCovered); + env(loan_broker::coverDeposit( + brokerAcct, broker.brokerID, STAmount{broker.asset, amountToBeCovered})); + coverAvailable(broker.brokerID, startingCoverAvailable); + env.close(); + }; + + auto defaultImmediately = [&](std::uint32_t baseFlag, bool impair = true) { + return [&, impair, baseFlag]( + Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { + // toEndOfLife + // + // Default the loan + + // Initialize values with the current state + auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus); + BEAST_EXPECT(state.flags == baseFlag); + + auto const& broker = verifyLoanStatus.broker; + auto const startingCoverAvailable = coverAvailable( + broker.brokerID, broker.asset(broker.params.coverDeposit).number()); + + if (impair) + { + // Check the vault + bool const canImpair = canImpairLoan(env, broker, state); + // Impair the loan, if possible + env(manage(lender, loanKeylet.key, tfLoanImpair), + canImpair ? Ter(tesSUCCESS) : Ter(tecLIMIT_EXCEEDED)); + + if (canImpair) + { + state.flags |= tfLoanImpair; + state.nextPaymentDate = env.now().time_since_epoch().count(); + + // Once the loan is impaired, it can't be impaired again + env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION)); + } + verifyLoanStatus(state); + } + + auto const nextDueDate = tp{d{state.nextPaymentDate}}; + + // Can't default the loan yet. The grace period hasn't + // expired + env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecTOO_SOON)); + + // Let some time pass so that the loan can be + // defaulted + env.close(nextDueDate + 60s); + + auto const [amountToBeCovered, brokerAcct] = getDefaultInfo(state, broker); + + // Default the loan + env(manage(lender, loanKeylet.key, tfLoanDefault)); + env.close(); + + // The LoanBroker just lost some of it's first-loss capital. + // Replenish it. + replenishCover(broker, brokerAcct, startingCoverAvailable, amountToBeCovered); + + state.flags |= tfLoanDefault; + state.paymentRemaining = 0; + state.totalValue = 0; + state.principalOutstanding = 0; + state.managementFeeOutstanding = 0; + state.nextPaymentDate = 0; + verifyLoanStatus(state); + + // Once a loan is defaulted, it can't be managed + env(manage(lender, loanKeylet.key, tfLoanUnimpair), Ter(tecNO_PERMISSION)); + env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION)); + // Can't make a payment on it either + env(pay(borrower, loanKeylet.key, broker.asset(300)), Ter(tecKILLED)); + }; + }; + + auto singlePayment = [&](Keylet const& loanKeylet, + VerifyLoanStatus const& verifyLoanStatus, + LoanState& state, + STAmount const& payoffAmount, + std::uint32_t numPayments, + std::uint32_t baseFlag, + std::uint32_t txFlags) { + // toEndOfLife + // + verifyLoanStatus(state); + + // Send some bogus pay transactions + env(pay(borrower, keylet::loan(uint256(0)).key, broker.asset(10), txFlags), + Ter(temINVALID)); + // broker.asset(80) is less than a single payment, but all these + // checks fail before that matters + env(pay(borrower, loanKeylet.key, broker.asset(-80), txFlags), Ter(temBAD_AMOUNT)); + env(pay(borrower, broker.brokerID, broker.asset(80), txFlags), Ter(tecNO_ENTRY)); + env(pay(evan, loanKeylet.key, broker.asset(80), txFlags), Ter(tecNO_PERMISSION)); + + // TODO: Write a general "isFlag" function? See STObject::isFlag. + // Maybe add a static overloaded member? + if (!(state.flags & lsfLoanOverpayment)) + { + // If the loan does not allow overpayments, send a payment that + // tries to make an overpayment. Do not include `txFlags`, so we + // don't end up duplicating the next test transaction. + // + // fixCleanup3_1_3 gates tfLoanOverpayment as a valid flag: + // with fix on → preflight passes, apply returns tecNO_PERMISSION; + // with fix off → preflight rejects the flag, returns temINVALID_FLAG. + bool const hasFix313 = env.current()->rules().enabled(fixCleanup3_1_3); + STAmount const overpayAmount{broker.asset, state.periodicPayment * Number{15, -1}}; + XRPAmount const overpayFee{ + baseFee * (Number{15, -1} / kLoanPaymentsPerFeeIncrement + 1)}; + env(pay(borrower, loanKeylet.key, overpayAmount, tfLoanOverpayment), + Fee(overpayFee), + Ter(hasFix313 ? TER{tecNO_PERMISSION} : TER{temINVALID_FLAG})); + + if (hasFix313) + { + env.disableFeature(fixCleanup3_1_3); + env(pay(borrower, loanKeylet.key, overpayAmount, tfLoanOverpayment), + Fee(overpayFee), + Ter(temINVALID_FLAG)); + env.enableFeature(fixCleanup3_1_3); + } + } + // Try to send a payment marked as multiple mutually exclusive + // payment types. Do not include `txFlags`, so we don't duplicate + // the prior test transaction. + env(pay(borrower, + loanKeylet.key, + broker.asset(state.periodicPayment * 2), + tfLoanLatePayment | tfLoanFullPayment), + Ter(temINVALID_FLAG)); + env(pay(borrower, + loanKeylet.key, + broker.asset(state.periodicPayment * 2), + tfLoanLatePayment | tfLoanOverpayment), + Ter(temINVALID_FLAG)); + env(pay(borrower, + loanKeylet.key, + broker.asset(state.periodicPayment * 2), + tfLoanOverpayment | tfLoanFullPayment), + Ter(temINVALID_FLAG)); + env(pay(borrower, + loanKeylet.key, + broker.asset(state.periodicPayment * 2), + tfLoanLatePayment | tfLoanOverpayment | tfLoanFullPayment), + Ter(temINVALID_FLAG)); + + { + auto const otherAsset = + broker.asset.raw() == assets[0].raw() ? assets[1] : assets[0]; + env(pay(borrower, loanKeylet.key, otherAsset(100), txFlags), Ter(tecWRONG_ASSET)); + } + + // Amount doesn't cover a single payment + env(pay(borrower, loanKeylet.key, STAmount{broker.asset, 1}, txFlags), + Ter(tecINSUFFICIENT_PAYMENT)); + + // Get the balance after these failed transactions take + // fees + auto const borrowerBalanceBeforePayment = env.balance(borrower, broker.asset); + + BEAST_EXPECT(payoffAmount > state.principalOutstanding); + // Try to pay a little extra to show that it's _not_ + // taken + auto const transactionAmount = payoffAmount + broker.asset(10); + + // Send a transaction that tries to pay more than the borrowers's + // balance + XRPAmount const badFee{ + baseFee * + (borrowerBalanceBeforePayment.number() * 2 / state.periodicPayment / + kLoanPaymentsPerFeeIncrement + + 1)}; + env(pay(borrower, + loanKeylet.key, + STAmount{broker.asset, borrowerBalanceBeforePayment.number() * 2}, + txFlags), + Fee(badFee), + Ter(tecINSUFFICIENT_FUNDS)); + + XRPAmount const goodFee{baseFee * (numPayments / kLoanPaymentsPerFeeIncrement + 1)}; + env(pay(borrower, loanKeylet.key, transactionAmount, txFlags), Fee(goodFee)); + + env.close(); + + // log << env.meta()->getJson() << std::endl; + + // Need to account for fees if the loan is in XRP + PrettyAmount adjustment = broker.asset(0); + if (broker.asset.native()) + { + adjustment = badFee + goodFee; + } + + state.paymentRemaining = 0; + state.principalOutstanding = 0; + state.totalValue = 0; + state.managementFeeOutstanding = 0; + state.previousPaymentDate = + state.nextPaymentDate + (state.paymentInterval * (numPayments - 1)); + state.nextPaymentDate = 0; + verifyLoanStatus(state); + + verifyLoanStatus.checkPayment( + state.loanScale, borrower, borrowerBalanceBeforePayment, payoffAmount, adjustment); + + // Can't impair or default a paid off loan + env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION)); + env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecNO_PERMISSION)); + }; + + auto fullPayment = [&](std::uint32_t baseFlag) { + return [&, baseFlag]( + Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { + // toEndOfLife + // + auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus); + env.close(state.startDate + 20s); + auto const loanAge = (env.now() - state.startDate).count(); + BEAST_EXPECT(loanAge == 30); + + // Full payoff amount will consist of + // 1. principal outstanding (1000) + // 2. accrued interest (at 12%) + // 3. prepayment penalty (closeInterest at 3.6%) + // 4. close payment fee (4) + // Calculate these values without the helper functions + // to verify they're working correctly The numbers in + // the below BEAST_EXPECTs may not hold across assets. + Number const interval = state.paymentInterval; + auto const periodicRate = interval * Number(12, -2) / kSecondsInYear; + BEAST_EXPECT( + periodicRate == Number(2283105022831050228ULL, -24, Number::Normalized{})); + STAmount const principalOutstanding{broker.asset, state.principalOutstanding}; + STAmount const accruedInterest{ + broker.asset, state.principalOutstanding * periodicRate * loanAge / interval}; + BEAST_EXPECT(accruedInterest == broker.asset(Number(1141552511415525, -19))); + STAmount const prepaymentPenalty{ + broker.asset, state.principalOutstanding * Number(36, -3)}; + BEAST_EXPECT(prepaymentPenalty == broker.asset(36)); + STAmount const closePaymentFee = broker.asset(4); + auto const payoffAmount = roundToScale( + principalOutstanding + accruedInterest + prepaymentPenalty + closePaymentFee, + state.loanScale); + BEAST_EXPECT( + payoffAmount == + roundToAsset( + broker.asset, + broker.asset(Number(1040000114155251, -12)).number(), + state.loanScale)); + + // The terms of this loan actually make the early payoff + // more expensive than just making payments + BEAST_EXPECT( + payoffAmount > + state.paymentRemaining * (state.periodicPayment + broker.asset(2).value())); + + singlePayment( + loanKeylet, + verifyLoanStatus, + state, + payoffAmount, + 1, + baseFlag, + tfLoanFullPayment); + }; + }; + + auto combineAllPayments = [&](std::uint32_t baseFlag) { + return + [&, baseFlag](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { + // toEndOfLife + // + + auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus); + env.close(); + + BEAST_EXPECT( + STAmount(broker.asset, state.periodicPayment) == + broker.asset(Number(8333457002039338267, -17))); + + // Make all the payments in one transaction + // service fee is 2 + auto const startingPayments = state.paymentRemaining; + STAmount const payoffAmount = [&]() { + NumberRoundModeGuard const mg(Number::RoundingMode::Upward); + auto const rawPayoff = + startingPayments * (state.periodicPayment + broker.asset(2).value()); + STAmount payoffAmount{broker.asset, rawPayoff}; + BEAST_EXPECTS( + payoffAmount == broker.asset(Number(1024014840244721, -12)), + to_string(payoffAmount)); + BEAST_EXPECT(payoffAmount > state.principalOutstanding); + + payoffAmount = roundToScale(payoffAmount, state.loanScale); + + return payoffAmount; + }(); + + auto const totalPayoffValue = + state.totalValue + startingPayments * broker.asset(2).value(); + STAmount const totalPayoffAmount{broker.asset, totalPayoffValue}; + + BEAST_EXPECTS( + totalPayoffAmount == payoffAmount, + "Payoff amount: " + to_string(payoffAmount) + + ". Total Value: " + to_string(totalPayoffAmount)); + + singlePayment( + loanKeylet, + verifyLoanStatus, + state, + payoffAmount, + state.paymentRemaining, + baseFlag, + 0); + }; + }; + + // There are a lot of fields that can be set on a loan, but most + // of them only affect the "math" when a payment is made. The + // only one that really affects behavior is the + // `tfLoanOverpayment` flag. + lifecycle( + caseLabel, + "Loan overpayment allowed - Impair and Default", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + defaultImmediately(lsfLoanOverpayment)); + + lifecycle( + caseLabel, + "Loan overpayment prohibited - Impair and Default", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + 0, + defaultImmediately(0)); + + lifecycle( + caseLabel, + "Loan overpayment allowed - Default without Impair", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + defaultImmediately(lsfLoanOverpayment, false)); + + lifecycle( + caseLabel, + "Loan overpayment prohibited - Default without Impair", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + 0, + defaultImmediately(0, false)); + + lifecycle( + caseLabel, + "Loan overpayment prohibited - Pay off immediately", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + 0, + fullPayment(0)); + + lifecycle( + caseLabel, + "Loan overpayment allowed - Pay off immediately", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + fullPayment(lsfLoanOverpayment)); + + lifecycle( + caseLabel, + "Loan overpayment prohibited - Combine all payments", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + 0, + combineAllPayments(0)); + + lifecycle( + caseLabel, + "Loan overpayment allowed - Combine all payments", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + combineAllPayments(lsfLoanOverpayment)); + + lifecycle( + caseLabel, + "Loan overpayment prohibited - Make payments", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + 0, + [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { + // toEndOfLife + // + // Draw and make multiple payments + auto state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus); + BEAST_EXPECT(state.flags == 0); + env.close(); + + verifyLoanStatus(state); + + env.close(state.startDate + 20s); + auto const loanAge = (env.now() - state.startDate).count(); + BEAST_EXPECT(loanAge == 30); + + // Periodic payment amount will consist of + // 1. principal outstanding (1000) + // 2. interest interest rate (at 12%) + // 3. payment interval (600s) + // 4. loan service fee (2) + // Calculate these values without the helper functions + // to verify they're working correctly The numbers in + // the below BEAST_EXPECTs may not hold across assets. + Number const interval = state.paymentInterval; + auto const periodicRate = interval * Number(12, -2) / kSecondsInYear; + BEAST_EXPECT( + periodicRate == Number(2283105022831050228, -24, Number::Normalized{})); + STAmount const roundedPeriodicPayment{ + broker.asset, + roundPeriodicPayment(broker.asset, state.periodicPayment, state.loanScale)}; + + testcase << currencyLabel << " Payment components: " + << "Payments remaining, rawInterest, rawPrincipal, " + "rawMFee, trackedValueDelta, trackedPrincipalDelta, " + "trackedInterestDelta, trackedMgmtFeeDelta, special"; + + auto const serviceFee = broker.asset(2); + + BEAST_EXPECT( + roundedPeriodicPayment == + roundToScale( + broker.asset( + Number(8333457002039338267, -17), Number::RoundingMode::Upward), + state.loanScale, + Number::RoundingMode::Upward)); + // 83334570.01162141 + // Include the service fee + STAmount const totalDue = roundToScale( + roundedPeriodicPayment + serviceFee, + state.loanScale, + Number::RoundingMode::Upward); + // Only check the first payment since the rounding + // may drift as payments are made + BEAST_EXPECT( + totalDue == + roundToScale( + broker.asset( + Number(8533457002039338267, -17), Number::RoundingMode::Upward), + state.loanScale, + Number::RoundingMode::Upward)); + + { + auto const raw = computeTheoreticalLoanState( + env.current()->rules(), + state.periodicPayment, + periodicRate, + state.paymentRemaining, + broker.params.managementFeeRate); + auto const rounded = constructLoanState( + state.totalValue, + state.principalOutstanding, + state.managementFeeOutstanding); + testcase << currencyLabel << " Loan starting state: " << state.paymentRemaining + << ", " << raw.interestDue << ", " << raw.principalOutstanding << ", " + << raw.managementFeeDue << ", " << rounded.valueOutstanding << ", " + << rounded.principalOutstanding << ", " << rounded.interestDue << ", " + << rounded.managementFeeDue; + } + + // Try to pay a little extra to show that it's _not_ + // taken + STAmount const transactionAmount = + STAmount{broker.asset, totalDue} + broker.asset(10); + // Only check the first payment since the rounding + // may drift as payments are made + BEAST_EXPECT( + transactionAmount == + roundToScale( + broker.asset(Number(9533457002039400, -14), Number::RoundingMode::Upward), + state.loanScale, + Number::RoundingMode::Upward)); + + auto const initialState = state; + xrpl::detail::PaymentComponents totalPaid{ + .trackedValueDelta = 0, + .trackedPrincipalDelta = 0, + .trackedManagementFeeDelta = 0}; + Number totalInterestPaid = 0; + std::size_t totalPaymentsMade = 0; + + xrpl::LoanState currentTrueState = computeTheoreticalLoanState( + env.current()->rules(), + state.periodicPayment, + periodicRate, + state.paymentRemaining, + broker.params.managementFeeRate); + + while (state.paymentRemaining > 0) + { + // Compute the expected principal amount + auto const paymentComponents = xrpl::detail::computePaymentComponents( + env.current()->rules(), + broker.asset.raw(), + state.loanScale, + state.totalValue, + state.principalOutstanding, + state.managementFeeOutstanding, + state.periodicPayment, + periodicRate, + state.paymentRemaining, + broker.params.managementFeeRate); + + BEAST_EXPECTS( + paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final || + paymentComponents.trackedValueDelta <= roundedPeriodicPayment, + "Delta: " + to_string(paymentComponents.trackedValueDelta) + + ", periodic payment: " + to_string(roundedPeriodicPayment)); + + xrpl::LoanState const nextTrueState = computeTheoreticalLoanState( + env.current()->rules(), + state.periodicPayment, + periodicRate, + state.paymentRemaining - 1, + broker.params.managementFeeRate); + xrpl::detail::LoanStateDeltas const deltas = currentTrueState - nextTrueState; + + testcase << currencyLabel << " Payment components: " << state.paymentRemaining + << ", " << deltas.interest << ", " << deltas.principal << ", " + << deltas.managementFee << ", " << paymentComponents.trackedValueDelta + << ", " << paymentComponents.trackedPrincipalDelta << ", " + << paymentComponents.trackedInterestPart() << ", " + << paymentComponents.trackedManagementFeeDelta << ", " + << [&]() -> char const* { + if (paymentComponents.specialCase == + ::xrpl::detail::PaymentSpecialCase::Final) + return "final"; + if (paymentComponents.specialCase == + ::xrpl::detail::PaymentSpecialCase::Extra) + return "extra"; + return "none"; + }(); + + auto const totalDueAmount = STAmount{ + broker.asset, paymentComponents.trackedValueDelta + serviceFee.number()}; + + // Due to the rounding algorithms to keep the interest and + // principal in sync with "true" values, the computed amount + // may be a little less than the rounded fixed payment + // amount. For integral types, the difference should be < 3 + // (1 unit for each of the interest and management fee). For + // IOUs, the difference should be after the 8th digit. + Number const diff = totalDue - totalDueAmount; + BEAST_EXPECT( + paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final || + diff == beast::kZero || + (diff > beast::kZero && + ((broker.asset.integral() && (static_cast(diff) < 3)) || + (state.loanScale - diff.exponent() > 13)))); + + BEAST_EXPECT( + paymentComponents.trackedValueDelta == + paymentComponents.trackedPrincipalDelta + + paymentComponents.trackedInterestPart() + + paymentComponents.trackedManagementFeeDelta); + BEAST_EXPECT( + paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final || + paymentComponents.trackedValueDelta <= roundedPeriodicPayment); + + BEAST_EXPECT( + state.paymentRemaining < 12 || + roundToAsset( + broker.asset, + deltas.principal, + state.loanScale, + Number::RoundingMode::Upward) == + roundToScale( + broker.asset( + Number(8333228691531218890, -17), Number::RoundingMode::Upward), + state.loanScale, + Number::RoundingMode::Upward)); + BEAST_EXPECT( + paymentComponents.trackedPrincipalDelta >= beast::kZero && + paymentComponents.trackedPrincipalDelta <= state.principalOutstanding); + BEAST_EXPECT( + paymentComponents.specialCase != xrpl::detail::PaymentSpecialCase::Final || + paymentComponents.trackedPrincipalDelta == state.principalOutstanding); + BEAST_EXPECT( + paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final || + (state.periodicPayment.exponent() - + (deltas.principal + deltas.interest + deltas.managementFee - + state.periodicPayment) + .exponent()) > 14); + + auto const borrowerBalanceBeforePayment = env.balance(borrower, broker.asset); + + if (canImpairLoan(env, broker, state)) + { + // Making a payment will unimpair the loan + env(manage(lender, loanKeylet.key, tfLoanImpair)); + } + + env.close(); + + // Make the payment + env(pay(borrower, loanKeylet.key, transactionAmount)); + + env.close(); + + // Need to account for fees if the loan is in XRP + PrettyAmount adjustment = broker.asset(0); + if (broker.asset.native()) + { + adjustment = env.current()->fees().base; + } + + // Check the result + verifyLoanStatus.checkPayment( + state.loanScale, + borrower, + borrowerBalanceBeforePayment, + totalDueAmount, + adjustment); + + --state.paymentRemaining; + state.previousPaymentDate = state.nextPaymentDate; + if (paymentComponents.specialCase == xrpl::detail::PaymentSpecialCase::Final) + { + state.paymentRemaining = 0; + state.nextPaymentDate = 0; + } + else + { + state.nextPaymentDate += state.paymentInterval; + } + state.principalOutstanding -= paymentComponents.trackedPrincipalDelta; + state.managementFeeOutstanding -= paymentComponents.trackedManagementFeeDelta; + state.totalValue -= paymentComponents.trackedValueDelta; + + verifyLoanStatus(state); + + totalPaid.trackedValueDelta += paymentComponents.trackedValueDelta; + totalPaid.trackedPrincipalDelta += paymentComponents.trackedPrincipalDelta; + totalPaid.trackedManagementFeeDelta += + paymentComponents.trackedManagementFeeDelta; + totalInterestPaid += paymentComponents.trackedInterestPart(); + ++totalPaymentsMade; + + currentTrueState = nextTrueState; + } + + // Loan is paid off + BEAST_EXPECT(state.paymentRemaining == 0); + BEAST_EXPECT(state.principalOutstanding == 0); + + // Make sure all the payments add up + BEAST_EXPECT(totalPaid.trackedValueDelta == initialState.totalValue); + BEAST_EXPECT(totalPaid.trackedPrincipalDelta == initialState.principalOutstanding); + BEAST_EXPECT( + totalPaid.trackedManagementFeeDelta == initialState.managementFeeOutstanding); + // This is almost a tautology given the previous checks, but + // check it anyway for completeness. + BEAST_EXPECT( + totalInterestPaid == + initialState.totalValue - + (initialState.principalOutstanding + + initialState.managementFeeOutstanding)); + BEAST_EXPECT(totalPaymentsMade == initialState.paymentRemaining); + + // Can't impair or default a paid off loan + env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION)); + env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecNO_PERMISSION)); + }); + +#if LOAN_TODO + // TODO + + /* + LoanPay fails with tecINVARIANT_FAILED error when loan_broker(also + borrower) tries to do the payment. Here's the scenario: Create a XRP + loan with loan broker as borrower, loan origination fee and loan service + fee. Loan broker makes the first payment with periodic payment and loan + service fee. + */ + + auto time = [&](std::string label, std::function timed) { + if (!BEAST_EXPECT(timed)) + return; + + using clock_type = std::chrono::steady_clock; + using duration_type = std::chrono::milliseconds; + + auto const start = clock_type::now(); + timed(); + auto const duration = + std::chrono::duration_cast(clock_type::now() - start); + + log << label << " took " << duration.count() << "ms" << std::endl; + + return duration; + }; + + lifecycle( + caseLabel, + "timing", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { + // Estimate optimal values for kLoanPaymentsPerFeeIncrement and + // kLoanMaximumPaymentsPerTransaction. + using namespace loan; + + auto const state = getCurrentState(env, broker, verifyLoanStatus.keylet); + auto const serviceFee = broker.asset(2).value(); + + STAmount const totalDue{ + broker.asset, + roundPeriodicPayment( + broker.asset, state.periodicPayment + serviceFee, state.loanScale)}; + + // Make a single payment + time("single payment", [&]() { env(pay(borrower, loanKeylet.key, totalDue)); }); + env.close(); + + // Make all but the final payment + auto const numPayments = (state.paymentRemaining - 2); + STAmount const bigPayment{broker.asset, totalDue * numPayments}; + XRPAmount const bigFee{baseFee * (numPayments / kLoanPaymentsPerFeeIncrement + 1)}; + time("ten payments", [&]() { + env(pay(borrower, loanKeylet.key, bigPayment), Fee(bigFee)); + }); + env.close(); + + time("final payment", [&]() { + // Make the final payment + env(pay(borrower, loanKeylet.key, totalDue + STAmount{broker.asset, 1})); + }); + env.close(); + }); + + lifecycle( + caseLabel, + "Loan overpayment allowed - Explicit overpayment", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; }); + + lifecycle( + caseLabel, + "Loan overpayment prohibited - Late payment", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; }); + + lifecycle( + caseLabel, + "Loan overpayment allowed - Late payment", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; }); + + lifecycle( + caseLabel, + "Loan overpayment allowed - Late payment and overpayment", + env, + loanAmount, + interestExponent, + lender, + borrower, + evan, + broker, + pseudoAcct, + tfLoanOverpayment, + [&](Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { throw 0; }); + +#endif + } +}; + +} // namespace xrpl::test diff --git a/src/test/app/lending/LoanValidation_test.cpp b/src/test/app/lending/LoanValidation_test.cpp new file mode 100644 index 0000000000..d2985c4c30 --- /dev/null +++ b/src/test/app/lending/LoanValidation_test.cpp @@ -0,0 +1,558 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl::test { + +class LoanValidation_test : public LoanTestBase +{ +private: + void + testDisabled() + { + testcase("Disabled"); + // Lending Protocol depends on Single Asset Vault (SAV). Test + // combinations of the two amendments. + // Single Asset Vault depends on MPTokensV1, but don't test every combo + // of that. + using namespace jtx; + auto failAll = [this](FeatureBitset features) { + Env env(*this, features); + + Account const alice{"alice"}; + Account const bob{"bob"}; + env.fund(XRP(10000), alice, bob); + + auto const keylet = keylet::loanBroker(alice, env.seq(alice)); + + using namespace std::chrono_literals; + using namespace loan; + + // counter party signature is optional on LoanSet. Confirm that by + // sending transaction without one. + auto setTx = env.jt(set(alice, keylet.key, Number(10000)), Ter(temDISABLED)); + env(setTx); + + // All loan transactions are disabled. + // 1. LoanSet + setTx = env.jt(setTx, Sig(sfCounterpartySignature, bob), Ter(temDISABLED)); + env(setTx); + // Actual sequence will be based off the loan broker, but we + // obviously don't have one of those if the amendment is disabled + auto const loanKeylet = keylet::loan(keylet.key, env.seq(alice)); + // Other Loan transactions are disabled, too. + // 2. LoanDelete + env(del(alice, loanKeylet.key), Ter(temDISABLED)); + // 3. LoanManage + env(manage(alice, loanKeylet.key, tfLoanImpair), Ter(temDISABLED)); + // 4. LoanPay + env(pay(alice, loanKeylet.key, XRP(500)), Ter(temDISABLED)); + }; + failAll(all_ - featureMPTokensV1); + failAll(all_ - featureSingleAssetVault - featureLendingProtocol); + failAll(all_ - featureSingleAssetVault); + failAll(all_ - featureLendingProtocol); + } + + void + testInvalidLoanSet() + { + testcase("Invalid LoanSet"); + using namespace jtx; + using namespace loan; + Account const lender{"lender"}; + Account const issuer{"issuer"}; + Account const borrower{"borrower"}; + Account const sponsor{"sponsor"}; + auto const iou = issuer["IOU"]; + + auto testWrapper = [&](auto&& test) { + Env env(*this); + env.fund(XRP(1'000), lender, issuer, borrower, sponsor); + env(trust(lender, iou(10'000'000))); + env(pay(issuer, lender, iou(5'000'000))); + BrokerInfo const brokerInfo{createVaultAndBroker(env, issuer["IOU"], lender)}; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const debtMaximumRequest = brokerInfo.asset(1'000).value(); + test(env, brokerInfo, loanSetFee, debtMaximumRequest); + }; + + // preflight: + testWrapper([&](Env& env, + BrokerInfo const& brokerInfo, + jtx::Fee const& loanSetFee, + Number const& debtMaximumRequest) { + for (auto const sponsorFlags : {spfSponsorReserve, spfSponsorReserve | spfSponsorFee}) + { + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + sponsor::As(sponsor, sponsorFlags), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(temINVALID_FLAG)); + } + + // first temBAD_SIGNER: TODO + // invalid grace period + { + // zero grace period + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + kGracePeriod(0), + loanSetFee, + Ter(temINVALID)); + + // grace period less than default minimum + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + kGracePeriod(LoanSet::kDefaultGracePeriod - 1), + loanSetFee, + Ter(temINVALID)); + + // grace period greater than payment interval + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + kPaymentInterval(120), + kGracePeriod(121), + loanSetFee, + Ter(temINVALID)); + } + // empty/zero broker ID + { + auto jv = set(borrower, uint256{}, debtMaximumRequest); + + auto testZeroBrokerID = [&](std::string const& id, std::uint32_t flags = 0) { + // empty broker ID + jv[sfLoanBrokerID] = id; + env(jv, + Sig(sfCounterpartySignature, lender), + loanSetFee, + Txflags(flags), + Ter(temINVALID)); + }; + // empty broker ID + testZeroBrokerID(std::string("")); + // zero broker ID + // needs a flag to distinguish the parsed STTx from the prior + // test + testZeroBrokerID(to_string(uint256{}), tfFullyCanonicalSig); + } + + // preflightCheckSigningKey() failure: + // can it happen? the signature is checked before transactor + // executes + + JTx const tx = env.jt( + set(borrower, brokerInfo.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + loanSetFee); + STTx local = *(tx.stx); + auto counterpartySig = local.getFieldObject(sfCounterpartySignature); + auto badPubKey = counterpartySig.getFieldVL(sfSigningPubKey); + badPubKey[20] ^= 0xAA; + counterpartySig.setFieldVL(sfSigningPubKey, badPubKey); + local.setFieldObject(sfCounterpartySignature, counterpartySig); + json::Value jvResult; + jvResult[jss::tx_blob] = strHex(local.getSerializer().slice()); + auto res = env.rpc("json", "submit", to_string(jvResult))["result"]; + BEAST_EXPECT( + res[jss::error] == "invalidTransaction" && + res[jss::error_exception] == + "fails local checks: Counterparty: Invalid signature."); + }); + + // preclaim: + testWrapper([&](Env& env, + BrokerInfo const& brokerInfo, + jtx::Fee const& loanSetFee, + Number const& debtMaximumRequest) { + // canAddHoldingFailure (IOU only, if MPT doesn't have + // MPTCanTransfer set, then can't create Vault/LoanBroker, + // and LoanSet will fail with different error + env(fclear(issuer, asfDefaultRipple)); + env.close(); + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(terNO_RIPPLE)); + }); + + // doApply: + testWrapper([&](Env& env, + BrokerInfo const& brokerInfo, + jtx::Fee const& loanSetFee, + Number const& debtMaximumRequest) { + auto const amt = + env.balance(borrower) - accountReserve(*env.current(), borrower.id(), env.journal); + env(pay(borrower, issuer, amt)); + + // tecINSUFFICIENT_RESERVE + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(tecINSUFFICIENT_RESERVE)); + + // addEmptyHolding failure + env(pay(issuer, borrower, amt)); + env(fset(issuer, asfGlobalFreeze)); + env.close(); + + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + loanSetFee, + Ter(tecFROZEN)); + }); + } + + void + testInvalidLoanDelete() + { + testcase("Invalid LoanDelete"); + using namespace jtx; + using namespace loan; + + // preflight: temINVALID, LoanID == zero + { + Account const alice{"alice"}; + Env env(*this); + env.fund(XRP(1'000), alice); + env.close(); + env(del(alice, beast::kZero), Ter(temINVALID)); + } + } + + void + testInvalidLoanManage() + { + testcase("Invalid LoanManage"); + using namespace jtx; + using namespace loan; + + // preflight: temINVALID, LoanID == zero + { + Account const alice{"alice"}; + Env env(*this); + env.fund(XRP(1'000), alice); + env.close(); + env(manage(alice, beast::kZero, tfLoanDefault), Ter(temINVALID)); + } + } + + void + testInvalidLoanPay() + { + testcase("Invalid LoanPay"); + using namespace jtx; + using namespace loan; + Account const lender{"lender"}; + Account const issuer{"issuer"}; + Account const borrower{"borrower"}; + auto const iou = issuer["IOU"]; + + // preclaim + Env env(*this); + env.fund(XRP(1'000), lender, issuer, borrower); + env(trust(lender, iou(10'000'000))); + env(pay(issuer, lender, iou(5'000'000))); + BrokerInfo brokerInfo{createVaultAndBroker(env, issuer["IOU"], lender)}; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + STAmount const debtMaximumRequest = brokerInfo.asset(1'000).value(); + + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + loanSetFee); + + env.close(); + + std::uint32_t const loanSequence = 1; + auto const loanKeylet = keylet::loan(brokerInfo.brokerID, loanSequence); + + env(fset(issuer, asfGlobalFreeze)); + env.close(); + + // preclaim: tecFROZEN + env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecFROZEN)); + env.close(); + + env(fclear(issuer, asfGlobalFreeze)); + env.close(); + + auto const pseudoBroker = [&]() -> std::optional { + if (auto brokerSle = env.le(keylet::loanBroker(brokerInfo.brokerID)); + BEAST_EXPECT(brokerSle)) + { + return Account{"pseudo", brokerSle->at(sfAccount)}; + } + + return std::nullopt; + }(); + if (!pseudoBroker) + return; + + // Lender and pseudoaccount must both be frozen + env(trust(issuer, lender["IOU"](1'000), lender, tfSetFreeze | tfSetDeepFreeze)); + env(trust( + issuer, (*pseudoBroker)["IOU"](1'000), *pseudoBroker, tfSetFreeze | tfSetDeepFreeze)); + env.close(); + + // preclaim: tecFROZEN due to deep frozen + env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecFROZEN)); + env.close(); + + // Only one needs to be unfrozen + env(trust(issuer, lender["IOU"](1'000), tfClearFreeze | tfClearDeepFreeze)); + env.close(); + + // The payment is late by this point + env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecEXPIRED)); + env.close(); + env(pay(borrower, loanKeylet.key, debtMaximumRequest, tfLoanLatePayment)); + env.close(); + + // preclaim: tecKILLED + // note that tecKILLED in loanMakePayment() + // doesn't happen because of the preclaim check. + env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecKILLED)); + } + + void + testRequireAuth() + { + testcase("Require Auth - Implicit Pseudo-account authorization"); + using namespace jtx; + using namespace loan; + Account const lender{"lender"}; + Account const issuer{"issuer"}; + Account const borrower{"borrower"}; + Env env(*this); + + env.fund(XRP(100'000), issuer, lender, borrower); + env.close(); + + auto asset = MPTTester({ + .env = env, + .issuer = issuer, + .holders = {lender, borrower}, + .flags = kMptDexFlags | tfMPTRequireAuth | tfMPTCanClawback | tfMPTCanLock, + .authHolder = true, + }); + + env(pay(issuer, lender, asset(5'000'000))); + BrokerInfo brokerInfo{createVaultAndBroker(env, asset, lender)}; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + STAmount const debtMaximumRequest = brokerInfo.asset(1'000).value(); + + auto forUnauthAuth = [&](auto&& doTx) { + for (auto const flag : {tfMPTUnauthorize, 0u}) + { + asset.authorize({.account = issuer, .holder = borrower, .flags = flag}); + env.close(); + doTx(flag == 0); + env.close(); + } + }; + + // Can't create a loan if the borrower is not authorized + forUnauthAuth([&](bool authorized) { + auto const err = !authorized ? Ter(tecNO_AUTH) : Ter(tesSUCCESS); + env(set(borrower, brokerInfo.brokerID, debtMaximumRequest), + Sig(sfCounterpartySignature, lender), + loanSetFee, + err); + }); + + static constexpr std::uint32_t kLoanSequence = 1; + auto const loanKeylet = keylet::loan(brokerInfo.brokerID, kLoanSequence); + + // Can't loan pay if the borrower is not authorized + forUnauthAuth([&](bool authorized) { + auto const err = !authorized ? Ter(tecNO_AUTH) : Ter(tesSUCCESS); + env(pay(borrower, loanKeylet.key, debtMaximumRequest), err); + }); + } + + void + testLimitExceeded() + { + testcase("RIPD-4125 - overpayment"); + + using namespace jtx; + + Account const issuer("issuer"); + Account const lender("lender"); + Account const borrower("borrower"); + + BrokerParameters const brokerParams{ + .vaultDeposit = 100'000, + .debtMax = 0, + .coverRateMin = TenthBips32{0}, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + LoanParameters const loanParams{ + .account = lender, + .counter = borrower, + .principalRequest = Number{200000, -6}, + .interest = TenthBips32{50000}, + .payTotal = 3, + .payInterval = 200, + .gracePd = 60, + .flags = tfLoanOverpayment, + }; + + auto const assetType = AssetType::XRP; + + Env env(*this, makeConfig(), all_, nullptr, beast::Severity::Warning); + + auto loanResult = + createLoan(env, assetType, brokerParams, loanParams, issuer, lender, borrower); + + if (BEAST_EXPECT(loanResult); !loanResult.has_value()) + return; + + auto broker = std::get(*loanResult); + auto loanKeylet = std::get(*loanResult); + auto pseudoAcct = std::get(*loanResult); + + VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, loanKeylet); + + auto const state = getCurrentState(env, broker, loanKeylet); + + env(loan::pay( + borrower, + loanKeylet.key, + STAmount{broker.asset, state.periodicPayment * 3 / 2 + 1}, + tfLoanOverpayment)); + env.close(); + + PaymentParameters const paymentParams{ + .showStepBalances = false, + .validateBalances = true, + }; + + makeLoanPayments( + env, + broker, + loanParams, + loanKeylet, + verifyLoanStatus, + issuer, + lender, + borrower, + paymentParams); + } + + void + testWrongMaxDebtBehavior(FeatureBitset features) + { + // From FIND-003 + testcase << "Wrong Max Debt Behavior"; + + using namespace jtx; + using namespace std::chrono_literals; + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + + BrokerParameters const brokerParams{.debtMax = 0}; + env.fund(XRP(brokerParams.vaultDeposit * 100), issuer, noripple(lender)); + env.close(); + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + + BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + if (auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); + BEAST_EXPECT(brokerSle)) + { + BEAST_EXPECT(brokerSle->at(sfDebtMaximum) == 0); + } + + using namespace loan; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{1, 3}; + + auto createJson = env.json(set(lender, broker.brokerID, principalRequest), Fee(loanSetFee)); + + json::Value counterpartyJson{json::ValueType::Object}; + counterpartyJson[sfTxnSignature] = createJson[sfTxnSignature]; + counterpartyJson[sfSigningPubKey] = createJson[sfSigningPubKey]; + if (!BEAST_EXPECT(!createJson.isMember(jss::Signers))) + counterpartyJson[sfSigners] = createJson[sfSigners]; + + createJson = env.json(createJson, Json(sfCounterpartySignature, counterpartyJson)); + env(createJson); + + env.close(); + } + + void + runAmendmentIndependent() + { + testDisabled(); + testInvalidLoanSet(); + testInvalidLoanDelete(); + testInvalidLoanManage(); + testInvalidLoanPay(); + testRequireAuth(); + testLimitExceeded(); + } + + // Tests run under each entry in amendmentCombinations(). + void + runAmendmentSensitive(FeatureBitset features) + { + testWrongMaxDebtBehavior(features); + } + +public: + void + run() override + { + runAmendmentIndependent(); + for (auto const& features : jtx::amendmentCombinations( + {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) + runAmendmentSensitive(features); + } +}; + +BEAST_DEFINE_TESTSUITE(LoanValidation, tx, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/lending/Loan_test.cpp b/src/test/app/lending/Loan_test.cpp new file mode 100644 index 0000000000..717387665e --- /dev/null +++ b/src/test/app/lending/Loan_test.cpp @@ -0,0 +1,46 @@ +#include +#include + +#include +#include +#include + +namespace xrpl::test { + +/** + * Aggregator: running this suite ("Loan") reruns every topical Loan/Lending + * suite in one invocation. Each member suite below remains independently + * runnable under its own name. Declared manual so an unfiltered full test + * run doesn't execute every case twice. + */ +class Loan_test : public beast::unit_test::Suite +{ + void + run() override + { + static constexpr std::array kMembers{ + "LendingHelpers", + "LoanBroker", + "LoanCashBasis", + "LoanCoverFreezeAuth", + "LoanInvariants", + "LoanLifecycle", + "LoanMisc", + "LoanPay", + "LoanRounding", + "LoanSecurity", + "LoanSet", + "LoanValidation", + }; + + for (auto const& info : beast::unit_test::globalSuites()) + { + if (std::ranges::find(kMembers, info.name()) != kMembers.end()) + info.run(runner()); + } + } +}; + +BEAST_DEFINE_TESTSUITE_MANUAL(Loan, tx, xrpl); + +} // namespace xrpl::test From a75488e5ff1420b6312db2edfce2a3691ccf8b6c Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Tue, 4 Aug 2026 17:17:41 +0100 Subject: [PATCH 40/52] docs: Add a fix for `command not found: nix` on macOS (#7951) --- docs/build/nix_troubleshooting.md | 72 +++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/docs/build/nix_troubleshooting.md b/docs/build/nix_troubleshooting.md index ae5cb8059a..fa766c0ee9 100644 --- a/docs/build/nix_troubleshooting.md +++ b/docs/build/nix_troubleshooting.md @@ -3,6 +3,78 @@ Common issues encountered when using the [Nix development shell](./nix.md), and how to resolve them. +## `command not found: nix` after a macOS update + +If a shell suddenly can't find `nix` at all: + +``` +$ nix develop +zsh: command not found: nix +``` + +then Nix is almost certainly still installed — only the shell hook that puts it +on your `PATH` is gone. Confirm that first: + +```bash +ls -l /nix/var/nix/profiles/default/bin/nix +``` + +If that exists, the installation is fine and this is purely a `PATH` problem. + +### Why it happens + +The installer does not touch your dotfiles. Instead it sources a setup script +from the Nix store by editing **system-wide** rc files: + +| Shell | File the installer edits | +| ----- | ------------------------------------- | +| bash | `/etc/bashrc`, `/etc/bash.bashrc` | +| zsh | `/etc/zshrc` | +| fish | `$__fish_sysconf_dir/conf.d/nix.fish` | + +macOS manages `/etc/zshrc`, so an OS update can replace it with the vendor copy +and silently drop the Nix block. `/etc/bashrc` and the fish file usually survive, +which is why the breakage often shows up in zsh only. You can verify this by +diffing against the backup the installer left behind: + +```bash +diff /etc/zshrc /etc/zshrc.backup-before-nix +``` + +If they are identical, the Nix snippet was wiped. This is upstream issue +[NixOS/nix#3616](https://github.com/NixOS/nix/issues/3616). + +### Fix + +To unblock the current shell: + +```bash +. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh +``` + +For a permanent fix, add the snippet to your **user** rc file rather than +restoring `/etc/zshrc` — user dotfiles are not clobbered by OS updates: + +```bash +cat >>~/.zshrc <<'EOF' + +# Nix +if [ -e '/nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh' ]; then + . '/nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh' +fi +# End Nix +EOF +``` + +The scripts guard against double-sourcing via `__ETC_PROFILE_NIX_SOURCED`, so +this is safe even if a system-wide hook is later restored. + +> [!NOTE] +> `/etc/zshrc` and `~/.zshrc` are only read by **interactive** zsh. If the +> snippet is present but `zsh -c '…'`, a script, or an IDE terminal still can't +> find `nix`, that shell is non-interactive — put the snippet in `~/.zshenv` +> instead. + ## Git worktrees If `nix develop` fails with an error like: From 54cfdda00b64b73bf3f9a987ed6467d7fd213e14 Mon Sep 17 00:00:00 2001 From: Pratik Mankawde <3397372+pratikmankawde@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:08:43 +0100 Subject: [PATCH 41/52] fix: Increase manifest protocol message size cap and fix manifests relay Signed-off-by: Pratik Mankawde <3397372+pratikmankawde@users.noreply.github.com> --- cfg/xrpld-example.cfg | 39 ++++++++++ include/xrpl/config/Constants.h | 2 + include/xrpl/server/Manifest.h | 87 ++++++++++++++++++---- src/libxrpl/server/Manifest.cpp | 2 +- src/test/core/Config_test.cpp | 81 ++++++++++++++++++++ src/xrpld/app/main/Application.cpp | 20 ++++- src/xrpld/core/Config.h | 17 +++++ src/xrpld/core/detail/Config.cpp | 32 ++++++++ src/xrpld/overlay/Message.h | 35 +++++++-- src/xrpld/overlay/detail/OverlayImpl.cpp | 52 ++++++------- src/xrpld/overlay/detail/OverlayImpl.h | 8 ++ src/xrpld/overlay/detail/PeerImp.h | 16 ++++ src/xrpld/overlay/detail/ProtocolMessage.h | 15 ++-- 13 files changed, 347 insertions(+), 59 deletions(-) diff --git a/cfg/xrpld-example.cfg b/cfg/xrpld-example.cfg index 6a44561c68..747bafe077 100644 --- a/cfg/xrpld-example.cfg +++ b/cfg/xrpld-example.cfg @@ -549,6 +549,45 @@ # only be used for local testing and debugging. Do not disable # on mainnet. # +# max_untrusted_count = +# +# The number of manifests the server keeps for validators it does not +# list, and the number it sends and processes in a single peer protocol +# message. Once the server holds this many, a manifest for a new +# unlisted validator is rejected, so peer gossip cannot grow the cache +# without end. +# +# This option can take any value between 50 and 1000, inclusive. If +# the option is not present the server uses its built-in value. +# +# The current default (which is subject to change) is 300. +# +# max_trusted_count = +# +# The number of manifests for listed validators to allow for when +# sizing peer protocol messages. Manifests for listed validators are +# never dropped, whether sending or receiving, because doing so would +# delay a validator key change reaching this server. Set this above the +# number of validators the server lists. +# +# Together the two counts above set the largest manifest message the +# server accepts: bigger messages are discarded without reading them, +# and without penalising the sender. Raising either means the server +# accepts and sends bigger messages than a peer using the defaults, and +# those peers will discard what this server sends. Lowering either below +# what peers send makes this server discard their manifest messages, +# which it does without recording anything. +# +# This option can take any value between 50 and 1000, inclusive. If +# the option is not present the server uses its built-in value. +# +# The current default (which is subject to change) is 300. +# +# NOTE: These two options (max_untrusted_count and max_trusted_count) +# are transitional. They exist to bound manifest-message size and cache +# growth during the network upgrade. They may be removed in a future +# release once the fleet has upgraded, and should not be relied upon as +# stable configuration. # # [transaction_queue] EXPERIMENTAL # diff --git a/include/xrpl/config/Constants.h b/include/xrpl/config/Constants.h index 0fe4efee63..85d9e3f147 100644 --- a/include/xrpl/config/Constants.h +++ b/include/xrpl/config/Constants.h @@ -119,7 +119,9 @@ struct Keys static constexpr auto kLogInterval = "log_interval"; static constexpr auto kMaxDivergedTime = "max_diverged_time"; static constexpr auto kMaxLedgerCountsToStore = "max_ledger_counts_to_store"; + static constexpr auto kMaxTrustedCount = "max_trusted_count"; static constexpr auto kMaxUnknownTime = "max_unknown_time"; + static constexpr auto kMaxUntrustedCount = "max_untrusted_count"; static constexpr auto kMaximumTxnInLedger = "maximum_txn_in_ledger"; static constexpr auto kMaximumTxnPerAccount = "maximum_txn_per_account"; static constexpr auto kMemoryLevel = "memory_level"; diff --git a/include/xrpl/server/Manifest.h b/include/xrpl/server/Manifest.h index 2de5bf4752..786967b057 100644 --- a/include/xrpl/server/Manifest.h +++ b/include/xrpl/server/Manifest.h @@ -201,21 +201,67 @@ constexpr std::size_t kMaxManifestBytes = 358; constexpr std::size_t kMaxManifestBase64 = base64::encodedSize(kMaxManifestBytes); /** - * Maximum number of manifests carried in a single TMManifests message. + * Default number of untrusted manifests to store in cache and allowed + * in one Manifest message. * - * Outbound, the TMManifests message sent to a peer includes every trusted - * manifest and fills the rest of this budget with untrusted gossip, so it - * never exceeds this size. Inbound, trusted manifests are always processed - * and untrusted ones are processed up to this many, so a peer sending its - * whole cache cannot force unbounded work. + * Bounds unlisted validators two ways. In the cache, a manifest for a + * brand-new unlisted key is rejected once this many are held, so peer gossip + * cannot grow the cache without end. In a TMManifests message, this many are + * sent and processed, so a peer sending its whole cache cannot force unbounded + * work. * - * The trusted set is tiny relative to this bound, so trusted manifests are - * not dropped in practice. This is a transitional per-message cap; the cache - * already bounds untrusted manifests (see kMaxUntrustedCount), so it is no - * longer needed once the network has upgraded past nodes that send their - * whole cache in one message. + * Operators can override this with `[overlay] max_untrusted_count`. Both users + * read the configured value and fall back to this default. */ -constexpr std::size_t kMaxManifestsPerMessage = 200; +constexpr std::size_t kMaxUntrustedCount = 300; + +/** + * Default number of trusted manifests allowed in a Manifest message. + * Not used atm while creating the message, but used to calculate the higher limit on + * received message size. Introduced to maintain consistency. Future implementation + * will use this limit. + * + * Trusted manifests are never dropped: every one this node holds is sent, and + * every one received is processed, since dropping one would delay a validator + * key rotation. This count only sizes the largest message accepted, so it must + * stay above any realistic validator list. Cap can be increased in the config + * file if messages get rejected with actual trusted manifest count crossing + * configured(or else default) value. + * Operators can override this with `[overlay] max_trusted_count`. + */ +constexpr std::size_t kMaxTrustedCount = 300; + +/** + * Number of untrusted manifests to store in cache and allowed + * in one Manifest message.. + * + * Returns the operator's override when one is configured, otherwise + * @ref kMaxUntrustedCount. Config stores an override rather than the default + * itself because the core module cannot depend on this module. + * + * @param configured The value from `[overlay] max_untrusted_count`, or + * `std::nullopt` when the operator did not set it. + */ +constexpr std::size_t +untrustedManifestCount(std::optional const& configured) +{ + return configured.value_or(kMaxUntrustedCount); +} + +/** + * Number of trusted manifests allowed in a Manifest message. + * + * Not a cap on how many are sent or processed; see @ref kMaxTrustedCount. + * but used to calculate the higher limit on received message size. + * + * @param configured The value from `[overlay] max_trusted_count`, or + * `std::nullopt` when the operator did not set it. + */ +constexpr std::size_t +trustedManifestCount(std::optional const& configured) +{ + return configured.value_or(kMaxTrustedCount); +} /** * Constructs Manifest from serialized string @@ -361,9 +407,10 @@ private: /** * Maximum number of untrusted master keys kept in the cache. * - * Once reached, a manifest for a brand-new unlisted key is rejected. + * Once reached, a manifest for a brand-new unlisted key is rejected. Set + * from the config, defaulting to @ref kMaxUntrustedCount. */ - static constexpr std::size_t kMaxUntrustedCount = 100; + std::size_t const maxUntrustedCount_; /** * Running count of manifests rejected because the untrusted cap was full. @@ -381,7 +428,17 @@ private: static constexpr std::uint64_t kUntrustedRejectCount = 10000; public: - explicit ManifestCache(beast::Journal j = beast::Journal(beast::Journal::getNullSink())) : j_(j) + /** + * @param j Journal for logging. + * + * @param maxUntrustedCount Untrusted master keys to keep. Pass the + * configured value; defaults to @ref kMaxUntrustedCount. Taken as a + * parameter because this module cannot depend on the config. + */ + explicit ManifestCache( + beast::Journal j = beast::Journal(beast::Journal::getNullSink()), + std::size_t maxUntrustedCount = kMaxUntrustedCount) + : j_(j), maxUntrustedCount_(maxUntrustedCount) { } diff --git a/src/libxrpl/server/Manifest.cpp b/src/libxrpl/server/Manifest.cpp index 3da3f9e9cd..0760196a3b 100644 --- a/src/libxrpl/server/Manifest.cpp +++ b/src/libxrpl/server/Manifest.cpp @@ -487,7 +487,7 @@ ManifestCache::applyManifest(Manifest m, ManifestRateLimitCapPolicy const cap) lock.owns_lock(), "xrpl::ManifestCache::applyManifest::atUntrustedCap : locked"); (void)lock; // not used. parameter is present to ensure the mutex is // locked when the lambda is called. - if (iter == map_.end() && !uncapped && untrustedKeys_.size() >= kMaxUntrustedCount) + if (iter == map_.end() && !uncapped && untrustedKeys_.size() >= maxUntrustedCount_) { // Log each rejection at debug, but warn only once per interval so a // flood does not fill the log. diff --git a/src/test/core/Config_test.cpp b/src/test/core/Config_test.cpp index e98a0e1e88..ac5471fd3c 100644 --- a/src/test/core/Config_test.cpp +++ b/src/test/core/Config_test.cpp @@ -1575,6 +1575,87 @@ r.ripple.com:51235 // Above upper bound BEAST_EXPECT(!testDiverged("901")); + + testcase("overlay: manifest counts"); + + // Both keys share one range and one parse path, so exercise each + // through the same helper. + auto testCount = [](std::string const& key, + std::string const& value) -> std::optional { + try + { + Config c; + c.loadFromString("[overlay]\n" + key + "=" + value); + return key == "max_trusted_count" ? c.maxTrustedCount : c.maxUntrustedCount; + } + catch (std::runtime_error const&) + { + return {}; + } + }; + + for (auto const* key : {"max_untrusted_count", "max_trusted_count"}) + { + // Failures. A bad value must surface as std::runtime_error, not + // the std::bad_cast that the underlying parse throws. + BEAST_EXPECT(!testCount(key, "none")); + BEAST_EXPECT(!testCount(key, "0.5")); + BEAST_EXPECT(!testCount(key, "400 manifests")); + BEAST_EXPECT(!testCount(key, "-1")); + + // Below lower bound + BEAST_EXPECT(!testCount(key, "0")); + BEAST_EXPECT(!testCount(key, "49")); + + // In bounds + BEAST_EXPECT(testCount(key, "50") == 50); + BEAST_EXPECT(testCount(key, "51") == 51); + BEAST_EXPECT(testCount(key, "300") == 300); + BEAST_EXPECT(testCount(key, "400") == 400); + BEAST_EXPECT(testCount(key, "999") == 999); + BEAST_EXPECT(testCount(key, "1000") == 1000); + + // Above upper bound + BEAST_EXPECT(!testCount(key, "1001")); + } + + // Each key is independent: setting one leaves the other unset. + { + Config c; + c.loadFromString("[overlay]\nmax_untrusted_count=500"); + BEAST_EXPECT(c.maxUntrustedCount == 500); + BEAST_EXPECT(!c.maxTrustedCount); + } + { + Config c; + c.loadFromString("[overlay]\nmax_trusted_count=500"); + BEAST_EXPECT(c.maxTrustedCount == 500); + BEAST_EXPECT(!c.maxUntrustedCount); + } + + // Both can be set together. + { + Config c; + c.loadFromString("[overlay]\nmax_untrusted_count=250\nmax_trusted_count=750"); + BEAST_EXPECT(c.maxUntrustedCount == 250); + BEAST_EXPECT(c.maxTrustedCount == 750); + } + + // Unset leaves no override, so the use sites fall back to the defaults. + { + Config c; + c.loadFromString("[overlay]\nip_limit=64"); + BEAST_EXPECT(!c.maxUntrustedCount); + BEAST_EXPECT(!c.maxTrustedCount); + } + + // No [overlay] section at all leaves both unset too. + { + Config c; + c.loadFromString(""); + BEAST_EXPECT(!c.maxUntrustedCount); + BEAST_EXPECT(!c.maxTrustedCount); + } } void diff --git a/src/xrpld/app/main/Application.cpp b/src/xrpld/app/main/Application.cpp index d329475874..b0f0226e49 100644 --- a/src/xrpld/app/main/Application.cpp +++ b/src/xrpld/app/main/Application.cpp @@ -91,6 +91,7 @@ #include #include #include +#include #include #include #include @@ -428,8 +429,14 @@ public: , cluster_(std::make_unique(logs_->journal("Overlay"))) , peerReservations_( std::make_unique(logs_->journal("PeerReservationTable"))) - , validatorManifests_(std::make_unique(logs_->journal("ManifestCache"))) - , publisherManifests_(std::make_unique(logs_->journal("ManifestCache"))) + , validatorManifests_( + std::make_unique( + logs_->journal("ManifestCache"), + untrustedManifestCount(config_->maxUntrustedCount))) + , publisherManifests_( + std::make_unique( + logs_->journal("ManifestCache"), + untrustedManifestCount(config_->maxUntrustedCount))) , validators_( std::make_unique( *validatorManifests_, @@ -1190,6 +1197,15 @@ ApplicationImp::setup(boost::program_options::variables_map const& cmdline) JLOG(journal_.info()) << "Process starting: " << BuildInfo::getFullVersionString() << ", Instance Cookie: " << instanceCookie_; + // Log the resolved manifest counts, whether configured or defaulted, so a + // shared log shows what the server is running without needing its config. + JLOG(journal_.warn()) << "Manifest counts: max_untrusted_count " + << untrustedManifestCount(config_->maxUntrustedCount) + << (config_->maxUntrustedCount ? " (configured)" : " (default)") + << ", max_trusted_count " + << trustedManifestCount(config_->maxTrustedCount) + << (config_->maxTrustedCount ? " (configured)" : " (default)"); + if (numberOfThreads(*config_) < 2) { JLOG(journal_.warn()) << "Limited to a single I/O service thread by " diff --git a/src/xrpld/core/Config.h b/src/xrpld/core/Config.h index a7cb5d053a..2492614825 100644 --- a/src/xrpld/core/Config.h +++ b/src/xrpld/core/Config.h @@ -295,6 +295,23 @@ public: // How long can a peer remain in the "diverged" state std::chrono::seconds maxDivergedTime{300}; + // Optional overrides for how many manifests are kept in the cache and + // carried in one TMManifests message, split by whether this node lists the + // validator. Unset means use the built-in defaults (kMaxUntrustedCount and + // kMaxTrustedCount in Manifest.h). Kept as overrides here, rather than the + // defaults themselves, so the core module need not depend on the server + // module that owns the constants. + std::optional maxUntrustedCount; + std::optional maxTrustedCount; + + // Bounds for both counts above. The lower bound leaves room for a small + // network or a deliberately tight limit; note that setting a count below + // what peers actually send means their manifest messages are dropped for + // being oversized. The upper bound keeps the implied message size well + // under the overall protocol message limit. + static constexpr std::size_t kMinManifestCount = 50; + static constexpr std::size_t kMaxManifestCount = 1000; + // Enable the beta API version bool betaRpcApi = false; diff --git a/src/xrpld/core/detail/Config.cpp b/src/xrpld/core/detail/Config.cpp index 616717c5fd..e93ccec56e 100644 --- a/src/xrpld/core/detail/Config.cpp +++ b/src/xrpld/core/detail/Config.cpp @@ -923,6 +923,38 @@ Config::loadFromString(std::string const& fileContents) std::string("Invalid value 'max_diverged_time' in ") + Sections::kOverlay + ": the time must be between 60 and 900 seconds, inclusive."); } + + // Both manifest counts parse and validate identically, so read them + // the same way. Returns nullopt when the key is absent, leaving the + // built-in default in effect at the use site. + auto manifestCount = [&sec](char const* key) -> std::optional { + std::optional count; + + try + { + if (auto val = sec.get(key)) + count = beast::lexicalCastThrow(*val); + } + catch (...) + { + Throw( + std::string("Invalid value '") + key + "' in " + Sections::kOverlay + + ": must be of the form '' representing a count of manifests."); + } + + if (count && (*count < kMinManifestCount || *count > kMaxManifestCount)) + { + Throw( + std::string("Invalid value '") + key + "' in " + Sections::kOverlay + + ": the count must be between " + std::to_string(kMinManifestCount) + " and " + + std::to_string(kMaxManifestCount) + ", inclusive."); + } + + return count; + }; + + maxUntrustedCount = manifestCount(Keys::kMaxUntrustedCount); + maxTrustedCount = manifestCount(Keys::kMaxTrustedCount); } if (getSingleSection(secConfig, Sections::kAmendmentMajorityTime, strTemp, j_)) diff --git a/src/xrpld/overlay/Message.h b/src/xrpld/overlay/Message.h index bd4772b451..065da696d8 100644 --- a/src/xrpld/overlay/Message.h +++ b/src/xrpld/overlay/Message.h @@ -24,12 +24,37 @@ constexpr std::size_t kMaximumMessageSize = megabytes(64); // so we define a separate limit for them. constexpr std::size_t kMaximumPingMessageSize = kilobytes(1); -// Upper bound on the wire size of a TMManifests message: kMaxManifestsPerMessage entries -// of at most kMaxManifestBytes each, plus a small allowance for protobuf -// framing per entry. +// Allowance for protobuf framing around each manifest in a TMManifests message. constexpr std::size_t kManifestFramingBytes = 8; -constexpr std::size_t kMaximumManifestsMessageSize = - kMaxManifestsPerMessage * (kMaxManifestBytes + kManifestFramingBytes); + +/** + * Upper bound on the wire size of a TMManifests message. + * + * Allows both counts' worth of entries at @ref kMaxManifestBytes each, plus + * framing per entry. Messages larger than this are dropped before parsing, + * which bounds the work an oversized message can cause. + * + * @param trustedCount Trusted manifests per message. + * + * @param untrustedCount Untrusted manifests per message. + * + * @note A node that raises either count accepts larger messages than a peer + * running the defaults, and the messages it sends may be dropped by such a + * peer. Lowering either count below what peers send drops their manifest + * messages, including any trusted key rotations they carry, and the drop + * is not recorded on either side. + */ +constexpr std::size_t +maximumManifestsMessageSize(std::size_t const trustedCount, std::size_t const untrustedCount) +{ + return (trustedCount + untrustedCount) * (kMaxManifestBytes + kManifestFramingBytes); +} + +// The message size the defaults imply must stay within the overall protocol +// message limit. The same check for the largest configurable counts lives in +// OverlayImpl.h, where the configured bound is visible. +static_assert( + maximumManifestsMessageSize(kMaxTrustedCount, kMaxUntrustedCount) < kMaximumMessageSize); // VFALCO NOTE If we forward declare Message and write out shared_ptr // instead of using the in-class type alias, we can remove the diff --git a/src/xrpld/overlay/detail/OverlayImpl.cpp b/src/xrpld/overlay/detail/OverlayImpl.cpp index 5bac7df720..d1f145a601 100644 --- a/src/xrpld/overlay/detail/OverlayImpl.cpp +++ b/src/xrpld/overlay/detail/OverlayImpl.cpp @@ -667,7 +667,10 @@ OverlayImpl::onManifests( auto const& journal = from->pJournal(); // Process every trusted manifest, but stop processing untrusted ones once - // kMaxManifestsPerMessage of them have been handled, so the work stays bounded. + // the configured untrusted count has been handled, so the work stays + // bounded. Trusted manifests are always processed: dropping one would delay + // a validator key rotation reaching this node. + auto const maxUntrusted = untrustedManifestCount(app_.config().maxUntrustedCount); auto const total = static_cast(m->list_size()); std::size_t untrusted = 0; bool skippedUntrusted = false; @@ -686,23 +689,18 @@ OverlayImpl::onManifests( // first avoids holding the two locks in opposite orders. bool const isTrusted = app_.getValidators().listed(mo->masterKey); - // Bound untrusted work: process at most kMaxManifestsPerMessage - // untrusted manifests, but never skip a trusted one. Trusted - // manifests are not counted against the cap. + // Bound untrusted work: process at most maxUntrusted untrusted + // manifests, but never skip a trusted one. Trusted manifests are + // not counted against the cap. if (!isTrusted) { - if (untrusted >= kMaxManifestsPerMessage) + if (untrusted >= maxUntrusted) { skippedUntrusted = true; continue; } ++untrusted; } - // Updates to a known key are relayed even when untrusted. Use - // getSequence, not getManifest, to avoid copying the cached payload - // on this hot path. - bool const isKnown = - app_.getValidatorManifests().getSequence(mo->masterKey).has_value(); auto const result = app_.getValidatorManifests().applyManifest( std::move(*mo), @@ -721,22 +719,17 @@ OverlayImpl::onManifests( "deserialization succeeded"); // NOLINTBEGIN(bugprone-unchecked-optional-access) assert above app_.getOPs().pubManifest(*mo); + // NOLINTEND(bugprone-unchecked-optional-access) + + relay.add_list()->set_stobject(s); - // Relay only trusted manifests or updates to known keys, so - // untrusted gossip for a brand-new key cannot be amplified. // Persist to the wallet DB only for trusted keys, so untrusted // gossip never survives a restart. - if (isTrusted || isKnown) + if (isTrusted) { - relay.add_list()->set_stobject(s); - - if (isTrusted) - { - auto db = app_.getWalletDB().checkoutDb(); - addValidatorManifest(*db, serialized); - } + auto db = app_.getWalletDB().checkoutDb(); + addValidatorManifest(*db, serialized); } - // NOLINTEND(bugprone-unchecked-optional-access) } } else @@ -749,13 +742,12 @@ OverlayImpl::onManifests( if (skippedUntrusted) { // The sender exceeded the untrusted per-message cap. Charge it (once, - // here) so a flood of untrusted manifests is penalized, while an honest - // message of trusted manifests never is. + // here) so a flood of untrusted manifests is penalized. from->charge(Resource::kFeeMalformedRequest, "too many untrusted manifests"); JLOG(journal.warn()) << "Manifests: message had " << total - << " entries; processed all trusted plus the first " - << kMaxManifestsPerMessage << " untrusted"; + << " entries; processed all trusted plus the first " << maxUntrusted + << " untrusted"; } if (!relay.list().empty()) @@ -1283,10 +1275,9 @@ OverlayImpl::getManifestsMessage() }); // Phase 2: no cache lock held, so trust checks are safe. Include every - // trusted manifest, then fill any remaining headroom up to - // kMaxManifestsPerMessage with untrusted gossip, so the whole message - // stays within the per-message cap the receiver enforces (trusted - // count is tiny in practice, so this effectively never drops trusted). + // trusted manifest, then fill any remaining headroom up to the + // configured untrusted count with gossip. Trusted manifests are never + // dropped; the trusted count only sizes the accepted message. std::vector selected; std::vector untrusted; for (auto const& e : cached) @@ -1302,7 +1293,8 @@ OverlayImpl::getManifestsMessage() } // Cap untrusted only; trusted manifests are all included above. - auto const take = std::min(kMaxManifestsPerMessage, untrusted.size()); + auto const take = + std::min(untrustedManifestCount(app_.config().maxUntrustedCount), untrusted.size()); selected.insert(selected.end(), untrusted.begin(), untrusted.begin() + take); // Shuffle the order. Cryptographic randomness is not needed here. diff --git a/src/xrpld/overlay/detail/OverlayImpl.h b/src/xrpld/overlay/detail/OverlayImpl.h index 092ac86a6d..4c8fcd2a00 100644 --- a/src/xrpld/overlay/detail/OverlayImpl.h +++ b/src/xrpld/overlay/detail/OverlayImpl.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -54,6 +55,13 @@ namespace xrpl { +// The largest counts an operator can configure must still imply a message size +// within the overall protocol message limit. The same check for the defaults +// lives in Message.h. +static_assert( + maximumManifestsMessageSize(Config::kMaxManifestCount, Config::kMaxManifestCount) < + kMaximumMessageSize); + class PeerImp; class BasicConfig; diff --git a/src/xrpld/overlay/detail/PeerImp.h b/src/xrpld/overlay/detail/PeerImp.h index 0085927550..720d9a1195 100644 --- a/src/xrpld/overlay/detail/PeerImp.h +++ b/src/xrpld/overlay/detail/PeerImp.h @@ -32,6 +32,7 @@ #include #include #include +#include #include #include @@ -464,6 +465,21 @@ public: return compressionEnabled_ == Compressed::On; } + /** + * Largest TMManifests message this node accepts, in bytes. + * + * Read by invokeProtocolMessage to drop oversized messages before + * parsing. Not part of the Peer interface: the message handler is a + * template parameter, so only PeerImp needs to provide this. + */ + [[nodiscard]] std::size_t + maxManifestsMessageSize() const + { + return maximumManifestsMessageSize( + trustedManifestCount(app_.config().maxTrustedCount), + untrustedManifestCount(app_.config().maxUntrustedCount)); + } + bool txReduceRelayEnabled() const override { diff --git a/src/xrpld/overlay/detail/ProtocolMessage.h b/src/xrpld/overlay/detail/ProtocolMessage.h index abd087f3c8..f7d5e26272 100644 --- a/src/xrpld/overlay/detail/ProtocolMessage.h +++ b/src/xrpld/overlay/detail/ProtocolMessage.h @@ -375,13 +375,16 @@ invokeProtocolMessage(Buffers const& buffers, Handler& handler, std::size_t& hin } // Drop an oversized TMManifests without penalty: consume the bytes and - // return no error, so the connection is preserved. - if (header->messageType == protocol::mtMANIFESTS && - (header->payloadWireSize > kMaximumManifestsMessageSize || - header->uncompressedSize > kMaximumManifestsMessageSize)) + // return no error, so the connection is preserved. The limit follows this + // node's configured manifests-per-message count. + if (header->messageType == protocol::mtMANIFESTS) { - result.first = header->totalWireSize; - return result; + auto const maxSize = handler.maxManifestsMessageSize(); + if (header->payloadWireSize > maxSize || header->uncompressedSize > maxSize) + { + result.first = header->totalWireSize; + return result; + } } bool success = false; From 39c8c293b32e345ea8f665ba270a7593fc20b900 Mon Sep 17 00:00:00 2001 From: Bart Date: Tue, 4 Aug 2026 17:11:00 -0400 Subject: [PATCH 42/52] chore: Bump version to 3.3.0-rc7 --- src/libxrpl/protocol/BuildInfo.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libxrpl/protocol/BuildInfo.cpp b/src/libxrpl/protocol/BuildInfo.cpp index 87956a12fd..b3f9128f2e 100644 --- a/src/libxrpl/protocol/BuildInfo.cpp +++ b/src/libxrpl/protocol/BuildInfo.cpp @@ -23,7 +23,7 @@ namespace { //------------------------------------------------------------------------------ // clang-format off // NOLINTNEXTLINE(readability-identifier-naming) -char const* const versionString = "3.3.0-rc6" +char const* const versionString = "3.3.0-rc7" // clang-format on ; From 41d6bb5f736459d31bc3207c7ab77f720aaaed63 Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Wed, 5 Aug 2026 00:27:23 +0100 Subject: [PATCH 43/52] build: Fix build on macOS 15 and Nix environment (#7953) --- BUILD.md | 2 ++ CMakeLists.txt | 19 +++++++++++++++++++ conan.lock | 3 ++- conan/profiles/default | 20 ++++++++++++++++++++ conanfile.py | 2 ++ sanitizers/suppressions/ubsan.supp | 4 ++++ src/libxrpl/json/json_reader.cpp | 12 ++++++++++-- 7 files changed, 59 insertions(+), 3 deletions(-) diff --git a/BUILD.md b/BUILD.md index a15c94edc9..edc52fe3b7 100644 --- a/BUILD.md +++ b/BUILD.md @@ -42,6 +42,8 @@ Our Linux CI tooling is distro-independent and uses a Nix-based environment, so ### macOS Many `xrpld` engineers use macOS for development. +The minimum supported version is macOS 15 (Sequoia). +CI testing is done in macOS 26 (Tahoe), but the build defaults `CMAKE_OSX_DEPLOYMENT_TARGET` to 15. ### Windows diff --git a/CMakeLists.txt b/CMakeLists.txt index f2e8fb3ae5..b7e1c0cad0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -13,6 +13,23 @@ if(DEFINED CMAKE_MODULE_PATH) endif() list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake") +# Must be set before project() because project() consumes it when configuring the compiler and SDK. +# A user-provided -DCMAKE_OSX_DEPLOYMENT_TARGET still takes precedence. +# +# CMAKE_SYSTEM_NAME can't be used before project(), so CMAKE_HOST_SYSTEM_NAME is used instead. +# +# When CMAKE_OSX_DEPLOYMENT_TARGET is bumped to >=26.0, FastFloat dependency won't be needed anymore +if( + CMAKE_HOST_SYSTEM_NAME STREQUAL "Darwin" + AND NOT DEFINED CMAKE_OSX_DEPLOYMENT_TARGET +) + set(CMAKE_OSX_DEPLOYMENT_TARGET + "15.0" + CACHE STRING + "Minimum macOS deployment version" + ) +endif() + project(xrpl) set(CMAKE_CXX_EXTENSIONS OFF) set(CMAKE_CXX_STANDARD 23) @@ -87,6 +104,7 @@ include(deps/Boost) add_subdirectory(external/antithesis-sdk) find_package(date REQUIRED) find_package(ed25519 REQUIRED) +find_package(FastFloat REQUIRED) find_package(gRPC REQUIRED) find_package(LibArchive REQUIRED) find_package(lz4 REQUIRED) @@ -102,6 +120,7 @@ target_link_libraries( xrpl_libs INTERFACE ed25519::ed25519 + FastFloat::fast_float lz4::lz4 mpt-crypto::mpt-crypto OpenSSL::Crypto diff --git a/conan.lock b/conan.lock index c6a4070c77..0e1461ba9c 100644 --- a/conan.lock +++ b/conan.lock @@ -20,6 +20,7 @@ "jemalloc/5.3.1#1fc58d55316041f10fbc1e8a2eae632a%1776700028.228", "gtest/1.17.0#5224b3b3ff3b4ce1133cbdd27d53ee7d%1782392402.791979", "grpc/1.81.1#f729f6d75992d20f9c72828e9142d62f%1783945160.094135", + "fast_float/8.2.10#f6f28d6bb22112078e7dbda611caf681%1782494504.298", "ed25519/2015.03#ae761bdc52730a843f0809bdf6c1b1f6%1782307148.15562", "date/3.0.4#862e11e80030356b53c2c38599ceb32b%1782392402.538492", "c-ares/1.34.6#545240bb1c40e2cacd4362d6b8967650%1782392402.681654", @@ -34,7 +35,7 @@ "protobuf/6.33.5#ff253ead763bd8d9904a52979cd21e81%1782392410.233933", "nasm/2.16.01#31e26f2ee3c4346ecd347911bd126904%1782395690.33162", "msys2/cci.latest#d22fe7b2808f5fd34d0a7923ace9c54f%1770657326.649", - "m4/1.4.19#34c4bbc3eeebe98ca6edf2f52d602e7d%1777282960.259", + "m4/1.4.19#1727f439cf74e83826ec96d0b4904eee%1784541921.659", "cmake/4.3.3#840cf00ea09777e05c2050a50a82c722%1782392418.696091", "b2/5.4.2#ffd6084a119587e70f11cd45d1a386e2%1782392402.624226", "automake/1.16.5#b91b7c384c3deaa9d535be02da14d04f%1755524470.56", diff --git a/conan/profiles/default b/conan/profiles/default index 6534f8092b..f2d93213ac 100644 --- a/conan/profiles/default +++ b/conan/profiles/default @@ -5,6 +5,13 @@ {% if os == "Linux" %} {% set compiler_version = detect_api.default_compiler_version(compiler, version) %} {% endif %} +{% if os == "Macos" %} +{# Minimum macOS the dependencies target. #} +{# Without this, Conan builds each dependency against the (possibly newer) host SDK, so the #} +{# dependency objects target a newer macOS than the binary and the linker warns. #} +{# Keep at or below CMAKE_OSX_DEPLOYMENT_TARGET in CMakeLists.txt. #} +{% set min_macos_version = "15.0" %} +{% endif %} [settings] os={{ os }} @@ -18,6 +25,9 @@ compiler.runtime=static {% else %} compiler.libcxx={{ detect_api.detect_libcxx(compiler, version, compiler_exe) }} {% endif %} +{% if os == "Macos" %} +os.version={{ min_macos_version }} +{% endif %} [conf] {# The Boost recipe builds with b2, which doesn't use Conan's toolchain files. #} @@ -41,3 +51,13 @@ tools.build:compiler_executables={'c':'{{ cc_exe }}','cpp':'{{ cxx_exe }}'} {# More info: https://docs.conan.io/2/reference/extensions/binary_compatibility.html #} user.package:cppstd_version=23 tools.info.package_id:confs+=["user.package:cppstd_version"] + +{% if os == "Macos" %} +[buildenv] +{# os.version adds -mmacosx-version-min to compiler command lines, #} +{# but Boost.Context's b2 assembly (.S) rule ignores it, #} +{# so those objects keep the host SDK version and still warn at link time. #} +{# clang's assembler honors this env var regardless, pinning them. #} +{# Scoped to boost/* since it is the only gap. #} +boost/*:MACOSX_DEPLOYMENT_TARGET={{ min_macos_version }} +{% endif %} diff --git a/conanfile.py b/conanfile.py index f883761f0e..b9fa513d67 100644 --- a/conanfile.py +++ b/conanfile.py @@ -29,6 +29,7 @@ class Xrpl(ConanFile): requires = [ "ed25519/2015.03", + "fast_float/8.2.10", "grpc/1.81.1", "libarchive/3.8.7", "nudb/2.0.9", @@ -211,6 +212,7 @@ class Xrpl(ConanFile): "boost::thread", "date::date", "ed25519::ed25519", + "fast_float::fast_float", "grpc::grpc++", "libarchive::libarchive", "lz4::lz4", diff --git a/sanitizers/suppressions/ubsan.supp b/sanitizers/suppressions/ubsan.supp index cb93a617aa..56f2c77204 100644 --- a/sanitizers/suppressions/ubsan.supp +++ b/sanitizers/suppressions/ubsan.supp @@ -102,6 +102,10 @@ undefined:nudb # Snappy compression library intentional overflows unsigned-integer-overflow:snappy.cc +# fast_float parses floats with a SWAR trick (parse_eight_digits_unrolled) that +# multiplies eight packed digits modulo 2^64; the wraparound is by design. +unsigned-integer-overflow:fast_float + # Abseil intentional overflows in hashing, RNG and time arithmetic. # Matched at library scope (like boost above): the wraparound is by design # across many absl files (hash mixing, raw_hash_set probing, duration math, diff --git a/src/libxrpl/json/json_reader.cpp b/src/libxrpl/json/json_reader.cpp index f9134e6629..8598f94491 100644 --- a/src/libxrpl/json/json_reader.cpp +++ b/src/libxrpl/json/json_reader.cpp @@ -3,9 +3,11 @@ #include #include +#include // IWYU pragma: keep +#include + #include #include -#include #include #include #include @@ -605,8 +607,14 @@ Reader::decodeNumber(Token& token) bool Reader::decodeDouble(Token& token) { + // Sanity check to avoid buffer overflow exploits. + if (token.end < token.start) + { + return addError("Unable to parse token length", token); + } + double value = 0; - auto const [ptr, ec] = std::from_chars(token.start, token.end, value); + auto const [ptr, ec] = fast_float::from_chars(token.start, token.end, value); // Reject anything from_chars could not turn into a finite double: // - ec != std::errc{}: no valid conversion, or an out-of-range magnitude From 8d7524f03b6298fb0d366d23036688b23042defd Mon Sep 17 00:00:00 2001 From: Gregory Tsipenyuk Date: Wed, 5 Aug 2026 13:54:44 -0400 Subject: [PATCH 44/52] fix: Use consistent endianness serializing MPT STIssue sequence (#7429) Co-authored-by: Ed Hennis Co-authored-by: David Fuelling --- src/libxrpl/protocol/STIssue.cpp | 10 ++++++ src/test/protocol/STIssue_test.cpp | 54 ++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/src/libxrpl/protocol/STIssue.cpp b/src/libxrpl/protocol/STIssue.cpp index 10403d2c50..ba32c1214c 100644 --- a/src/libxrpl/protocol/STIssue.cpp +++ b/src/libxrpl/protocol/STIssue.cpp @@ -11,6 +11,8 @@ #include #include +#include + #include #include #include @@ -45,6 +47,10 @@ STIssue::STIssue(SerialIter& sit, SField const& name) : STBase{name} { MPTID mptID; std::uint32_t sequence = sit.get32(); + // MPTID stores the sequence in canonical big-endian bytes. STIssue + // ledger bytes are the legacy LE-host encoding, so convert the + // native get32() value to LE bytes before copying into the MPTID. + sequence = boost::endian::native_to_little(sequence); static_assert(MPTID::size() == sizeof(sequence) + sizeof(currencyOrAccount)); memcpy(mptID.data(), &sequence, sizeof(sequence)); memcpy( @@ -100,6 +106,10 @@ STIssue::add(Serializer& s) const s.addBitString(noAccount()); std::uint32_t sequence = 0; memcpy(&sequence, issue.getMptID().data(), sizeof(sequence)); + // The MPTID bytes are canonical big-endian. Interpret those bytes + // as the legacy LE-host value so add32() writes the preserved + // STIssue wire bytes on every host endian. + sequence = boost::endian::little_to_native(sequence); s.add32(sequence); }); } diff --git a/src/test/protocol/STIssue_test.cpp b/src/test/protocol/STIssue_test.cpp index b7cc944e6b..41517b38f3 100644 --- a/src/test/protocol/STIssue_test.cpp +++ b/src/test/protocol/STIssue_test.cpp @@ -7,17 +7,22 @@ #include #include +#include #include #include #include #include +#include #include +#include #include #include #include #include #include +#include +#include #include namespace xrpl::test { @@ -273,6 +278,54 @@ public: } } + void + testMPTSerialization() + { + testcase("MPT serialization"); + using namespace jtx; + Account const alice{"alice"}; + + // 0x01020304 pins canonical MPTID bytes 01 02 03 04 and + // preserved STIssue wire bytes 04 03 02 01 on BE and LE. + auto const sequences = std::to_array({0x00000001, 0x01020304, 0xa1b2c3d4}); + + for (auto const vector : sequences) + { + MPTID const mptID = makeMptID(vector, alice); + MPTIssue const issue{mptID}; + STIssue const stIssue(sfAsset, Asset{issue}); + + Serializer actual; + stIssue.add(actual); + + // STIssue preserves the existing little-endian validator ledger bytes. + Serializer expected; + expected.addBitString(alice.id()); + expected.addBitString(noAccount()); + { + std::array const bytes{ + static_cast(vector), + static_cast(vector >> 8), + static_cast(vector >> 16), + static_cast(vector >> 24)}; + expected.addRaw(bytes.data(), bytes.size()); + } + + BEAST_EXPECTS(strHex(actual) == strHex(expected), strHex(actual)); + + // Decoding the preserved wire format must recover the canonical MPTID. + SerialIter iter(expected.slice()); + STIssue const decoded(iter, sfAsset); + BEAST_EXPECT(decoded.holds()); + BEAST_EXPECT(decoded.value().get().getMptID() == mptID); + + // A decoded ledger value must serialize back to the same bytes. + Serializer roundTrip; + decoded.add(roundTrip); + BEAST_EXPECTS(strHex(roundTrip) == strHex(expected), strHex(roundTrip)); + } + } + void run() override { @@ -283,6 +336,7 @@ public: testNoAccountIssuer(); testXrpAccountIssuerRpc(); testXrpAccountIssuer(); + testMPTSerialization(); } }; From 5bf24c40456dfe0fa33a3ecf315fa0454ab26424 Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Thu, 6 Aug 2026 12:04:41 +0100 Subject: [PATCH 45/52] ci: Use a separate benchmark filter (#7919) --- .github/scripts/strategy-matrix/generate.py | 9 +++++++++ .github/scripts/strategy-matrix/linux.json | 3 ++- .../workflows/reusable-build-test-config.yml | 17 +++++++++++++---- .github/workflows/reusable-build-test.yml | 1 + 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/.github/scripts/strategy-matrix/generate.py b/.github/scripts/strategy-matrix/generate.py index c783f32fb7..3797e5881d 100755 --- a/.github/scripts/strategy-matrix/generate.py +++ b/.github/scripts/strategy-matrix/generate.py @@ -33,6 +33,10 @@ def get_cmake_args(build_type: str, extra_args: str) -> str: # Every config must declare 'minimal'. Minimal configs form the reduced matrix # built for pull requests by default; the full matrix adds the rest. Packaging # configs declare it too, but packaging is gated in the workflow, not by it. +# +# Configs may also opt into 'benchmark' to smoke-run the benchmarks. Note that +# the flag applies to every entry a config expands into, so only set it on +# configs that expand to a single combination. @dataclasses.dataclass @@ -43,6 +47,7 @@ class LinuxConfig: build_type: list[str] arch: list[str] minimal: bool + benchmark: bool = False # if true, smoke-run the benchmarks after testing sanitizers: list[str] = dataclasses.field(default_factory=list) suffix: str = "" extra_cmake_args: str = "" @@ -81,6 +86,7 @@ class PlatformConfig: build_type: list[str] minimal: bool build_only: bool = False # if true, skip tests (e.g. macos/Windows Debug) + benchmark: bool = False # if true, smoke-run the benchmarks after testing extra_cmake_args: str = "" def __post_init__(self) -> None: @@ -125,6 +131,7 @@ class MatrixEntry: cmake_args: str cmake_target: str build_only: bool + benchmark: bool build_type: str architecture: Architecture sanitizers: str @@ -193,6 +200,7 @@ def expand_linux_matrix(linux: LinuxFile, minimal: bool) -> list[MatrixEntry]: cmake_args=get_cmake_args(build_type, cfg.extra_cmake_args), cmake_target="all", build_only=False, + benchmark=cfg.benchmark, build_type=build_type, architecture=arch_info, sanitizers=sanitizer, @@ -245,6 +253,7 @@ def expand_platform_matrix(pf: PlatformFile, minimal: bool) -> list[MatrixEntry] cmake_args=get_cmake_args(build_type, cfg.extra_cmake_args), cmake_target="install" if is_windows else "all", build_only=cfg.build_only, + benchmark=cfg.benchmark, build_type=build_type, architecture=Architecture(platform=pf.platform, runner=pf.runner), sanitizers="", diff --git a/.github/scripts/strategy-matrix/linux.json b/.github/scripts/strategy-matrix/linux.json index 9510212344..159c76b6c2 100644 --- a/.github/scripts/strategy-matrix/linux.json +++ b/.github/scripts/strategy-matrix/linux.json @@ -14,7 +14,8 @@ "compiler": ["clang"], "build_type": ["Release"], "arch": ["amd64"], - "minimal": true + "minimal": true, + "benchmark": true }, { diff --git a/.github/workflows/reusable-build-test-config.yml b/.github/workflows/reusable-build-test-config.yml index 3023f70cdf..e21067cc5f 100644 --- a/.github/workflows/reusable-build-test-config.yml +++ b/.github/workflows/reusable-build-test-config.yml @@ -3,6 +3,12 @@ name: Build and test configuration on: workflow_call: inputs: + benchmark: + description: "Whether to smoke-run the benchmarks after testing." + required: false + type: boolean + default: false + build_only: description: 'Whether to only build or to build and test the code ("true", "false").' required: true @@ -328,11 +334,14 @@ jobs: # Smoke-run every benchmark module with a single repetition to confirm the # benchmarks still build and execute. This is a correctness check, not a - # performance measurement, so it is skipped for instrumented builds - # (sanitizers/coverage/voidstar), where it would be slow and meaningless, - # and on Windows, where the `install` target does not build them. + # performance measurement, so there is nothing to gain from repeating it + # across configurations: it is opted into by a single config in the + # strategy matrix (see the 'benchmark' flag in the JSON files), which + # keeps it off instrumented builds (sanitizers/coverage/voidstar), where + # it would be slow and meaningless, off Debug builds, where it is much + # slower, and off Windows, where the `install` target does not build them. - name: Run the benchmarks - if: ${{ !inputs.build_only && runner.os != 'Windows' && env.SANITIZERS_ENABLED == 'false' && env.COVERAGE_ENABLED != 'true' && env.VOIDSTAR_ENABLED != 'true' }} + if: ${{ inputs.benchmark }} working-directory: ${{ env.BUILD_DIR }} run: | rc=0 diff --git a/.github/workflows/reusable-build-test.yml b/.github/workflows/reusable-build-test.yml index 4b64c53521..5368274a16 100644 --- a/.github/workflows/reusable-build-test.yml +++ b/.github/workflows/reusable-build-test.yml @@ -40,6 +40,7 @@ jobs: fail-fast: ${{ github.event_name == 'merge_group' }} matrix: ${{ fromJson(needs.generate-matrix.outputs.matrix) }} with: + benchmark: ${{ matrix.benchmark }} build_only: ${{ matrix.build_only }} build_type: ${{ matrix.build_type }} ccache_enabled: ${{ inputs.ccache_enabled }} From cb425647a45d12d4e038bbb8cdd41accbdfea607 Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Thu, 6 Aug 2026 14:25:28 +0100 Subject: [PATCH 46/52] ci: Generate protocol_autogen only once in CI (#7918) --- .github/workflows/on-pr.yml | 7 ++ .github/workflows/on-trigger.yml | 4 + .../workflows/reusable-build-test-config.yml | 32 +------- .github/workflows/reusable-check-autogen.yml | 76 +++++++++++++++++++ BUILD.md | 12 ++- cmake/XrplProtocolAutogen.cmake | 27 +++---- cmake/codegen/CMakeLists.txt | 21 +++++ include/xrpl/protocol_autogen/README.md | 10 +++ 8 files changed, 146 insertions(+), 43 deletions(-) create mode 100644 .github/workflows/reusable-check-autogen.yml create mode 100644 cmake/codegen/CMakeLists.txt diff --git a/.github/workflows/on-pr.yml b/.github/workflows/on-pr.yml index 1cd97305da..0a4e4b1f49 100644 --- a/.github/workflows/on-pr.yml +++ b/.github/workflows/on-pr.yml @@ -82,6 +82,7 @@ jobs: .github/scripts/strategy-matrix/** .github/workflows/reusable-build-test-config.yml .github/workflows/reusable-build-test.yml + .github/workflows/reusable-check-autogen.yml .github/workflows/reusable-clang-tidy.yml .github/workflows/reusable-package.yml .github/workflows/reusable-strategy-matrix.yml @@ -126,6 +127,11 @@ jobs: outputs: go: ${{ steps.go.outputs.go == 'true' }} + check-autogen: + needs: should-run + if: ${{ needs.should-run.outputs.go == 'true' }} + uses: ./.github/workflows/reusable-check-autogen.yml + check-levelization: needs: should-run if: ${{ needs.should-run.outputs.go == 'true' }} @@ -200,6 +206,7 @@ jobs: passed: if: failure() || cancelled() needs: + - check-autogen - check-levelization - check-rename - clang-tidy diff --git a/.github/workflows/on-trigger.yml b/.github/workflows/on-trigger.yml index b8899cec72..73f918d528 100644 --- a/.github/workflows/on-trigger.yml +++ b/.github/workflows/on-trigger.yml @@ -20,6 +20,7 @@ on: - ".github/scripts/strategy-matrix/**" - ".github/workflows/reusable-build-test-config.yml" - ".github/workflows/reusable-build-test.yml" + - ".github/workflows/reusable-check-autogen.yml" - ".github/workflows/reusable-clang-tidy.yml" - ".github/workflows/reusable-package.yml" - ".github/workflows/reusable-strategy-matrix.yml" @@ -67,6 +68,9 @@ defaults: shell: bash jobs: + check-autogen: + uses: ./.github/workflows/reusable-check-autogen.yml + clang-tidy: uses: ./.github/workflows/reusable-clang-tidy.yml permissions: diff --git a/.github/workflows/reusable-build-test-config.yml b/.github/workflows/reusable-build-test-config.yml index e21067cc5f..548fde8b5e 100644 --- a/.github/workflows/reusable-build-test-config.yml +++ b/.github/workflows/reusable-build-test-config.yml @@ -176,9 +176,9 @@ jobs: .. # Export the sanitizer options before any instrumented binary runs. The - # protocol code-gen and build steps below invoke instrumented dependency - # tools (protoc, grpc), so setting UBSAN_OPTIONS here lets the UBSan - # suppression list silence their diagnostics too, not just at test time. + # build step below invokes instrumented dependency tools (protoc, grpc), + # so setting UBSAN_OPTIONS here lets the UBSan suppression list silence + # their diagnostics too, not just at test time. # GITHUB_WORKSPACE (not the github.workspace context) is used so the path # resolves correctly inside the container job. - name: Set sanitizer options @@ -196,32 +196,6 @@ jobs: echo "UBSAN_OPTIONS=include=${SUPP}/runtime-ubsan-options.txt:suppressions=${SUPP}/ubsan.supp" >>${GITHUB_ENV} echo "LSAN_OPTIONS=include=${SUPP}/runtime-lsan-options.txt:suppressions=${SUPP}/lsan.supp" >>${GITHUB_ENV} - - name: Check protocol autogen files are up-to-date - working-directory: ${{ env.BUILD_DIR }} - env: - MESSAGE: | - - The generated protocol wrapper classes are out of date. - - This typically happens when the macro files or generator scripts - have changed but the generated files were not regenerated. - - To fix this: - 1. Run: cmake --build . --target setup_code_gen - 2. Run: cmake --build . --target code_gen - 3. Commit and push the regenerated files - run: | - set -e - cmake --build . --target setup_code_gen - cmake --build . --target code_gen - DIFF=$(git -C .. status --porcelain -- include/xrpl/protocol_autogen src/tests/libxrpl/protocol_autogen) - if [ -n "${DIFF}" ]; then - echo "::error::Generated protocol files are out of date" - git -C .. diff -- include/xrpl/protocol_autogen src/tests/libxrpl/protocol_autogen - echo "${MESSAGE}" - exit 1 - fi - - name: Build the binary working-directory: ${{ env.BUILD_DIR }} env: diff --git a/.github/workflows/reusable-check-autogen.yml b/.github/workflows/reusable-check-autogen.yml new file mode 100644 index 0000000000..bb77ea85a9 --- /dev/null +++ b/.github/workflows/reusable-check-autogen.yml @@ -0,0 +1,76 @@ +# This workflow checks that the generated protocol wrapper classes are +# up-to-date with the macro files and generator scripts they are produced from, +# see more info in include/xrpl/protocol_autogen/README.md. +name: Check autogen + +# This workflow can only be triggered by other workflows. +on: workflow_call + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-autogen + cancel-in-progress: true + +defaults: + run: + shell: bash + +env: + BUILD_DIR: build/codegen + +jobs: + autogen: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.13" + + # Code generation is pure Python, so the standalone project below offers + # the same targets as the main build without needing its dependencies or + # a compiler, which keeps this job down to a few seconds. + - name: Configure CMake + run: cmake -S cmake/codegen -B "${BUILD_DIR}" + + - name: Install code generation dependencies + run: cmake --build "${BUILD_DIR}" --target setup_code_gen + + - name: Generate code + run: cmake --build "${BUILD_DIR}" --target code_gen + + - name: Check for differences + env: + MESSAGE: | + + The generated protocol wrapper classes are out of date. + + This typically happens when the macro files or generator scripts + have changed but the generated files were not regenerated. + + Run the following from the repository root, then commit and push + the regenerated files. This needs neither the dependencies nor a + compiler. See include/xrpl/protocol_autogen/README.md for more info. + + cmake -S cmake/codegen -B build/codegen + cmake --build build/codegen --target setup_code_gen + cmake --build build/codegen --target code_gen + + In an already configured build directory, the 'setup_code_gen' and + 'code_gen' targets do the same thing. + run: | + # Record untracked files in the index without staging their contents, + # so that classes generated for a newly added transaction or ledger + # entry type show up in the diff below rather than silently as an + # empty one. + git add --intent-to-add . + DIFF=$(git status --porcelain) + if [ -n "${DIFF}" ]; then + # Print the differences to give the contributor a hint about what to + # expect when running code generation on their own machine. + git diff + echo "${MESSAGE}" + exit 1 + fi diff --git a/BUILD.md b/BUILD.md index edc52fe3b7..238c10e17c 100644 --- a/BUILD.md +++ b/BUILD.md @@ -247,7 +247,17 @@ cmake --build . --target setup_code_gen # create venv and install dependencies cmake --build . --target code_gen # regenerate code ``` -The regenerated files should be committed alongside your changes. +The same targets are also available as a standalone project, which does not +need the dependencies to be configured first: + +``` +cmake -S cmake/codegen -B build/codegen +cmake --build build/codegen --target setup_code_gen +cmake --build build/codegen --target code_gen +``` + +The regenerated files should be committed alongside your changes. CI verifies +that they are up-to-date. ## Coverage report diff --git a/cmake/XrplProtocolAutogen.cmake b/cmake/XrplProtocolAutogen.cmake index dd9ef6a9a4..33af560113 100644 --- a/cmake/XrplProtocolAutogen.cmake +++ b/cmake/XrplProtocolAutogen.cmake @@ -2,21 +2,22 @@ Protocol Autogen - Code generation for protocol wrapper classes #]===================================================================] +# The repository root, derived from the location of this file rather than from +# the including project, so that the targets below can also be offered on their +# own by cmake/codegen/CMakeLists.txt. +get_filename_component(XRPL_ROOT "${CMAKE_CURRENT_LIST_DIR}/.." ABSOLUTE) + set(CODEGEN_VENV_DIR - "${CMAKE_CURRENT_SOURCE_DIR}/.venv" + "${XRPL_ROOT}/.venv" CACHE PATH "Path to a Python virtual environment for code generation. A venv will be created here by setup_code_gen and used to run generation scripts." ) # Directory paths -set(MACRO_DIR "${CMAKE_CURRENT_SOURCE_DIR}/include/xrpl/protocol/detail") -set(AUTOGEN_HEADER_DIR - "${CMAKE_CURRENT_SOURCE_DIR}/include/xrpl/protocol_autogen" -) -set(AUTOGEN_TEST_DIR - "${CMAKE_CURRENT_SOURCE_DIR}/src/tests/libxrpl/protocol_autogen" -) -set(SCRIPTS_DIR "${CMAKE_CURRENT_SOURCE_DIR}/cmake/scripts/codegen") +set(MACRO_DIR "${XRPL_ROOT}/include/xrpl/protocol/detail") +set(AUTOGEN_HEADER_DIR "${XRPL_ROOT}/include/xrpl/protocol_autogen") +set(AUTOGEN_TEST_DIR "${XRPL_ROOT}/src/tests/libxrpl/protocol_autogen") +set(SCRIPTS_DIR "${XRPL_ROOT}/cmake/scripts/codegen") # Input macro files set(TRANSACTIONS_MACRO "${MACRO_DIR}/transactions.macro") @@ -114,14 +115,14 @@ if(CODEGEN_VENV_DIR) setup_code_gen COMMAND ${Python3_EXECUTABLE} -m venv "${CODEGEN_VENV_DIR}" COMMAND ${CODEGEN_PYTHON} -m pip install -r "${REQUIREMENTS_FILE}" - WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" + WORKING_DIRECTORY "${XRPL_ROOT}" COMMENT "Creating venv and installing code generation dependencies..." ) else() add_custom_target( setup_code_gen COMMAND ${Python3_EXECUTABLE} -m pip install -r "${REQUIREMENTS_FILE}" - WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" + WORKING_DIRECTORY "${XRPL_ROOT}" COMMENT "Installing code generation dependencies..." ) endif() @@ -139,8 +140,8 @@ add_custom_target( -DSFIELDS_MACRO=${SFIELDS_MACRO} -DAUTOGEN_HEADER_DIR=${AUTOGEN_HEADER_DIR} -DAUTOGEN_TEST_DIR=${AUTOGEN_TEST_DIR} -P - "${CMAKE_CURRENT_SOURCE_DIR}/cmake/XrplProtocolAutogenRun.cmake" - WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" + "${CMAKE_CURRENT_LIST_DIR}/XrplProtocolAutogenRun.cmake" + WORKING_DIRECTORY "${XRPL_ROOT}" COMMENT "Running protocol code generation..." SOURCES ${ALL_INPUT_FILES} ) diff --git a/cmake/codegen/CMakeLists.txt b/cmake/codegen/CMakeLists.txt new file mode 100644 index 0000000000..f697d67dd8 --- /dev/null +++ b/cmake/codegen/CMakeLists.txt @@ -0,0 +1,21 @@ +#[===================================================================[ + Protocol Autogen - Standalone project + + Exposes the 'setup_code_gen' and 'code_gen' targets on their own, without + configuring the rest of xrpl. Code generation is pure Python, so this needs + neither the dependencies nor a compiler, which makes it usable in CI and by + contributors who only want to regenerate the protocol wrapper classes: + + cmake -S cmake/codegen -B build/codegen + cmake --build build/codegen --target setup_code_gen + cmake --build build/codegen --target code_gen + + The targets are identical to the ones offered by the top-level build, since + both come from cmake/XrplProtocolAutogen.cmake. +#]===================================================================] + +cmake_minimum_required(VERSION 3.16) + +project(xrpl_codegen LANGUAGES NONE) + +include("${CMAKE_CURRENT_LIST_DIR}/../XrplProtocolAutogen.cmake") diff --git a/include/xrpl/protocol_autogen/README.md b/include/xrpl/protocol_autogen/README.md index 608ffed085..ed649a05fc 100644 --- a/include/xrpl/protocol_autogen/README.md +++ b/include/xrpl/protocol_autogen/README.md @@ -23,6 +23,16 @@ By default, `CODEGEN_VENV_DIR` points to `.venv` in the project root. The `setup_code_gen` target creates a venv there and installs the required packages. The `code_gen` target then uses the venv's Python interpreter to run generation. +Generation is pure Python, so the same targets are also available as a +standalone project that needs neither the dependencies nor a compiler. This is +what CI uses, and it is handy if you only want to regenerate these files: + +```bash +cmake -S cmake/codegen -B build/codegen +cmake --build build/codegen --target setup_code_gen +cmake --build build/codegen --target code_gen +``` + ### Python Dependencies The code generation requires the following Python packages (installed by `setup_code_gen`): From 00a178fb92ca49521b937ae1a99d863765ea8a90 Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Thu, 6 Aug 2026 17:34:39 +0100 Subject: [PATCH 47/52] chore: Bump version to 3.3.0 --- src/libxrpl/protocol/BuildInfo.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libxrpl/protocol/BuildInfo.cpp b/src/libxrpl/protocol/BuildInfo.cpp index b3f9128f2e..267df204ba 100644 --- a/src/libxrpl/protocol/BuildInfo.cpp +++ b/src/libxrpl/protocol/BuildInfo.cpp @@ -23,7 +23,7 @@ namespace { //------------------------------------------------------------------------------ // clang-format off // NOLINTNEXTLINE(readability-identifier-naming) -char const* const versionString = "3.3.0-rc7" +char const* const versionString = "3.3.0" // clang-format on ; From a0e78b286fbbfb93fceb0da697a2c0cd64ed76d0 Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Fri, 7 Aug 2026 17:56:46 +0100 Subject: [PATCH 48/52] chore: Bump version to 3.4.0-b0 (#7976) --- src/libxrpl/protocol/BuildInfo.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libxrpl/protocol/BuildInfo.cpp b/src/libxrpl/protocol/BuildInfo.cpp index 6647437268..ff4e5aa0ee 100644 --- a/src/libxrpl/protocol/BuildInfo.cpp +++ b/src/libxrpl/protocol/BuildInfo.cpp @@ -23,7 +23,7 @@ namespace { //------------------------------------------------------------------------------ // clang-format off // NOLINTNEXTLINE(readability-identifier-naming) -char const* const versionString = "3.3.0" +char const* const versionString = "3.4.0-b0" // clang-format on ; From abf5511d07bd7280a0ac80c80dd5afce0d50c1c4 Mon Sep 17 00:00:00 2001 From: Timur Yalymov <36795566+tyalymov@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:41:23 +0100 Subject: [PATCH 49/52] fix: Correct sign-check wording in lending protocol messages (#7913) --- src/libxrpl/tx/invariants/VaultInvariant.cpp | 6 +++--- src/libxrpl/tx/transactors/lending/LoanPay.cpp | 2 +- src/test/app/Invariants_test.cpp | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/libxrpl/tx/invariants/VaultInvariant.cpp b/src/libxrpl/tx/invariants/VaultInvariant.cpp index eca50eb809..c577fdf356 100644 --- a/src/libxrpl/tx/invariants/VaultInvariant.cpp +++ b/src/libxrpl/tx/invariants/VaultInvariant.cpp @@ -465,7 +465,7 @@ ValidVault::finalize( if (afterVault.assetsAvailable < kZero) { - JLOG(j.fatal()) << "Invariant failed: assets available must be positive"; + JLOG(j.fatal()) << "Invariant failed: assets available must not be negative"; result = false; } @@ -491,13 +491,13 @@ ValidVault::finalize( if (afterVault.assetsTotal < kZero) { - JLOG(j.fatal()) << "Invariant failed: assets outstanding must be positive"; + JLOG(j.fatal()) << "Invariant failed: assets outstanding must not be negative"; result = false; } if (afterVault.assetsMaximum < kZero) { - JLOG(j.fatal()) << "Invariant failed: assets maximum must be positive"; + JLOG(j.fatal()) << "Invariant failed: assets maximum must not be negative"; result = false; } diff --git a/src/libxrpl/tx/transactors/lending/LoanPay.cpp b/src/libxrpl/tx/transactors/lending/LoanPay.cpp index 74e8efeda2..4619540295 100644 --- a/src/libxrpl/tx/transactors/lending/LoanPay.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanPay.cpp @@ -813,7 +813,7 @@ LoanPay::doApply() XRPL_ASSERT_PARTS( vaultBalanceAfter >= beast::kZero && brokerBalanceAfter >= beast::kZero, "xrpl::LoanPay::doApply", - "positive vault and broker balances"); + "non-negative vault and broker balances"); XRPL_ASSERT_PARTS( vaultBalanceAfter >= vaultBalanceBefore, "xrpl::LoanPay::doApply", diff --git a/src/test/app/Invariants_test.cpp b/src/test/app/Invariants_test.cpp index 9bc9524f80..6234d81762 100644 --- a/src/test/app/Invariants_test.cpp +++ b/src/test/app/Invariants_test.cpp @@ -3260,9 +3260,9 @@ class Invariants_test : public beast::unit_test::Suite "set must not change assets available", "set must not change shares outstanding", "set must not change vault balance", - "assets available must be positive", + "assets available must not be negative", "assets available must not be greater than assets outstanding", - "assets outstanding must be positive"}, + "assets outstanding must not be negative"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { auto const keylet = keylet::vault(a1.id(), ac.view().seq()); auto sleVault = ac.view().peek(keylet); @@ -3424,7 +3424,7 @@ class Invariants_test : public beast::unit_test::Suite TxAccount::A2); doInvariantCheck( - {"assets maximum must be positive"}, + {"assets maximum must not be negative"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { auto const keylet = keylet::vault(a1.id(), ac.view().seq()); return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) { @@ -3612,7 +3612,7 @@ class Invariants_test : public beast::unit_test::Suite doInvariantCheck( { - "assets maximum must be positive", + "assets maximum must not be negative", "create operation must not have updated a vault", }, [&](Account const& a1, Account const& a2, ApplyContext& ac) { From 798e889ec467661801dc9451a74adcbd3a0de92f Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Fri, 7 Aug 2026 13:24:45 -0700 Subject: [PATCH 50/52] fix: Deduplicate oracle entries in get_aggregate_price RPC (#6586) Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Mayukha Vadari Co-authored-by: Bart --- API-CHANGELOG.md | 1 + src/test/rpc/GetAggregatePrice_test.cpp | 42 +++++++++++++++++++ .../handlers/orderbook/GetAggregatePrice.cpp | 8 ++++ 3 files changed, 51 insertions(+) diff --git a/API-CHANGELOG.md b/API-CHANGELOG.md index a04f265328..79fb8ff522 100644 --- a/API-CHANGELOG.md +++ b/API-CHANGELOG.md @@ -42,6 +42,7 @@ This section contains changes targeting a future version. ### Bugfixes +- `get_aggregate_price`: Duplicate entries in the `oracles` request array are now ignored. [#6586](https://github.com/XRPLF/rippled/pull/6586) - Peer Crawler: The `port` field in `overlay.active[]` now consistently returns an integer instead of a string for outbound peers. [#6318](https://github.com/XRPLF/rippled/pull/6318) - `ping`: The `ip` field is no longer returned as an empty string for proxied connections without a forwarded-for header. It is now omitted, consistent with the behavior for identified connections. [#6730](https://github.com/XRPLF/rippled/pull/6730) - gRPC `GetLedgerDiff`: Fixed error message that incorrectly said "base ledger not validated" when the desired ledger was not validated. [#6730](https://github.com/XRPLF/rippled/pull/6730) diff --git a/src/test/rpc/GetAggregatePrice_test.cpp b/src/test/rpc/GetAggregatePrice_test.cpp index 3e0bfa1fd3..58c2e8b996 100644 --- a/src/test/rpc/GetAggregatePrice_test.cpp +++ b/src/test/rpc/GetAggregatePrice_test.cpp @@ -320,6 +320,48 @@ public: BEAST_EXPECT(ret[jss::median] == "74"); BEAST_EXPECT(ret[jss::time] == 946695000); } + + // Duplicate oracle entries should be deduplicated. + // Two separate oracles with different prices give size=2. + // Listing the first oracle twice in the query must not + // inflate the size to 3. + { + Env env(*this); + auto const baseFee = static_cast(env.current()->fees().base.drops()); + + Account const owner1{"owner1"}; + Account const owner2{"owner2"}; + env.fund(XRP(1'000), owner1); + env.fund(XRP(1'000), owner2); + Oracle const oracle1( + env, {.owner = owner1, .series = {{"XRP", "USD", 740, 1}}, .fee = baseFee}); + Oracle const oracle2( + env, {.owner = owner2, .series = {{"XRP", "USD", 840, 1}}, .fee = baseFee}); + + // Query with both oracles listed once + OraclesData const single = { + {owner1, oracle1.documentID()}, {owner2, oracle2.documentID()}}; + auto const retSingle = Oracle::aggregatePrice(env, "XRP", "USD", single); + + // Query with oracle1 listed twice + OraclesData const duplicated = { + {owner1, oracle1.documentID()}, + {owner1, oracle1.documentID()}, + {owner2, oracle2.documentID()}}; + auto const retDup = Oracle::aggregatePrice(env, "XRP", "USD", duplicated); + + // Results should be identical - duplicates must not be + // double-counted + BEAST_EXPECT( + retSingle[jss::entire_set][jss::size] == retDup[jss::entire_set][jss::size]); + BEAST_EXPECT(retDup[jss::entire_set][jss::size].asUInt() == 2); + BEAST_EXPECT( + retSingle[jss::entire_set][jss::mean] == retDup[jss::entire_set][jss::mean]); + BEAST_EXPECT( + retSingle[jss::entire_set][jss::standard_deviation] == + retDup[jss::entire_set][jss::standard_deviation]); + BEAST_EXPECT(retSingle[jss::median] == retDup[jss::median]); + } } void diff --git a/src/xrpld/rpc/handlers/orderbook/GetAggregatePrice.cpp b/src/xrpld/rpc/handlers/orderbook/GetAggregatePrice.cpp index f493000d0b..33eaa9ce1e 100644 --- a/src/xrpld/rpc/handlers/orderbook/GetAggregatePrice.cpp +++ b/src/xrpld/rpc/handlers/orderbook/GetAggregatePrice.cpp @@ -33,7 +33,9 @@ #include #include #include +#include #include +#include #include namespace xrpl { @@ -251,6 +253,8 @@ doGetAggregatePrice(rpc::JsonContext& context) // Collect the dataset into bimap keyed by lastUpdateTime and // STAmount (Number is int64 and price is uint64) Prices prices; + // Track seen {account, documentID} pairs to skip duplicates + std::set> seen; for (auto const& oracle : params[jss::oracles]) { if (!oracle.isMember(jss::oracle_document_id) || !oracle.isMember(jss::account)) @@ -268,6 +272,10 @@ doGetAggregatePrice(rpc::JsonContext& context) return result; } + // Skip duplicate oracle entries + if (!seen.emplace(*account, *documentID).second) + continue; + auto const sle = ledger->read(keylet::oracle(*account, *documentID)); iteratePriceData(context, sle, [&](STObject const& node) { auto const& series = node.getFieldArray(sfPriceDataSeries); From 0fb92c319421100144fb564a67c3b63e880aea74 Mon Sep 17 00:00:00 2001 From: Mayukha Vadari Date: Fri, 7 Aug 2026 17:29:11 -0400 Subject: [PATCH 51/52] refactor: Use SeqProxy instead of uint32 for all sequence-based keylets (#7890) Co-authored-by: xrplf-ai-reviewer[bot] <266832837+xrplf-ai-reviewer[bot]@users.noreply.github.com> --- include/xrpl/protocol/Indexes.h | 49 +- include/xrpl/protocol/STTx.h | 6 - include/xrpl/protocol/SeqProxy.h | 19 +- src/libxrpl/ledger/helpers/NFTokenHelpers.cpp | 2 +- src/libxrpl/protocol/Indexes.cpp | 88 ++-- src/libxrpl/protocol/STTx.cpp | 18 +- src/libxrpl/tx/Transactor.cpp | 11 +- src/libxrpl/tx/applySteps.cpp | 2 +- .../tx/transactors/check/CheckCreate.cpp | 5 +- .../tx/transactors/dex/OfferCancel.cpp | 4 +- .../tx/transactors/dex/OfferCreate.cpp | 8 +- .../tx/transactors/escrow/EscrowCancel.cpp | 7 +- .../tx/transactors/escrow/EscrowCreate.cpp | 4 +- .../tx/transactors/escrow/EscrowFinish.cpp | 7 +- .../tx/transactors/lending/LoanBrokerSet.cpp | 4 +- .../tx/transactors/lending/LoanSet.cpp | 4 +- .../payment_channel/PaymentChannelCreate.cpp | 4 +- .../PermissionedDomainSet.cpp | 6 +- .../tx/transactors/system/TicketCreate.cpp | 2 +- .../token/MPTokenIssuanceCreate.cpp | 2 +- .../tx/transactors/vault/VaultCreate.cpp | 6 +- src/test/app/AccountDelete_test.cpp | 37 +- src/test/app/Batch_test.cpp | 10 +- src/test/app/Check_test.cpp | 3 +- src/test/app/EscrowToken_test.cpp | 53 +- src/test/app/Escrow_test.cpp | 29 +- src/test/app/FixNFTokenPageLinks_test.cpp | 7 +- src/test/app/FlowMPT_test.cpp | 3 +- src/test/app/Flow_test.cpp | 3 +- src/test/app/Freeze_test.cpp | 19 +- src/test/app/Invariants_test.cpp | 187 +++---- src/test/app/LPTokenTransfer_test.cpp | 11 +- src/test/app/MPToken_test.cpp | 42 +- src/test/app/NFTokenAuth_test.cpp | 20 +- src/test/app/NFTokenBurn_test.cpp | 19 +- src/test/app/NFTokenDir_test.cpp | 25 +- src/test/app/NFToken_test.cpp | 472 ++++++++++++------ src/test/app/Offer_test.cpp | 9 +- src/test/app/PayChan_test.cpp | 4 +- src/test/app/PermissionedDEX_test.cpp | 33 +- src/test/app/PermissionedDomains_test.cpp | 8 +- src/test/app/Sponsor_test.cpp | 72 +-- src/test/app/TxQ_test.cpp | 4 +- src/test/app/Vault_test.cpp | 55 +- src/test/app/lending/LoanBroker_test.cpp | 64 ++- src/test/app/lending/LoanCashBasis_test.cpp | 14 +- .../app/lending/LoanCoverFreezeAuth_test.cpp | 16 +- src/test/app/lending/LoanInvariants_test.cpp | 7 +- src/test/app/lending/LoanLifecycle_test.cpp | 9 +- src/test/app/lending/LoanMisc_test.cpp | 3 +- src/test/app/lending/LoanPay_test.cpp | 12 +- src/test/app/lending/LoanRounding_test.cpp | 16 +- src/test/app/lending/LoanSecurity_test.cpp | 8 +- src/test/app/lending/LoanTestBase.h | 14 +- src/test/app/lending/LoanValidation_test.cpp | 11 +- src/test/jtx/TestHelpers.h | 5 +- src/test/jtx/impl/TestHelpers.cpp | 4 +- src/test/jtx/impl/batch.cpp | 4 +- src/test/jtx/impl/escrow.cpp | 3 +- src/test/jtx/impl/vault.cpp | 4 +- src/test/rpc/AccountObjects_test.cpp | 3 +- src/test/rpc/AccountTx_test.cpp | 11 +- src/test/rpc/LedgerEntry_test.cpp | 31 +- src/test/rpc/Subscribe_test.cpp | 24 +- src/test/rpc/Transaction_test.cpp | 2 +- src/tests/libxrpl/tx/AccountSet.cpp | 15 +- src/xrpld/app/ledger/detail/LocalTxs.cpp | 3 +- src/xrpld/app/misc/NetworkOPs.cpp | 2 +- src/xrpld/app/misc/detail/TxQ.cpp | 8 +- src/xrpld/rpc/handlers/VaultInfo.cpp | 4 +- .../rpc/handlers/account/AccountInfo.cpp | 3 +- src/xrpld/rpc/handlers/ledger/LedgerEntry.cpp | 22 +- 72 files changed, 1026 insertions(+), 679 deletions(-) diff --git a/include/xrpl/protocol/Indexes.h b/include/xrpl/protocol/Indexes.h index 07493da0bd..0836cffaf7 100644 --- a/include/xrpl/protocol/Indexes.h +++ b/include/xrpl/protocol/Indexes.h @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -21,8 +22,6 @@ #include namespace xrpl { - -class SeqProxy; /** * Keylet computation functions. * @@ -123,7 +122,7 @@ trustLine(AccountID const& id, Issue const& issue) noexcept */ /** @{ */ Keylet -offer(AccountID const& id, std::uint32_t seq) noexcept; +offer(AccountID const& id, SeqProxy const& seq) noexcept; inline Keylet offer(uint256 const& key) noexcept @@ -136,7 +135,7 @@ offer(uint256 const& key) noexcept * The initial directory page for a specific quality */ Keylet -quality(Keylet const& k, std::uint64_t q) noexcept; +quality(Keylet const& k, std::uint64_t const q) noexcept; /** * The directory for the next lower quality @@ -149,10 +148,7 @@ next(Keylet const& k); */ /** @{ */ Keylet -ticket(AccountID const& id, std::uint32_t ticketSeq); - -Keylet -ticket(AccountID const& id, SeqProxy ticketSeq); +ticket(AccountID const& id, SeqProxy const& ticketSeq); inline Keylet ticket(uint256 const& key) @@ -178,7 +174,7 @@ sponsorship(AccountID const& sponsor, AccountID const& sponsee) noexcept; */ /** @{ */ Keylet -check(AccountID const& id, std::uint32_t seq) noexcept; +check(AccountID const& id, SeqProxy const& seq) noexcept; inline Keylet check(uint256 const& key) noexcept @@ -225,10 +221,10 @@ ownerDir(AccountID const& id) noexcept; */ /** @{ */ Keylet -page(uint256 const& root, std::uint64_t index = 0) noexcept; +page(uint256 const& root, std::uint64_t const index = 0) noexcept; inline Keylet -page(Keylet const& root, std::uint64_t index = 0) noexcept +page(Keylet const& root, std::uint64_t const index = 0) noexcept { XRPL_ASSERT(root.type == ltDIR_NODE, "xrpl::keylet::page : valid root type"); return page(root.key, index); @@ -239,13 +235,13 @@ page(Keylet const& root, std::uint64_t index = 0) noexcept * An escrow entry */ Keylet -escrow(AccountID const& src, std::uint32_t seq) noexcept; +escrow(AccountID const& src, SeqProxy const& seq) noexcept; /** * A PaymentChannel */ Keylet -payChannel(AccountID const& src, AccountID const& dst, std::uint32_t seq) noexcept; +payChannel(AccountID const& src, AccountID const& dst, SeqProxy const& seq) noexcept; /** * NFT page keylets @@ -276,7 +272,7 @@ nftokenPage(Keylet const& k, uint256 const& token); * An offer from an account to buy or sell an NFT */ Keylet -nftokenOffer(AccountID const& owner, std::uint32_t seq); +nftokenOffer(AccountID const& owner, SeqProxy const& seq); inline Keylet nftokenOffer(uint256 const& offer) @@ -316,17 +312,17 @@ bridge(STXChainBridge const& bridge, STXChainBridge::ChainType chainType); // `seq` is stored as `sfXChainClaimID` in the object Keylet -xChainClaimID(STXChainBridge const& bridge, std::uint64_t seq); +xChainClaimID(STXChainBridge const& bridge, std::uint64_t const seq); // `seq` is stored as `sfXChainAccountCreateCount` in the object Keylet -xChainCreateAccountClaimID(STXChainBridge const& bridge, std::uint64_t seq); +xChainCreateAccountClaimID(STXChainBridge const& bridge, std::uint64_t const seq); Keylet did(AccountID const& account) noexcept; Keylet -oracle(AccountID const& account, std::uint32_t const& documentID) noexcept; +oracle(AccountID const& account, std::uint32_t const documentID) noexcept; Keylet credential(AccountID const& subject, AccountID const& issuer, Slice const& credType) noexcept; @@ -337,9 +333,6 @@ credential(uint256 const& key) noexcept return {ltCREDENTIAL, key}; } -Keylet -mptokenIssuance(std::uint32_t seq, AccountID const& issuer) noexcept; - Keylet mptokenIssuance(MPTID const& issuanceID) noexcept; @@ -362,7 +355,7 @@ Keylet mptoken(uint256 const& issuanceKey, AccountID const& holder) noexcept; Keylet -vault(AccountID const& owner, std::uint32_t seq) noexcept; +vault(AccountID const& owner, SeqProxy const& seq) noexcept; inline Keylet vault(uint256 const& vaultKey) @@ -371,7 +364,7 @@ vault(uint256 const& vaultKey) } Keylet -loanBroker(AccountID const& owner, std::uint32_t seq) noexcept; +loanBroker(AccountID const& owner, SeqProxy const& seq) noexcept; inline Keylet loanBroker(uint256 const& key) @@ -380,7 +373,7 @@ loanBroker(uint256 const& key) } Keylet -loan(uint256 const& loanBrokerID, std::uint32_t loanSeq) noexcept; +loan(uint256 const& loanBrokerID, SeqProxy const& loanSeq) noexcept; inline Keylet loan(uint256 const& key) @@ -389,7 +382,7 @@ loan(uint256 const& key) } Keylet -permissionedDomain(AccountID const& account, std::uint32_t seq) noexcept; +permissionedDomain(AccountID const& account, SeqProxy const& seq) noexcept; Keylet permissionedDomain(uint256 const& domainID) noexcept; @@ -407,12 +400,6 @@ getQualityNext(uint256 const& uBase); std::uint64_t getQuality(uint256 const& uBase); -uint256 -getTicketIndex(AccountID const& account, std::uint32_t uSequence); - -uint256 -getTicketIndex(AccountID const& account, SeqProxy ticketSeq); - template // NOLINTNEXTLINE(cppcoreguidelines-pro-type-member-init) struct KeyletDesc @@ -426,6 +413,6 @@ struct KeyletDesc extern std::array, 6> const kDirectAccountKeylets; MPTID -makeMptID(std::uint32_t sequence, AccountID const& account); +makeMptID(std::uint32_t const sequence, AccountID const& account); } // namespace xrpl diff --git a/include/xrpl/protocol/STTx.h b/include/xrpl/protocol/STTx.h index d329d42eee..e213d4e0b7 100644 --- a/include/xrpl/protocol/STTx.h +++ b/include/xrpl/protocol/STTx.h @@ -93,12 +93,6 @@ public: [[nodiscard]] SeqProxy getSeqProxy() const; - /** - * Returns the first non-zero value of (Sequence, TicketSequence). - */ - [[nodiscard]] std::uint32_t - getSeqValue() const; - [[nodiscard]] boost::container::flat_set getMentionedAccounts() const; diff --git a/include/xrpl/protocol/SeqProxy.h b/include/xrpl/protocol/SeqProxy.h index e6a97be0e7..fa72914591 100644 --- a/include/xrpl/protocol/SeqProxy.h +++ b/include/xrpl/protocol/SeqProxy.h @@ -53,14 +53,29 @@ public: operator=(SeqProxy const& other) = default; /** - * Factory function to return a sequence-based SeqProxy + * Factory function to return a sequence-based SeqProxy. + * Outside of tests, this function should only be used for "secondary" transaction sequences, + * e.g. `sfOfferSequence`, or sequence fields in an existing ledger object. DO NOT use this for + * the "primary" sequence of a transaction, `sfSequence`. */ static constexpr SeqProxy - sequence(std::uint32_t v) + rawSequence(std::uint32_t v) { return SeqProxy{Type::Seq, v}; } + /** + * Factory function to return a ticket-based SeqProxy. + * Outside of tests, this function should only be used for "secondary" transaction sequences, + * e.g. `sfOfferSequence`, or sequence fields in an existing ledger object. DO NOT use this for + * the "primary" ticket sequence of a transaction, `sfTicketSequence`. + */ + static constexpr SeqProxy + rawTicket(std::uint32_t v) + { + return SeqProxy{Type::Ticket, v}; + } + [[nodiscard]] constexpr std::uint32_t value() const { diff --git a/src/libxrpl/ledger/helpers/NFTokenHelpers.cpp b/src/libxrpl/ledger/helpers/NFTokenHelpers.cpp index 589e49d335..f3e4597558 100644 --- a/src/libxrpl/ledger/helpers/NFTokenHelpers.cpp +++ b/src/libxrpl/ledger/helpers/NFTokenHelpers.cpp @@ -925,7 +925,7 @@ tokenOfferCreateApply( priorBalance < accountReserve(view, acct, j, {.ownerCountDelta = 1})) return tecINSUFFICIENT_RESERVE; - auto const offerID = keylet::nftokenOffer(acctID, seqProxy.value()); + auto const offerID = keylet::nftokenOffer(acctID, seqProxy); // Create the offer: { diff --git a/src/libxrpl/protocol/Indexes.cpp b/src/libxrpl/protocol/Indexes.cpp index 95416d0f2a..66fdfd453b 100644 --- a/src/libxrpl/protocol/Indexes.cpp +++ b/src/libxrpl/protocol/Indexes.cpp @@ -180,26 +180,13 @@ getQuality(uint256 const& uBase) return boost::endian::load_big_u64(uBase.end() - 8); } -uint256 -getTicketIndex(AccountID const& account, std::uint32_t ticketSeq) -{ - return indexHash(LedgerNameSpace::Ticket, account, ticketSeq); -} - -uint256 -getTicketIndex(AccountID const& account, SeqProxy ticketSeq) -{ - XRPL_ASSERT(ticketSeq.isTicket(), "xrpl::getTicketIndex : valid input"); - return getTicketIndex(account, ticketSeq.value()); -} - MPTID -makeMptID(std::uint32_t sequence, AccountID const& account) +makeMptID(std::uint32_t const sequence, AccountID const& account) { MPTID u; - sequence = boost::endian::native_to_big(sequence); - memcpy(u.data(), &sequence, sizeof(sequence)); - memcpy(u.data() + sizeof(sequence), account.data(), sizeof(account)); + auto const bigEndianSequence = boost::endian::native_to_big(sequence); + memcpy(u.data(), &bigEndianSequence, sizeof(bigEndianSequence)); + memcpy(u.data() + sizeof(bigEndianSequence), account.data(), sizeof(account)); return u; } @@ -286,13 +273,13 @@ trustLine(AccountID const& id0, AccountID const& id1, Currency const& currency) } Keylet -offer(AccountID const& id, std::uint32_t seq) noexcept +offer(AccountID const& id, SeqProxy const& seq) noexcept { - return {ltOFFER, indexHash(LedgerNameSpace::Offer, id, seq)}; + return {ltOFFER, indexHash(LedgerNameSpace::Offer, id, seq.value())}; } Keylet -quality(Keylet const& k, std::uint64_t q) noexcept +quality(Keylet const& k, std::uint64_t const q) noexcept { XRPL_ASSERT(k.type == ltDIR_NODE, "xrpl::keylet::quality : valid input type"); @@ -320,22 +307,17 @@ next(Keylet const& k) } Keylet -ticket(AccountID const& id, std::uint32_t ticketSeq) +ticket(AccountID const& id, SeqProxy const& seq) { - return {ltTICKET, getTicketIndex(id, ticketSeq)}; -} - -Keylet -ticket(AccountID const& id, SeqProxy ticketSeq) -{ - return {ltTICKET, getTicketIndex(id, ticketSeq)}; + XRPL_ASSERT(seq.isTicket(), "xrpl::keylet::ticket : valid input"); + return {ltTICKET, indexHash(LedgerNameSpace::Ticket, id, seq.value())}; } // This function is presently static, since it's never accessed from anywhere // else. If we ever support multiple pages of signer lists, this would be the // keylet used to locate them. static Keylet -signerList(AccountID const& account, std::uint32_t page) noexcept +signerList(AccountID const& account, std::uint32_t const page) noexcept { return {ltSIGNER_LIST, indexHash(LedgerNameSpace::SignerList, account, page)}; } @@ -353,9 +335,9 @@ sponsorship(AccountID const& sponsor, AccountID const& sponsee) noexcept } Keylet -check(AccountID const& id, std::uint32_t seq) noexcept +check(AccountID const& id, SeqProxy const& seq) noexcept { - return {ltCHECK, indexHash(LedgerNameSpace::Check, id, seq)}; + return {ltCHECK, indexHash(LedgerNameSpace::Check, id, seq.value())}; } Keylet @@ -394,7 +376,7 @@ ownerDir(AccountID const& id) noexcept } Keylet -page(uint256 const& key, std::uint64_t index) noexcept +page(uint256 const& key, std::uint64_t const index) noexcept { if (index == 0) return {ltDIR_NODE, key}; @@ -403,15 +385,15 @@ page(uint256 const& key, std::uint64_t index) noexcept } Keylet -escrow(AccountID const& src, std::uint32_t seq) noexcept +escrow(AccountID const& src, SeqProxy const& seq) noexcept { - return {ltESCROW, indexHash(LedgerNameSpace::Escrow, src, seq)}; + return {ltESCROW, indexHash(LedgerNameSpace::Escrow, src, seq.value())}; } Keylet -payChannel(AccountID const& src, AccountID const& dst, std::uint32_t seq) noexcept +payChannel(AccountID const& src, AccountID const& dst, SeqProxy const& seq) noexcept { - return {ltPAYCHAN, indexHash(LedgerNameSpace::XRPPaymentChannel, src, dst, seq)}; + return {ltPAYCHAN, indexHash(LedgerNameSpace::XRPPaymentChannel, src, dst, seq.value())}; } Keylet @@ -438,9 +420,9 @@ nftokenPage(Keylet const& k, uint256 const& token) } Keylet -nftokenOffer(AccountID const& owner, std::uint32_t seq) +nftokenOffer(AccountID const& owner, SeqProxy const& seq) { - return {ltNFTOKEN_OFFER, indexHash(LedgerNameSpace::NftokenOffer, owner, seq)}; + return {ltNFTOKEN_OFFER, indexHash(LedgerNameSpace::NftokenOffer, owner, seq.value())}; } Keylet @@ -512,7 +494,7 @@ bridge(STXChainBridge const& bridge, STXChainBridge::ChainType chainType) } Keylet -xChainClaimID(STXChainBridge const& bridge, std::uint64_t seq) +xChainClaimID(STXChainBridge const& bridge, std::uint64_t const seq) { return { ltXCHAIN_OWNED_CLAIM_ID, @@ -526,7 +508,7 @@ xChainClaimID(STXChainBridge const& bridge, std::uint64_t seq) } Keylet -xChainCreateAccountClaimID(STXChainBridge const& bridge, std::uint64_t seq) +xChainCreateAccountClaimID(STXChainBridge const& bridge, std::uint64_t const seq) { return { ltXCHAIN_OWNED_CREATE_ACCOUNT_CLAIM_ID, @@ -546,17 +528,11 @@ did(AccountID const& account) noexcept } Keylet -oracle(AccountID const& account, std::uint32_t const& documentID) noexcept +oracle(AccountID const& account, std::uint32_t const documentID) noexcept { return {ltORACLE, indexHash(LedgerNameSpace::Oracle, account, documentID)}; } -Keylet -mptokenIssuance(std::uint32_t seq, AccountID const& issuer) noexcept -{ - return mptokenIssuance(makeMptID(seq, issuer)); -} - Keylet mptokenIssuance(MPTID const& issuanceID) noexcept { @@ -582,27 +558,29 @@ credential(AccountID const& subject, AccountID const& issuer, Slice const& credT } Keylet -vault(AccountID const& owner, std::uint32_t seq) noexcept +vault(AccountID const& owner, SeqProxy const& seq) noexcept { - return vault(indexHash(LedgerNameSpace::Vault, owner, seq)); + return vault(indexHash(LedgerNameSpace::Vault, owner, seq.value())); } Keylet -loanBroker(AccountID const& owner, std::uint32_t seq) noexcept +loanBroker(AccountID const& owner, SeqProxy const& seq) noexcept { - return loanBroker(indexHash(LedgerNameSpace::LoanBroker, owner, seq)); + return loanBroker(indexHash(LedgerNameSpace::LoanBroker, owner, seq.value())); } Keylet -loan(uint256 const& loanBrokerID, std::uint32_t loanSeq) noexcept +loan(uint256 const& loanBrokerID, SeqProxy const& loanSeq) noexcept { - return loan(indexHash(LedgerNameSpace::Loan, loanBrokerID, loanSeq)); + return loan(indexHash(LedgerNameSpace::Loan, loanBrokerID, loanSeq.value())); } Keylet -permissionedDomain(AccountID const& account, std::uint32_t seq) noexcept +permissionedDomain(AccountID const& account, SeqProxy const& seq) noexcept { - return {ltPERMISSIONED_DOMAIN, indexHash(LedgerNameSpace::PermissionedDomain, account, seq)}; + return { + ltPERMISSIONED_DOMAIN, + indexHash(LedgerNameSpace::PermissionedDomain, account, seq.value())}; } Keylet diff --git a/src/libxrpl/protocol/STTx.cpp b/src/libxrpl/protocol/STTx.cpp index 6aadefee27..7f1e19ea12 100644 --- a/src/libxrpl/protocol/STTx.cpp +++ b/src/libxrpl/protocol/STTx.cpp @@ -200,22 +200,16 @@ STTx::getSeqProxy() const { std::uint32_t const seq{getFieldU32(sfSequence)}; if (seq != 0) - return SeqProxy::sequence(seq); + return SeqProxy::rawSequence(seq); - std::optional const ticketSeq{operator[](~sfTicketSequence)}; + std::optional const ticketSeq{at(~sfTicketSequence)}; if (!ticketSeq) { // No TicketSequence specified. Return the Sequence, whatever it is. - return SeqProxy::sequence(seq); + return SeqProxy::rawSequence(seq); } - return SeqProxy{SeqProxy::Type::Ticket, *ticketSeq}; -} - -std::uint32_t -STTx::getSeqValue() const -{ - return getSeqProxy().value(); + return SeqProxy::rawTicket(*ticketSeq); } void @@ -459,7 +453,7 @@ STTx::checkBatchSingleSign(STObject const& batchSigner, std::vector con { XRPL_ASSERT(getTxnType() == ttBATCH, "STTx::checkBatchSingleSign : batch transaction"); Serializer msg; - serializeBatch(msg, getAccountID(sfAccount), getSeqValue(), getFlags(), txIds); + serializeBatch(msg, getAccountID(sfAccount), getSeqProxy().value(), getFlags(), txIds); finishMultiSigningData(batchSigner.getAccountID(sfAccount), msg); return singleSignHelper(batchSigner, msg.slice()); } @@ -553,7 +547,7 @@ STTx::checkBatchMultiSign( // with the stuff that stays constant from signature to signature. auto const batchSignerAccount = batchSigner.getAccountID(sfAccount); Serializer dataStart; - serializeBatch(dataStart, getAccountID(sfAccount), getSeqValue(), getFlags(), txIds); + serializeBatch(dataStart, getAccountID(sfAccount), getSeqProxy().value(), getFlags(), txIds); dataStart.addBitString(batchSignerAccount); return multiSignHelper( batchSigner, diff --git a/src/libxrpl/tx/Transactor.cpp b/src/libxrpl/tx/Transactor.cpp index 4b562692d7..5fc6942e20 100644 --- a/src/libxrpl/tx/Transactor.cpp +++ b/src/libxrpl/tx/Transactor.cpp @@ -709,7 +709,7 @@ Transactor::checkSeqProxy(ReadView const& view, STTx const& tx, beast::Journal j } SeqProxy const tSeqProx = tx.getSeqProxy(); - SeqProxy const aSeq = SeqProxy::sequence((*sle)[sfSequence]); + SeqProxy const aSeq = SeqProxy::rawSequence((*sle)[sfSequence]); if (tSeqProx.isSeq()) { @@ -791,16 +791,17 @@ TER Transactor::consumeSeqProxy(SLE::pointer const& sleAccount) { XRPL_ASSERT(sleAccount, "xrpl::Transactor::consumeSeqProxy : non-null account"); - SeqProxy const seqProx = ctx_.tx.getSeqProxy(); - if (seqProx.isSeq()) + SeqProxy const seqProxy = ctx_.tx.getSeqProxy(); + if (seqProxy.isSeq()) { // Note that if this transaction is a TicketCreate, then // the transaction will modify the account root sfSequence // yet again. - sleAccount->setFieldU32(sfSequence, seqProx.value() + 1); + sleAccount->setFieldU32(sfSequence, seqProxy.value() + 1); return tesSUCCESS; } - return ticketDelete(view(), accountID_, getTicketIndex(accountID_, seqProx), j_); + auto const keylet = keylet::ticket(accountID_, seqProxy); + return ticketDelete(view(), accountID_, keylet.key, j_); } // Remove a single Ticket from the ledger. diff --git a/src/libxrpl/tx/applySteps.cpp b/src/libxrpl/tx/applySteps.cpp index 5af4f621a7..2c05c874d3 100644 --- a/src/libxrpl/tx/applySteps.cpp +++ b/src/libxrpl/tx/applySteps.cpp @@ -251,7 +251,7 @@ TxConsequences::TxConsequences(NotTEC pfResult) : isBlocker_(false) , fee_(beast::kZero) , potentialSpend_(beast::kZero) - , seqProx_(SeqProxy::sequence(0)) + , seqProx_(SeqProxy::rawSequence(0)) , sequencesConsumed_(0) { XRPL_ASSERT( diff --git a/src/libxrpl/tx/transactors/check/CheckCreate.cpp b/src/libxrpl/tx/transactors/check/CheckCreate.cpp index cb1d81ba4a..129855e48d 100644 --- a/src/libxrpl/tx/transactors/check/CheckCreate.cpp +++ b/src/libxrpl/tx/transactors/check/CheckCreate.cpp @@ -25,7 +25,6 @@ #include #include -#include #include #include @@ -201,14 +200,14 @@ CheckCreate::doApply() return ret; // Note that we use the value from the sequence or ticket as the // Check sequence. For more explanation see comments in SeqProxy.h. - std::uint32_t const seq = ctx_.tx.getSeqValue(); + auto const seq = ctx_.tx.getSeqProxy(); Keylet const checkKeylet = keylet::check(accountID_, seq); auto sleCheck = std::make_shared(checkKeylet); sleCheck->setAccountID(sfAccount, accountID_); AccountID const dstAccountId = ctx_.tx[sfDestination]; sleCheck->setAccountID(sfDestination, dstAccountId); - sleCheck->setFieldU32(sfSequence, seq); + sleCheck->setFieldU32(sfSequence, seq.value()); sleCheck->setFieldAmount(sfSendMax, ctx_.tx[sfSendMax]); if (auto const srcTag = ctx_.tx[~sfSourceTag]) sleCheck->setFieldU32(sfSourceTag, *srcTag); diff --git a/src/libxrpl/tx/transactors/dex/OfferCancel.cpp b/src/libxrpl/tx/transactors/dex/OfferCancel.cpp index 0dea5fa967..fd19037d4f 100644 --- a/src/libxrpl/tx/transactors/dex/OfferCancel.cpp +++ b/src/libxrpl/tx/transactors/dex/OfferCancel.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -57,7 +58,8 @@ OfferCancel::doApply() if (!sle) return tefINTERNAL; // LCOV_EXCL_LINE - if (auto sleOffer = view().peek(keylet::offer(accountID_, offerSequence))) + auto const seqProxy = SeqProxy::rawSequence(offerSequence); + if (auto sleOffer = view().peek(keylet::offer(accountID_, seqProxy))) { JLOG(j_.debug()) << "Trying to cancel offer #" << offerSequence; return offerDelete(view(), sleOffer, ctx_.registry.get().getJournal("View")); diff --git a/src/libxrpl/tx/transactors/dex/OfferCreate.cpp b/src/libxrpl/tx/transactors/dex/OfferCreate.cpp index b95d1001e1..0492f9c062 100644 --- a/src/libxrpl/tx/transactors/dex/OfferCreate.cpp +++ b/src/libxrpl/tx/transactors/dex/OfferCreate.cpp @@ -34,6 +34,7 @@ #include #include #include +#include #include #include #include @@ -634,7 +635,7 @@ OfferCreate::applyGuts(Sandbox& sb, Sandbox& sbCancel) // Note that we use the value from the sequence or ticket as the // offer sequence. For more explanation see comments in SeqProxy.h. - auto const offerSequence = ctx_.tx.getSeqValue(); + auto const offerSequence = ctx_.tx.getSeqProxy(); // This is the original rate of the offer, and is the rate at which // it will be placed, even if crossing offers change the amounts that @@ -648,7 +649,8 @@ OfferCreate::applyGuts(Sandbox& sb, Sandbox& sbCancel) // Process a cancellation request that's passed along with an offer. if (cancelSequence) { - auto const sleCancel = sb.peek(keylet::offer(accountID_, *cancelSequence)); + auto const seqProxy = SeqProxy::rawSequence(*cancelSequence); + auto const sleCancel = sb.peek(keylet::offer(accountID_, seqProxy)); // It's not an error to not find the offer to cancel: it might have // been consumed or removed. If it is found, however, it's an error @@ -933,7 +935,7 @@ OfferCreate::applyGuts(Sandbox& sb, Sandbox& sbCancel) auto sleOffer = std::make_shared(offerIndex); sleOffer->setAccountID(sfAccount, accountID_); - sleOffer->setFieldU32(sfSequence, offerSequence); + sleOffer->setFieldU32(sfSequence, offerSequence.value()); sleOffer->setFieldH256(sfBookDirectory, dir.key); sleOffer->setFieldAmount(sfTakerPays, saTakerPays); sleOffer->setFieldAmount(sfTakerGets, saTakerGets); diff --git a/src/libxrpl/tx/transactors/escrow/EscrowCancel.cpp b/src/libxrpl/tx/transactors/escrow/EscrowCancel.cpp index feed43d410..21e6bd2c30 100644 --- a/src/libxrpl/tx/transactors/escrow/EscrowCancel.cpp +++ b/src/libxrpl/tx/transactors/escrow/EscrowCancel.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -92,7 +93,8 @@ EscrowCancel::preclaim(PreclaimContext const& ctx) { if (ctx.view.rules().enabled(featureTokenEscrow)) { - auto const k = keylet::escrow(ctx.tx[sfOwner], ctx.tx[sfOfferSequence]); + auto const seqProxy = SeqProxy::rawSequence(ctx.tx[sfOfferSequence]); + auto const k = keylet::escrow(ctx.tx[sfOwner], seqProxy); auto const slep = ctx.view.read(k); if (!slep) return tecNO_TARGET; @@ -117,7 +119,8 @@ EscrowCancel::preclaim(PreclaimContext const& ctx) TER EscrowCancel::doApply() { - auto const k = keylet::escrow(ctx_.tx[sfOwner], ctx_.tx[sfOfferSequence]); + auto const seqProxy = SeqProxy::rawSequence(ctx_.tx[sfOfferSequence]); + auto const k = keylet::escrow(ctx_.tx[sfOwner], seqProxy); auto const slep = ctx_.view().peek(k); if (!slep) { diff --git a/src/libxrpl/tx/transactors/escrow/EscrowCreate.cpp b/src/libxrpl/tx/transactors/escrow/EscrowCreate.cpp index 50f2e8b859..212f9da075 100644 --- a/src/libxrpl/tx/transactors/escrow/EscrowCreate.cpp +++ b/src/libxrpl/tx/transactors/escrow/EscrowCreate.cpp @@ -476,7 +476,7 @@ EscrowCreate::doApply() // Create escrow in ledger. Note that we use the value from the // sequence or ticket. For more explanation see comments in SeqProxy.h. - Keylet const escrowKeylet = keylet::escrow(accountID_, ctx_.tx.getSeqValue()); + Keylet const escrowKeylet = keylet::escrow(accountID_, ctx_.tx.getSeqProxy()); auto const slep = std::make_shared(escrowKeylet); (*slep)[sfAmount] = amount; (*slep)[sfAccount] = accountID_; @@ -489,7 +489,7 @@ EscrowCreate::doApply() if (ctx_.view().rules().enabled(fixIncludeKeyletFields)) { - (*slep)[sfSequence] = ctx_.tx.getSeqValue(); + (*slep)[sfSequence] = ctx_.tx.getSeqProxy().value(); } if (ctx_.view().rules().enabled(featureTokenEscrow) && !isXRP(amount)) diff --git a/src/libxrpl/tx/transactors/escrow/EscrowFinish.cpp b/src/libxrpl/tx/transactors/escrow/EscrowFinish.cpp index 8bc98c7aa8..5fc0aef853 100644 --- a/src/libxrpl/tx/transactors/escrow/EscrowFinish.cpp +++ b/src/libxrpl/tx/transactors/escrow/EscrowFinish.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -203,7 +204,8 @@ EscrowFinish::preclaim(PreclaimContext const& ctx) if (ctx.view.rules().enabled(featureTokenEscrow)) { - auto const k = keylet::escrow(ctx.tx[sfOwner], ctx.tx[sfOfferSequence]); + auto const seqProxy = SeqProxy::rawSequence(ctx.tx[sfOfferSequence]); + auto const k = keylet::escrow(ctx.tx[sfOwner], seqProxy); auto const slep = ctx.view.read(k); if (!slep) return tecNO_TARGET; @@ -228,7 +230,8 @@ EscrowFinish::preclaim(PreclaimContext const& ctx) TER EscrowFinish::doApply() { - auto const k = keylet::escrow(ctx_.tx[sfOwner], ctx_.tx[sfOfferSequence]); + auto const seqProxy = SeqProxy::rawSequence(ctx_.tx[sfOfferSequence]); + auto const k = keylet::escrow(ctx_.tx[sfOwner], seqProxy); auto const slep = ctx_.view().peek(k); if (!slep) { diff --git a/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp b/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp index b12cfb692f..d6cda9c326 100644 --- a/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp @@ -218,7 +218,7 @@ LoanBrokerSet::doApply() } auto const vaultPseudoID = sleVault->at(sfAccount); auto const vaultAsset = sleVault->at(sfAsset); - auto const sequence = tx.getSeqValue(); + auto const sequence = tx.getSeqProxy(); auto owner = view.peek(keylet::account(accountID_)); if (!owner) @@ -253,7 +253,7 @@ LoanBrokerSet::doApply() return ter; // Initialize data fields: - broker->at(sfSequence) = sequence; + broker->at(sfSequence) = sequence.value(); broker->at(sfVaultID) = vaultID; broker->at(sfOwner) = accountID_; broker->at(sfAccount) = pseudoId; diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index 95a9581dd3..6533a47916 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -594,7 +595,8 @@ LoanSet::doApply() auto loanSequenceProxy = brokerSle->at(sfLoanSequence); // Create the loan - auto loan = std::make_shared(keylet::loan(brokerID, *loanSequenceProxy)); + auto loan = + std::make_shared(keylet::loan(brokerID, SeqProxy::rawSequence(*loanSequenceProxy))); // Prevent copy/paste errors auto setLoanField = [&loan, &tx](auto const& field, std::uint32_t const defValue = 0) { diff --git a/src/libxrpl/tx/transactors/payment_channel/PaymentChannelCreate.cpp b/src/libxrpl/tx/transactors/payment_channel/PaymentChannelCreate.cpp index b17430948a..26d8ff4f04 100644 --- a/src/libxrpl/tx/transactors/payment_channel/PaymentChannelCreate.cpp +++ b/src/libxrpl/tx/transactors/payment_channel/PaymentChannelCreate.cpp @@ -169,7 +169,7 @@ PaymentChannelCreate::doApply() // // Note that we use the value from the sequence or ticket as the // payChan sequence. For more explanation see comments in SeqProxy.h. - Keylet const payChanKeylet = keylet::payChannel(account, dst, ctx_.tx.getSeqValue()); + Keylet const payChanKeylet = keylet::payChannel(account, dst, ctx_.tx.getSeqProxy()); auto const slep = std::make_shared(payChanKeylet); // Funds held in this channel @@ -185,7 +185,7 @@ PaymentChannelCreate::doApply() (*slep)[~sfDestinationTag] = ctx_.tx[~sfDestinationTag]; if (ctx_.view().rules().enabled(fixIncludeKeyletFields)) { - (*slep)[sfSequence] = ctx_.tx.getSeqValue(); + (*slep)[sfSequence] = ctx_.tx.getSeqProxy().value(); } ctx_.view().insert(slep); diff --git a/src/libxrpl/tx/transactors/permissioned_domain/PermissionedDomainSet.cpp b/src/libxrpl/tx/transactors/permissioned_domain/PermissionedDomainSet.cpp index 61ebdcf9c7..36c324eb80 100644 --- a/src/libxrpl/tx/transactors/permissioned_domain/PermissionedDomainSet.cpp +++ b/src/libxrpl/tx/transactors/permissioned_domain/PermissionedDomainSet.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -114,12 +115,13 @@ PermissionedDomainSet::doApply() return tecINSUFFICIENT_RESERVE; bool const fixEnabled = view().rules().enabled(fixCleanup3_1_3); - auto const seq = fixEnabled ? ctx_.tx.getSeqValue() : ctx_.tx.getFieldU32(sfSequence); + auto const seq = fixEnabled ? ctx_.tx.getSeqProxy() + : SeqProxy::rawSequence(ctx_.tx.getFieldU32(sfSequence)); Keylet const pdKeylet = keylet::permissionedDomain(accountID_, seq); auto slePd = std::make_shared(pdKeylet); slePd->setAccountID(sfOwner, accountID_); - slePd->setFieldU32(sfSequence, seq); + slePd->setFieldU32(sfSequence, seq.value()); slePd->peekFieldArray(sfAcceptedCredentials) = std::move(sortedLE); auto const page = view().dirInsert(keylet::ownerDir(accountID_), pdKeylet, describeOwnerDir(accountID_)); diff --git a/src/libxrpl/tx/transactors/system/TicketCreate.cpp b/src/libxrpl/tx/transactors/system/TicketCreate.cpp index e19dc9fe96..8844d325a8 100644 --- a/src/libxrpl/tx/transactors/system/TicketCreate.cpp +++ b/src/libxrpl/tx/transactors/system/TicketCreate.cpp @@ -99,7 +99,7 @@ TicketCreate::doApply() for (std::uint32_t i = 0; i < ticketCount; ++i) { std::uint32_t const curTicketSeq = firstTicketSeq + i; - Keylet const ticketKeylet = keylet::ticket(accountID_, curTicketSeq); + Keylet const ticketKeylet = keylet::ticket(accountID_, SeqProxy::rawTicket(curTicketSeq)); SLE::pointer const sleTicket = std::make_shared(ticketKeylet); sleTicket->setAccountID(sfAccount, accountID_); diff --git a/src/libxrpl/tx/transactors/token/MPTokenIssuanceCreate.cpp b/src/libxrpl/tx/transactors/token/MPTokenIssuanceCreate.cpp index 375110c330..cd0f839030 100644 --- a/src/libxrpl/tx/transactors/token/MPTokenIssuanceCreate.cpp +++ b/src/libxrpl/tx/transactors/token/MPTokenIssuanceCreate.cpp @@ -216,7 +216,7 @@ MPTokenIssuanceCreate::doApply() { .priorBalance = preFeeBalance_, .account = accountID_, - .sequence = tx.getSeqValue(), + .sequence = tx.getSeqProxy().value(), .flags = tx.getFlags(), .maxAmount = tx[~sfMaximumAmount], .assetScale = tx[~sfAssetScale], diff --git a/src/libxrpl/tx/transactors/vault/VaultCreate.cpp b/src/libxrpl/tx/transactors/vault/VaultCreate.cpp index efb0d57c42..f74a27c39b 100644 --- a/src/libxrpl/tx/transactors/vault/VaultCreate.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultCreate.cpp @@ -131,7 +131,7 @@ VaultCreate::preclaim(PreclaimContext const& ctx) return tecOBJECT_NOT_FOUND; } - auto const sequence = ctx.tx.getSeqValue(); + auto const sequence = ctx.tx.getSeqProxy(); if (auto const accountId = pseudoAccountAddress(ctx.view, keylet::vault(account, sequence).key); accountId == beast::kZero) return terADDRESS_COLLISION; @@ -148,7 +148,7 @@ VaultCreate::doApply() auto const& tx = ctx_.tx; auto applyViewContext = ctx_.getApplyViewContext(); - auto const sequence = tx.getSeqValue(); + auto const sequence = tx.getSeqProxy(); auto const owner = view().peek(keylet::account(accountID_)); if (owner == nullptr) return tefINTERNAL; // LCOV_EXCL_LINE @@ -218,7 +218,7 @@ VaultCreate::doApply() vault->setFieldIssue(sfAsset, STIssue{sfAsset, asset}); vault->at(sfFlags) = tx.getFlags() & tfVaultPrivate; - vault->at(sfSequence) = sequence; + vault->at(sfSequence) = sequence.value(); vault->at(sfOwner) = accountID_; vault->at(sfAccount) = pseudoId; vault->at(sfAssetsTotal) = Number(0); diff --git a/src/test/app/AccountDelete_test.cpp b/src/test/app/AccountDelete_test.cpp index 8fbb786caf..15668d4d71 100644 --- a/src/test/app/AccountDelete_test.cpp +++ b/src/test/app/AccountDelete_test.cpp @@ -37,6 +37,7 @@ #include #include #include +#include #include #include #include @@ -216,8 +217,10 @@ public: BEAST_EXPECT(env.closed()->exists(keylet::account(carol.id()))); BEAST_EXPECT(env.closed()->exists(keylet::ownerDir(carol.id()))); BEAST_EXPECT(env.closed()->exists(keylet::depositPreauth(carol.id(), becky.id()))); - BEAST_EXPECT(env.closed()->exists(keylet::offer(carol.id(), carolOfferSeq))); - BEAST_EXPECT(env.closed()->exists(keylet::ticket(carol.id(), carolTicketSeq))); + BEAST_EXPECT(env.closed()->exists( + keylet::offer(carol.id(), SeqProxy::rawSequence(carolOfferSeq)))); + BEAST_EXPECT(env.closed()->exists( + keylet::ticket(carol.id(), SeqProxy::rawTicket(carolTicketSeq)))); BEAST_EXPECT(env.closed()->exists(keylet::signerList(carol.id()))); // Delete carol's account even with stuff in her directory. Show @@ -230,8 +233,10 @@ public: BEAST_EXPECT(!env.closed()->exists(keylet::account(carol.id()))); BEAST_EXPECT(!env.closed()->exists(keylet::ownerDir(carol.id()))); BEAST_EXPECT(!env.closed()->exists(keylet::depositPreauth(carol.id(), becky.id()))); - BEAST_EXPECT(!env.closed()->exists(keylet::offer(carol.id(), carolOfferSeq))); - BEAST_EXPECT(!env.closed()->exists(keylet::ticket(carol.id(), carolTicketSeq))); + BEAST_EXPECT(!env.closed()->exists( + keylet::offer(carol.id(), SeqProxy::rawSequence(carolOfferSeq)))); + BEAST_EXPECT(!env.closed()->exists( + keylet::ticket(carol.id(), SeqProxy::rawTicket(carolTicketSeq)))); BEAST_EXPECT(!env.closed()->exists(keylet::signerList(carol.id()))); // Verify that Carol's XRP, minus the fee, was transferred to becky. @@ -325,7 +330,7 @@ public: // alice writes a check to becky. Until that check is cashed or // canceled it will prevent alice's and becky's accounts from being // deleted. - uint256 const checkId = keylet::check(alice, env.seq(alice)).key; + uint256 const checkId = keylet::check(alice, SeqProxy::rawSequence(env.seq(alice))).key; env(check::create(alice, becky, XRP(1))); env.close(); @@ -388,7 +393,8 @@ public: env(escrow::cancel(becky, alice, escrowSeq)); env.close(); - Keylet const alicePayChanKey{keylet::payChannel(alice, becky, env.seq(alice))}; + Keylet const alicePayChanKey{ + keylet::payChannel(alice, becky, SeqProxy::rawSequence(env.seq(alice)))}; env(payChanCreate(alice, becky, XRP(57), 4s, env.now() + 2s, alice.pk())); env.close(); @@ -419,7 +425,8 @@ public: // gw creates a PayChannel with alice as the destination, this should // prevent alice from deleting her account. - Keylet const gwPayChanKey{keylet::payChannel(gw, alice, env.seq(gw))}; + Keylet const gwPayChanKey{ + keylet::payChannel(gw, alice, SeqProxy::rawSequence(env.seq(gw)))}; env(payChanCreate(gw, alice, XRP(68), 4s, env.now() + 2s, alice.pk())); env.close(); @@ -505,7 +512,10 @@ public: // alice's offers. for (std::uint32_t i{0}; i < kOfferCount; ++i) - BEAST_EXPECT(closed->exists(keylet::offer(alice.id(), offerSeq0 + i))); + { + BEAST_EXPECT(closed->exists( + keylet::offer(alice.id(), SeqProxy::rawSequence(offerSeq0 + i)))); + } } // Delete alice's account. Should fail because she has too many @@ -539,7 +549,10 @@ public: // alice's former offers. for (std::uint32_t i{0}; i < kOfferCount; ++i) - BEAST_EXPECT(!closed->exists(keylet::offer(alice.id(), offerSeq0 + i))); + { + BEAST_EXPECT(!closed->exists( + keylet::offer(alice.id(), SeqProxy::rawSequence(offerSeq0 + i)))); + } } } @@ -664,7 +677,8 @@ public: BEAST_EXPECT(closed->exists(keylet::account(bob.id()))); for (std::uint32_t i = 0; i < 250; ++i) { - BEAST_EXPECT(closed->exists(keylet::ticket(bob.id(), ticketSeq + i))); + BEAST_EXPECT( + closed->exists(keylet::ticket(bob.id(), SeqProxy::rawTicket(ticketSeq + i)))); } } @@ -683,7 +697,8 @@ public: BEAST_EXPECT(!closed->exists(keylet::account(bob.id()))); for (std::uint32_t i = 0; i < 250; ++i) { - BEAST_EXPECT(!closed->exists(keylet::ticket(bob.id(), ticketSeq + i))); + BEAST_EXPECT( + !closed->exists(keylet::ticket(bob.id(), SeqProxy::rawTicket(ticketSeq + i)))); } } } diff --git a/src/test/app/Batch_test.cpp b/src/test/app/Batch_test.cpp index 5230a1f7dd..c332b26a5b 100644 --- a/src/test/app/Batch_test.cpp +++ b/src/test/app/Batch_test.cpp @@ -54,6 +54,7 @@ #include #include #include +#include #include #include #include @@ -166,7 +167,7 @@ class Batch_test : public beast::unit_test::Suite static uint256 getCheckIndex(AccountID const& account, std::uint32_t uSequence) { - return keylet::check(account, uSequence).key; + return keylet::check(account, SeqProxy::rawSequence(uSequence)).key; } static std::unique_ptr @@ -648,7 +649,7 @@ class Batch_test : public beast::unit_test::Suite serializeBatch( msg, jt.stx->getAccountID(sfAccount), - jt.stx->getSeqValue(), + jt.stx->getSeqProxy().value(), tfAllOrNothing, jt.stx->getBatchTransactionIDs()); finishMultiSigningData(bob.id(), msg); @@ -3176,7 +3177,8 @@ class Batch_test : public beast::unit_test::Suite env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = deposit})); env.close(); - auto const brokerKeylet = keylet::loanBroker(lender.id(), env.seq(lender)); + auto const brokerKeylet = + keylet::loanBroker(lender.id(), SeqProxy::rawSequence(env.seq(lender))); { using namespace loan_broker; @@ -3198,7 +3200,7 @@ class Batch_test : public beast::unit_test::Suite auto const lenderSeq = env.seq(lender); auto const batchFee = batch::calcBatchFee(env, 0, 2); - auto const loanKeylet = keylet::loan(brokerKeylet.key, 1); + auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1)); { auto const [txIDs, batchID] = submitBatch( env, diff --git a/src/test/app/Check_test.cpp b/src/test/app/Check_test.cpp index 840c06bd84..364f66c03a 100644 --- a/src/test/app/Check_test.cpp +++ b/src/test/app/Check_test.cpp @@ -35,6 +35,7 @@ #include #include #include +#include #include #include #include @@ -53,7 +54,7 @@ class Check_test : public beast::unit_test::Suite static uint256 getCheckIndex(AccountID const& account, std::uint32_t uSequence) { - return keylet::check(account, uSequence).key; + return keylet::check(account, SeqProxy::rawSequence(uSequence)).key; } // Helper function that returns the Checks on an account. diff --git a/src/test/app/EscrowToken_test.cpp b/src/test/app/EscrowToken_test.cpp index 4015f5ddc8..d0decaf497 100644 --- a/src/test/app/EscrowToken_test.cpp +++ b/src/test/app/EscrowToken_test.cpp @@ -30,6 +30,7 @@ #include #include #include +#include #include #include #include @@ -943,7 +944,7 @@ struct EscrowToken_test : public beast::unit_test::Suite if (env.current()->rules().enabled(fixCleanup3_2_0)) { - BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), seq))); + BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(seq)))); BEAST_EXPECT(env.current()->exists(trustLineKey)); BEAST_EXPECT(env.balance(alice, usd) == usd(1'000)); } @@ -1072,7 +1073,7 @@ struct EscrowToken_test : public beast::unit_test::Suite BEAST_EXPECT( (*env.meta())[sfTransactionResult] == static_cast(tesSUCCESS)); env.close(5s); - auto const aa = env.le(keylet::escrow(alice.id(), aseq)); + auto const aa = env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(aseq))); BEAST_EXPECT(aa); { xrpl::Dir const aod(*env.current(), keylet::ownerDir(alice.id())); @@ -1096,7 +1097,7 @@ struct EscrowToken_test : public beast::unit_test::Suite BEAST_EXPECT( (*env.meta())[sfTransactionResult] == static_cast(tesSUCCESS)); env.close(5s); - auto const bb = env.le(keylet::escrow(bob.id(), bseq)); + auto const bb = env.le(keylet::escrow(bob.id(), SeqProxy::rawSequence(bseq))); BEAST_EXPECT(bb); { @@ -1118,7 +1119,7 @@ struct EscrowToken_test : public beast::unit_test::Suite env.close(5s); env(escrow::finish(alice, alice, aseq)); { - BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), aseq))); + BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(aseq)))); BEAST_EXPECT( (*env.meta())[sfTransactionResult] == static_cast(tesSUCCESS)); @@ -1144,7 +1145,7 @@ struct EscrowToken_test : public beast::unit_test::Suite env.close(5s); env(escrow::cancel(bob, bob, bseq)); { - BEAST_EXPECT(!env.le(keylet::escrow(bob.id(), bseq))); + BEAST_EXPECT(!env.le(keylet::escrow(bob.id(), SeqProxy::rawSequence(bseq)))); BEAST_EXPECT( (*env.meta())[sfTransactionResult] == static_cast(tesSUCCESS)); @@ -1188,10 +1189,10 @@ struct EscrowToken_test : public beast::unit_test::Suite (*env.meta())[sfTransactionResult] == static_cast(tesSUCCESS)); env.close(5s); - auto const ab = env.le(keylet::escrow(alice.id(), aseq)); + auto const ab = env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(aseq))); BEAST_EXPECT(ab); - auto const bc = env.le(keylet::escrow(bob.id(), bseq)); + auto const bc = env.le(keylet::escrow(bob.id(), SeqProxy::rawSequence(bseq))); BEAST_EXPECT(bc); { @@ -1229,8 +1230,8 @@ struct EscrowToken_test : public beast::unit_test::Suite env.close(5s); env(escrow::finish(alice, alice, aseq)); { - BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), aseq))); - BEAST_EXPECT(env.le(keylet::escrow(bob.id(), bseq))); + BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(aseq)))); + BEAST_EXPECT(env.le(keylet::escrow(bob.id(), SeqProxy::rawSequence(bseq)))); xrpl::Dir const aod(*env.current(), keylet::ownerDir(alice.id())); BEAST_EXPECT(std::distance(aod.begin(), aod.end()) == 1); @@ -1263,8 +1264,8 @@ struct EscrowToken_test : public beast::unit_test::Suite env.close(5s); env(escrow::cancel(bob, bob, bseq)); { - BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), aseq))); - BEAST_EXPECT(!env.le(keylet::escrow(bob.id(), bseq))); + BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(aseq)))); + BEAST_EXPECT(!env.le(keylet::escrow(bob.id(), SeqProxy::rawSequence(bseq)))); xrpl::Dir const aod(*env.current(), keylet::ownerDir(alice.id())); BEAST_EXPECT(std::distance(aod.begin(), aod.end()) == 1); @@ -1320,7 +1321,7 @@ struct EscrowToken_test : public beast::unit_test::Suite Ter(tecNO_PERMISSION)); env.close(5s); - auto const ag = env.le(keylet::escrow(alice.id(), aseq)); + auto const ag = env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(aseq))); BEAST_EXPECT(ag); { @@ -1343,7 +1344,7 @@ struct EscrowToken_test : public beast::unit_test::Suite env.close(5s); env(escrow::finish(alice, alice, aseq)); { - BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), aseq))); + BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(aseq)))); xrpl::Dir const aod(*env.current(), keylet::ownerDir(alice.id())); BEAST_EXPECT(std::distance(aod.begin(), aod.end()) == 1); @@ -2702,7 +2703,8 @@ struct EscrowToken_test : public beast::unit_test::Suite auto const seq1 = env.seq(alice); env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal j) { Sandbox sb(&view, TapNone); - auto sleNew = std::make_shared(keylet::escrow(alice, seq1)); + auto sleNew = + std::make_shared(keylet::escrow(alice, SeqProxy::rawSequence(seq1))); MPTIssue const mpt{MPTIssue{makeMptID(1, AccountID(0x4985601))}}; STAmount const amt(mpt, 10); sleNew->setAccountID(sfDestination, bob); @@ -2929,7 +2931,8 @@ struct EscrowToken_test : public beast::unit_test::Suite auto const seq1 = env.seq(alice); env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal j) { Sandbox sb(&view, TapNone); - auto sleNew = std::make_shared(keylet::escrow(alice, seq1)); + auto sleNew = + std::make_shared(keylet::escrow(alice, SeqProxy::rawSequence(seq1))); MPTIssue const mpt{MPTIssue{makeMptID(1, AccountID(0x4985601))}}; STAmount const amt(mpt, 10); sleNew->setAccountID(sfDestination, bob); @@ -3280,7 +3283,7 @@ struct EscrowToken_test : public beast::unit_test::Suite BEAST_EXPECT( (*env.meta())[sfTransactionResult] == static_cast(tesSUCCESS)); env.close(5s); - auto const aa = env.le(keylet::escrow(alice.id(), aseq)); + auto const aa = env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(aseq))); BEAST_EXPECT(aa); { xrpl::Dir const aod(*env.current(), keylet::ownerDir(alice.id())); @@ -3304,7 +3307,7 @@ struct EscrowToken_test : public beast::unit_test::Suite BEAST_EXPECT( (*env.meta())[sfTransactionResult] == static_cast(tesSUCCESS)); env.close(5s); - auto const bb = env.le(keylet::escrow(bob.id(), bseq)); + auto const bb = env.le(keylet::escrow(bob.id(), SeqProxy::rawSequence(bseq))); BEAST_EXPECT(bb); { @@ -3318,7 +3321,7 @@ struct EscrowToken_test : public beast::unit_test::Suite env.close(5s); env(escrow::finish(alice, alice, aseq)); { - BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), aseq))); + BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(aseq)))); BEAST_EXPECT( (*env.meta())[sfTransactionResult] == static_cast(tesSUCCESS)); @@ -3338,7 +3341,7 @@ struct EscrowToken_test : public beast::unit_test::Suite env.close(5s); env(escrow::cancel(bob, bob, bseq)); { - BEAST_EXPECT(!env.le(keylet::escrow(bob.id(), bseq))); + BEAST_EXPECT(!env.le(keylet::escrow(bob.id(), SeqProxy::rawSequence(bseq)))); BEAST_EXPECT( (*env.meta())[sfTransactionResult] == static_cast(tesSUCCESS)); @@ -3379,10 +3382,10 @@ struct EscrowToken_test : public beast::unit_test::Suite (*env.meta())[sfTransactionResult] == static_cast(tesSUCCESS)); env.close(5s); - auto const ab = env.le(keylet::escrow(alice.id(), aseq)); + auto const ab = env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(aseq))); BEAST_EXPECT(ab); - auto const bc = env.le(keylet::escrow(bob.id(), bseq)); + auto const bc = env.le(keylet::escrow(bob.id(), SeqProxy::rawSequence(bseq))); BEAST_EXPECT(bc); { @@ -3411,8 +3414,8 @@ struct EscrowToken_test : public beast::unit_test::Suite env.close(5s); env(escrow::finish(alice, alice, aseq)); { - BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), aseq))); - BEAST_EXPECT(env.le(keylet::escrow(bob.id(), bseq))); + BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(aseq)))); + BEAST_EXPECT(env.le(keylet::escrow(bob.id(), SeqProxy::rawSequence(bseq)))); xrpl::Dir const aod(*env.current(), keylet::ownerDir(alice.id())); BEAST_EXPECT(std::distance(aod.begin(), aod.end()) == 1); @@ -3436,8 +3439,8 @@ struct EscrowToken_test : public beast::unit_test::Suite env.close(5s); env(escrow::cancel(bob, bob, bseq)); { - BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), aseq))); - BEAST_EXPECT(!env.le(keylet::escrow(bob.id(), bseq))); + BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(aseq)))); + BEAST_EXPECT(!env.le(keylet::escrow(bob.id(), SeqProxy::rawSequence(bseq)))); xrpl::Dir const aod(*env.current(), keylet::ownerDir(alice.id())); BEAST_EXPECT(std::distance(aod.begin(), aod.end()) == 1); diff --git a/src/test/app/Escrow_test.cpp b/src/test/app/Escrow_test.cpp index 5623bc4443..8a0d651004 100644 --- a/src/test/app/Escrow_test.cpp +++ b/src/test/app/Escrow_test.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -230,7 +231,7 @@ struct Escrow_test : public beast::unit_test::Suite Stag(1), Dtag(2)); - auto const sle = env.le(keylet::escrow(alice.id(), seq)); + auto const sle = env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(seq))); BEAST_EXPECT(sle); BEAST_EXPECT((*sle)[sfSourceTag] == 1); BEAST_EXPECT((*sle)[sfDestinationTag] == 2); @@ -773,7 +774,8 @@ struct Escrow_test : public beast::unit_test::Suite Fee(150 * baseFee)); // SLE removed on finish - BEAST_EXPECT(!env.le(keylet::escrow(Account("alice").id(), seq))); + BEAST_EXPECT( + !env.le(keylet::escrow(Account("alice").id(), SeqProxy::rawSequence(seq)))); BEAST_EXPECT((*env.le("alice"))[sfOwnerCount] == 0); env.require(Balance("carol", XRP(6000))); env(escrow::cancel("bob", "alice", seq), Ter(tecNO_TARGET)); @@ -795,7 +797,8 @@ struct Escrow_test : public beast::unit_test::Suite env(escrow::cancel("bob", "alice", seq)); env.require(Balance("alice", XRP(5000) - drops(baseFee))); // SLE removed on cancel - BEAST_EXPECT(!env.le(keylet::escrow(Account("alice").id(), seq))); + BEAST_EXPECT( + !env.le(keylet::escrow(Account("alice").id(), SeqProxy::rawSequence(seq)))); } { Env env(*this, features); @@ -1117,7 +1120,7 @@ struct Escrow_test : public beast::unit_test::Suite BEAST_EXPECT( (*env.meta())[sfTransactionResult] == static_cast(tesSUCCESS)); env.close(5s); - auto const aa = env.le(keylet::escrow(alice.id(), aseq)); + auto const aa = env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(aseq))); BEAST_EXPECT(aa); { @@ -1134,7 +1137,7 @@ struct Escrow_test : public beast::unit_test::Suite BEAST_EXPECT( (*env.meta())[sfTransactionResult] == static_cast(tesSUCCESS)); env.close(5s); - auto const bb = env.le(keylet::escrow(bruce.id(), bseq)); + auto const bb = env.le(keylet::escrow(bruce.id(), SeqProxy::rawSequence(bseq))); BEAST_EXPECT(bb); { @@ -1148,7 +1151,7 @@ struct Escrow_test : public beast::unit_test::Suite env.close(5s); env(escrow::finish(alice, alice, aseq)); { - BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), aseq))); + BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(aseq)))); BEAST_EXPECT( (*env.meta())[sfTransactionResult] == static_cast(tesSUCCESS)); @@ -1168,7 +1171,7 @@ struct Escrow_test : public beast::unit_test::Suite env.close(5s); env(escrow::cancel(bruce, bruce, bseq)); { - BEAST_EXPECT(!env.le(keylet::escrow(bruce.id(), bseq))); + BEAST_EXPECT(!env.le(keylet::escrow(bruce.id(), SeqProxy::rawSequence(bseq)))); BEAST_EXPECT( (*env.meta())[sfTransactionResult] == static_cast(tesSUCCESS)); @@ -1198,10 +1201,10 @@ struct Escrow_test : public beast::unit_test::Suite (*env.meta())[sfTransactionResult] == static_cast(tesSUCCESS)); env.close(5s); - auto const ab = env.le(keylet::escrow(alice.id(), aseq)); + auto const ab = env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(aseq))); BEAST_EXPECT(ab); - auto const bc = env.le(keylet::escrow(bruce.id(), bseq)); + auto const bc = env.le(keylet::escrow(bruce.id(), SeqProxy::rawSequence(bseq))); BEAST_EXPECT(bc); { @@ -1230,8 +1233,8 @@ struct Escrow_test : public beast::unit_test::Suite env.close(5s); env(escrow::finish(alice, alice, aseq)); { - BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), aseq))); - BEAST_EXPECT(env.le(keylet::escrow(bruce.id(), bseq))); + BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(aseq)))); + BEAST_EXPECT(env.le(keylet::escrow(bruce.id(), SeqProxy::rawSequence(bseq)))); xrpl::Dir const aod(*env.current(), keylet::ownerDir(alice.id())); BEAST_EXPECT(std::distance(aod.begin(), aod.end()) == 0); @@ -1255,8 +1258,8 @@ struct Escrow_test : public beast::unit_test::Suite env.close(5s); env(escrow::cancel(bruce, bruce, bseq)); { - BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), aseq))); - BEAST_EXPECT(!env.le(keylet::escrow(bruce.id(), bseq))); + BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(aseq)))); + BEAST_EXPECT(!env.le(keylet::escrow(bruce.id(), SeqProxy::rawSequence(bseq)))); xrpl::Dir const aod(*env.current(), keylet::ownerDir(alice.id())); BEAST_EXPECT(std::distance(aod.begin(), aod.end()) == 0); diff --git a/src/test/app/FixNFTokenPageLinks_test.cpp b/src/test/app/FixNFTokenPageLinks_test.cpp index 9be01b2abe..d73ab9b6c7 100644 --- a/src/test/app/FixNFTokenPageLinks_test.cpp +++ b/src/test/app/FixNFTokenPageLinks_test.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -375,7 +376,8 @@ class FixNFTokenPageLinks_test : public beast::unit_test::Suite dariaNFTs.reserve(32); for (int i = 0; i < 32; ++i) { - uint256 const offerIndex = keylet::nftokenOffer(carol, env.seq(carol)).key; + uint256 const offerIndex = + keylet::nftokenOffer(carol, SeqProxy::rawSequence(env.seq(carol))).key; env(token::createOffer(carol, carolNFTs.back(), XRP(0)), Txflags(tfSellNFToken)); env.close(); @@ -409,7 +411,8 @@ class FixNFTokenPageLinks_test : public beast::unit_test::Suite // back from daria. for (uint256 const& nft : dariaNFTs) { - uint256 const offerIndex = keylet::nftokenOffer(carol, env.seq(carol)).key; + uint256 const offerIndex = + keylet::nftokenOffer(carol, SeqProxy::rawSequence(env.seq(carol))).key; env(token::createOffer(carol, nft, drops(1)), token::Owner(daria)); env.close(); diff --git a/src/test/app/FlowMPT_test.cpp b/src/test/app/FlowMPT_test.cpp index 302e55a2cc..a94834eb28 100644 --- a/src/test/app/FlowMPT_test.cpp +++ b/src/test/app/FlowMPT_test.cpp @@ -31,6 +31,7 @@ #include #include #include +#include #include #include #include @@ -408,7 +409,7 @@ struct FlowMPT_test : public beast::unit_test::Suite env(pay(gw, alice, usd(1'000))); env(pay(gw, bob, eur(1'000))); - Keylet const bobUsdOffer = keylet::offer(bob, env.seq(bob)); + Keylet const bobUsdOffer = keylet::offer(bob, SeqProxy::rawSequence(env.seq(bob))); env(offer(bob, usd(10), drops(2)), Txflags(tfPassive)); env(offer(bob, drops(1), eur(1'000)), Txflags(tfPassive)); diff --git a/src/test/app/Flow_test.cpp b/src/test/app/Flow_test.cpp index 8d5162394e..5f12d54aec 100644 --- a/src/test/app/Flow_test.cpp +++ b/src/test/app/Flow_test.cpp @@ -35,6 +35,7 @@ #include #include #include +#include #include #include #include @@ -547,7 +548,7 @@ struct Flow_test : public beast::unit_test::Suite env(pay(gw, alice, usd(1000))); env(pay(gw, bob, eur(1000))); - Keylet const bobUsdOffer = keylet::offer(bob, env.seq(bob)); + Keylet const bobUsdOffer = keylet::offer(bob, SeqProxy::rawSequence(env.seq(bob))); env(offer(bob, usd(1), drops(2)), Txflags(tfPassive)); env(offer(bob, drops(1), eur(1000)), Txflags(tfPassive)); diff --git a/src/test/app/Freeze_test.cpp b/src/test/app/Freeze_test.cpp index 786f5b4680..79087dacc3 100644 --- a/src/test/app/Freeze_test.cpp +++ b/src/test/app/Freeze_test.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -1788,7 +1789,7 @@ class Freeze_test : public beast::unit_test::Suite env(token::mint(a2, 0), Txflags(tfTransferable)); env.close(); - auto const buyIdx = keylet::nftokenOffer(a1, env.seq(a1)).key; + auto const buyIdx = keylet::nftokenOffer(a1, SeqProxy::rawSequence(env.seq(a1))).key; env(token::createOffer(a1, nftID, usd(10)), token::Owner(a2)); env.close(); @@ -1874,10 +1875,11 @@ class Freeze_test : public beast::unit_test::Suite env(token::mint(a2, 0), Txflags(tfTransferable)); env.close(); - uint256 const sellIdx = keylet::nftokenOffer(a2, env.seq(a2)).key; + uint256 const sellIdx = + keylet::nftokenOffer(a2, SeqProxy::rawSequence(env.seq(a2))).key; env(token::createOffer(a2, nftID, usd(10)), Txflags(tfSellNFToken)); env.close(); - auto const buyIdx = keylet::nftokenOffer(a1, env.seq(a1)).key; + auto const buyIdx = keylet::nftokenOffer(a1, SeqProxy::rawSequence(env.seq(a1))).key; env(token::createOffer(a1, nftID, usd(11)), token::Owner(a2)); env.close(); @@ -1900,13 +1902,15 @@ class Freeze_test : public beast::unit_test::Suite env(token::mint(minter, 0), token::XferFee(1u), Txflags(tfTransferable)); env.close(); - uint256 const minterSellIdx = keylet::nftokenOffer(minter, env.seq(minter)).key; + uint256 const minterSellIdx = + keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key; env(token::createOffer(minter, nftID, drops(1)), Txflags(tfSellNFToken)); env.close(); env(token::acceptSellOffer(a2, minterSellIdx)); env.close(); - uint256 const sellIdx = keylet::nftokenOffer(a2, env.seq(a2)).key; + uint256 const sellIdx = + keylet::nftokenOffer(a2, SeqProxy::rawSequence(env.seq(a2))).key; env(token::createOffer(a2, nftID, usd(100)), Txflags(tfSellNFToken)); env.close(); env(trust(g1, minter["USD"](1000), tfSetFreeze | tfSetDeepFreeze)); @@ -1946,7 +1950,7 @@ class Freeze_test : public beast::unit_test::Suite static uint256 getCheckIndex(AccountID const& account, std::uint32_t uSequence) { - return keylet::check(account, uSequence).key; + return keylet::check(account, SeqProxy::rawSequence(uSequence)).key; } static uint256 @@ -1960,7 +1964,8 @@ class Freeze_test : public beast::unit_test::Suite env(token::mint(account, 0), Txflags(tfTransferable)); env.close(); - uint256 const sellOfferIndex = keylet::nftokenOffer(account, env.seq(account)).key; + uint256 const sellOfferIndex = + keylet::nftokenOffer(account, SeqProxy::rawSequence(env.seq(account))).key; env(token::createOffer(account, nftID, currency), Txflags(tfSellNFToken)); env.close(); diff --git a/src/test/app/Invariants_test.cpp b/src/test/app/Invariants_test.cpp index 6234d81762..ffdfe6bc83 100644 --- a/src/test/app/Invariants_test.cpp +++ b/src/test/app/Invariants_test.cpp @@ -42,6 +42,7 @@ #include #include #include +#include #include #include #include @@ -633,8 +634,8 @@ class Invariants_test : public beast::unit_test::Suite // make a dummy escrow ledger entry, then change the type to an // unsupported value so that the valid type invariant check // will fail. - auto const sleNew = - std::make_shared(keylet::escrow(a1, (*sle)[sfSequence] + 2)); + auto const sleNew = std::make_shared( + keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2))); // We don't use ltNICKNAME directly since it's marked deprecated // to prevent accidental use elsewhere. @@ -921,7 +922,8 @@ class Invariants_test : public beast::unit_test::Suite auto const sle = ac.view().peek(keylet::account(a1.id())); if (!sle) return false; - auto sleNew = std::make_shared(keylet::offer(a1.id(), (*sle)[sfSequence])); + auto sleNew = std::make_shared( + keylet::offer(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence]))); sleNew->setAccountID(sfAccount, a1.id()); sleNew->setFieldU32(sfSequence, (*sle)[sfSequence]); sleNew->setFieldAmount(sfTakerPays, XRP(-1)); @@ -935,7 +937,8 @@ class Invariants_test : public beast::unit_test::Suite auto const sle = ac.view().peek(keylet::account(a1.id())); if (!sle) return false; - auto sleNew = std::make_shared(keylet::offer(a1.id(), (*sle)[sfSequence])); + auto sleNew = std::make_shared( + keylet::offer(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence]))); sleNew->setAccountID(sfAccount, a1.id()); sleNew->setFieldU32(sfSequence, (*sle)[sfSequence]); sleNew->setFieldAmount(sfTakerPays, a1["USD"](10)); @@ -950,7 +953,8 @@ class Invariants_test : public beast::unit_test::Suite auto const sle = ac.view().peek(keylet::account(a1.id())); if (!sle) return false; - auto sleNew = std::make_shared(keylet::offer(a1.id(), (*sle)[sfSequence])); + auto sleNew = std::make_shared( + keylet::offer(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence]))); sleNew->setAccountID(sfAccount, a1.id()); sleNew->setFieldU32(sfSequence, (*sle)[sfSequence]); sleNew->setFieldAmount(sfTakerPays, XRP(10)); @@ -974,7 +978,8 @@ class Invariants_test : public beast::unit_test::Suite auto const sle = ac.view().peek(keylet::account(a1.id())); if (!sle) return false; - auto sleNew = std::make_shared(keylet::escrow(a1, (*sle)[sfSequence] + 2)); + auto sleNew = std::make_shared( + keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2))); sleNew->setFieldAmount(sfAmount, XRP(-1)); ac.view().insert(sleNew); return true; @@ -988,7 +993,8 @@ class Invariants_test : public beast::unit_test::Suite auto const sle = ac.view().peek(keylet::account(a1.id())); if (!sle) return false; - auto sleNew = std::make_shared(keylet::escrow(a1, (*sle)[sfSequence] + 2)); + auto sleNew = std::make_shared( + keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2))); // Use `drops(1)` to bypass a call to STAmount::canonicalize // with an invalid value sleNew->setFieldAmount(sfAmount, kInitialXrp + drops(1)); @@ -1004,7 +1010,8 @@ class Invariants_test : public beast::unit_test::Suite auto const sle = ac.view().peek(keylet::account(a1.id())); if (!sle) return false; - auto sleNew = std::make_shared(keylet::escrow(a1, (*sle)[sfSequence] + 2)); + auto sleNew = std::make_shared( + keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2))); Issue const usd{Currency(0x5553440000000000), AccountID(0x4985601)}; STAmount const amt(usd, -1); @@ -1021,7 +1028,8 @@ class Invariants_test : public beast::unit_test::Suite auto const sle = ac.view().peek(keylet::account(a1.id())); if (!sle) return false; - auto sleNew = std::make_shared(keylet::escrow(a1, (*sle)[sfSequence] + 2)); + auto sleNew = std::make_shared( + keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2))); Issue const bad{badCurrency(), AccountID(0x4985601)}; STAmount const amt(bad, 1); @@ -1038,7 +1046,8 @@ class Invariants_test : public beast::unit_test::Suite auto const sle = ac.view().peek(keylet::account(a1.id())); if (!sle) return false; - auto sleNew = std::make_shared(keylet::escrow(a1, (*sle)[sfSequence] + 2)); + auto sleNew = std::make_shared( + keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2))); MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))}; STAmount const amt(mpt, -1); @@ -1463,7 +1472,7 @@ class Invariants_test : public beast::unit_test::Suite std::uint32_t numCreds = 2, std::uint32_t seq = 10) { - Keylet const pdKeylet = keylet::permissionedDomain(a1.id(), seq); + Keylet const pdKeylet = keylet::permissionedDomain(a1.id(), SeqProxy::rawSequence(seq)); auto sle = std::make_shared(pdKeylet); sle->setAccountID(sfOwner, a1); @@ -2005,7 +2014,7 @@ class Invariants_test : public beast::unit_test::Suite makeEnv(features), {{"domain doesn't exist"}}, [](Account const& a1, Account const&, ApplyContext& ac) { - Keylet const offerKey = keylet::offer(a1.id(), 10); + Keylet const offerKey = keylet::offer(a1.id(), SeqProxy::rawSequence(10)); auto sleOffer = std::make_shared(offerKey); sleOffer->setAccountID(sfAccount, a1); sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10)); @@ -2032,7 +2041,7 @@ class Invariants_test : public beast::unit_test::Suite makeEnv(features), {{"hybrid offer is malformed"}}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - Keylet const offerKey = keylet::offer(a2.id(), 10); + Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10)); auto sleOffer = std::make_shared(offerKey); sleOffer->setAccountID(sfAccount, a2); sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10)); @@ -2067,7 +2076,7 @@ class Invariants_test : public beast::unit_test::Suite a2, {{"hybrid offer is malformed"}}, [&pd1](Account const& a1, Account const& a2, ApplyContext& ac) { - Keylet const offerKey = keylet::offer(a2.id(), 10); + Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10)); auto sleOffer = std::make_shared(offerKey); sleOffer->setAccountID(sfAccount, a2); sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10)); @@ -2106,7 +2115,7 @@ class Invariants_test : public beast::unit_test::Suite fixEnabled ? std::vector{{"hybrid offer is malformed"}} : std::vector{}, [&pd1](Account const& a1, Account const& a2, ApplyContext& ac) { - Keylet const offerKey = keylet::offer(a2.id(), 10); + Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10)); auto sleOffer = std::make_shared(offerKey); sleOffer->setAccountID(sfAccount, a2); sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10)); @@ -2143,7 +2152,7 @@ class Invariants_test : public beast::unit_test::Suite a2, {{"hybrid offer is malformed"}}, [&pd1](Account const& a1, Account const& a2, ApplyContext& ac) { - Keylet const offerKey = keylet::offer(a2.id(), 10); + Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10)); auto sleOffer = std::make_shared(offerKey); sleOffer->setAccountID(sfAccount, a2); sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10)); @@ -2176,7 +2185,7 @@ class Invariants_test : public beast::unit_test::Suite a2, {{"transaction consumed wrong domains"}}, [&pd1](Account const& a1, Account const& a2, ApplyContext& ac) { - Keylet const offerKey = keylet::offer(a2.id(), 10); + Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10)); auto sleOffer = std::make_shared(offerKey); sleOffer->setAccountID(sfAccount, a2); sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10)); @@ -2213,7 +2222,7 @@ class Invariants_test : public beast::unit_test::Suite a2, {{"domain transaction affected regular offers"}}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - Keylet const offerKey = keylet::offer(a2.id(), 10); + Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10)); auto sleOffer = std::make_shared(offerKey); sleOffer->setAccountID(sfAccount, a2); sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10)); @@ -2414,7 +2423,7 @@ class Invariants_test : public beast::unit_test::Suite // Create Loan Broker using namespace loan_broker; - auto const loanBrokerKeylet = keylet::loanBroker(a.id(), env.seq(a)); + auto const loanBrokerKeylet = keylet::loanBroker(a.id(), SeqProxy::rawSequence(env.seq(a))); // Create a Loan Broker with all default values. env(set(a, vaultID), Fee(kIncrement)); @@ -2918,7 +2927,7 @@ class Invariants_test : public beast::unit_test::Suite doInvariantCheck( {"vault deletion succeeded without deleting a vault"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); auto sleVault = ac.view().peek(keylet); if (!sleVault) return false; @@ -2939,7 +2948,7 @@ class Invariants_test : public beast::unit_test::Suite {"vault updated by a wrong transaction type", "deleted Vault without deleting its pseudo-account"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); auto sleVault = ac.view().peek(keylet); if (!sleVault) return false; @@ -2959,7 +2968,7 @@ class Invariants_test : public beast::unit_test::Suite doInvariantCheck( {"vault updated by a wrong transaction type"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); auto sleVault = ac.view().peek(keylet); if (!sleVault) return false; @@ -2980,7 +2989,7 @@ class Invariants_test : public beast::unit_test::Suite {"vault updated by a wrong transaction type"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { auto const sequence = ac.view().seq(); - auto const vaultKeylet = keylet::vault(a1.id(), sequence); + auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(sequence)); auto sleVault = std::make_shared(vaultKeylet); auto const vaultPage = ac.view().dirInsert( keylet::ownerDir(a1.id()), sleVault->key(), describeOwnerDir(a1.id())); @@ -2997,7 +3006,7 @@ class Invariants_test : public beast::unit_test::Suite {"vault deleted by a wrong transaction type", "deleted Vault without deleting its pseudo-account"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); auto sleVault = ac.view().peek(keylet); if (!sleVault) return false; @@ -3019,14 +3028,16 @@ class Invariants_test : public beast::unit_test::Suite "deleted Vault without deleting its pseudo-account"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = + keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); auto sleVault = ac.view().peek(keylet); if (!sleVault) return false; ac.view().erase(sleVault); } { - auto const keylet = keylet::vault(a2.id(), ac.view().seq()); + auto const keylet = + keylet::vault(a2.id(), SeqProxy::rawSequence(ac.view().seq())); auto sleVault = ac.view().peek(keylet); if (!sleVault) return false; @@ -3055,7 +3066,7 @@ class Invariants_test : public beast::unit_test::Suite [&](Account const& a1, Account const& a2, ApplyContext& ac) { auto const sequence = ac.view().seq(); auto const insertVault = [&](Account const a) { - auto const vaultKeylet = keylet::vault(a.id(), sequence); + auto const vaultKeylet = keylet::vault(a.id(), SeqProxy::rawSequence(sequence)); auto sleVault = std::make_shared(vaultKeylet); auto const vaultPage = ac.view().dirInsert( keylet::ownerDir(a.id()), sleVault->key(), describeOwnerDir(a.id())); @@ -3075,7 +3086,7 @@ class Invariants_test : public beast::unit_test::Suite {"deleted vault must also delete shares", "deleted Vault without deleting its pseudo-account"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); auto sleVault = ac.view().peek(keylet); if (!sleVault) return false; @@ -3097,7 +3108,7 @@ class Invariants_test : public beast::unit_test::Suite "deleted vault must have no assets outstanding", "deleted vault must have no assets available"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); auto sleVault = ac.view().peek(keylet); if (!sleVault) return false; @@ -3122,7 +3133,7 @@ class Invariants_test : public beast::unit_test::Suite doInvariantCheck( {"vault operation succeeded without modifying a vault"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); auto sleVault = ac.view().peek(keylet); if (!sleVault) return false; @@ -3209,7 +3220,7 @@ class Invariants_test : public beast::unit_test::Suite doInvariantCheck( {"updated vault must have shares"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); auto sleVault = ac.view().peek(keylet); if (!sleVault) return false; @@ -3236,7 +3247,7 @@ class Invariants_test : public beast::unit_test::Suite {"vault operation succeeded without updating shares", "assets available must not be greater than assets outstanding"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); auto sleVault = ac.view().peek(keylet); if (!sleVault) return false; @@ -3264,7 +3275,7 @@ class Invariants_test : public beast::unit_test::Suite "assets available must not be greater than assets outstanding", "assets outstanding must not be negative"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); auto sleVault = ac.view().peek(keylet); if (!sleVault) return false; @@ -3296,7 +3307,7 @@ class Invariants_test : public beast::unit_test::Suite doInvariantCheck( {"violation of vault immutable data"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); auto sleVault = ac.view().peek(keylet); if (!sleVault) return false; @@ -3312,7 +3323,7 @@ class Invariants_test : public beast::unit_test::Suite doInvariantCheck( {"violation of vault immutable data"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); auto sleVault = ac.view().peek(keylet); if (!sleVault) return false; @@ -3328,7 +3339,7 @@ class Invariants_test : public beast::unit_test::Suite doInvariantCheck( {"violation of vault immutable data"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); auto sleVault = ac.view().peek(keylet); if (!sleVault) return false; @@ -3345,7 +3356,7 @@ class Invariants_test : public beast::unit_test::Suite {"vault transaction must not change loss unrealized", "set must not change assets outstanding"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) { sample.lossUnrealized = 13; sample.assetsTotal = 20; @@ -3362,7 +3373,7 @@ class Invariants_test : public beast::unit_test::Suite "between assets outstanding and available", "vault transaction must not change loss unrealized"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); return kAdjust(ac.view(), keylet, kArgs(a2.id(), 100, [&](Adjustments& sample) { sample.lossUnrealized = 13; })); @@ -3381,7 +3392,7 @@ class Invariants_test : public beast::unit_test::Suite doInvariantCheck( {"loss unrealized must not be negative"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) { sample.lossUnrealized = -1; })); @@ -3398,7 +3409,7 @@ class Invariants_test : public beast::unit_test::Suite makeEnv(defaultAmendments() - fixCleanup3_4_0), {}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) { sample.lossUnrealized = -1; })); @@ -3412,7 +3423,7 @@ class Invariants_test : public beast::unit_test::Suite doInvariantCheck( {"set assets outstanding must not exceed assets maximum"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) { sample.assetsMaximum = 1; })); @@ -3426,7 +3437,7 @@ class Invariants_test : public beast::unit_test::Suite doInvariantCheck( {"assets maximum must not be negative"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) { sample.assetsMaximum = -1; })); @@ -3442,7 +3453,7 @@ class Invariants_test : public beast::unit_test::Suite "updated zero sized vault must have no assets outstanding", "updated zero sized vault must have no assets available"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); auto sleVault = ac.view().peek(keylet); if (!sleVault) return false; @@ -3463,7 +3474,7 @@ class Invariants_test : public beast::unit_test::Suite doInvariantCheck( {"updated shares must not exceed maximum"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); auto sleVault = ac.view().peek(keylet); if (!sleVault) return false; @@ -3484,7 +3495,7 @@ class Invariants_test : public beast::unit_test::Suite doInvariantCheck( {"updated shares must not exceed maximum"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [](Adjustments&) {})); auto sleVault = ac.view().peek(keylet); @@ -3511,7 +3522,7 @@ class Invariants_test : public beast::unit_test::Suite "create operation must not have updated a vault", }, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); auto sleVault = ac.view().peek(keylet); if (!sleVault) return false; @@ -3537,7 +3548,7 @@ class Invariants_test : public beast::unit_test::Suite "create operation must not have updated a vault", }, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); auto sleVault = ac.view().peek(keylet); if (!sleVault) return false; @@ -3564,7 +3575,7 @@ class Invariants_test : public beast::unit_test::Suite "create operation must not have updated a vault", }, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); auto sleVault = ac.view().peek(keylet); if (!sleVault) return false; @@ -3588,7 +3599,7 @@ class Invariants_test : public beast::unit_test::Suite "create operation must not have updated a vault", }, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); auto sleVault = ac.view().peek(keylet); if (!sleVault) return false; @@ -3616,7 +3627,7 @@ class Invariants_test : public beast::unit_test::Suite "create operation must not have updated a vault", }, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); auto sleVault = ac.view().peek(keylet); if (!sleVault) return false; @@ -3640,7 +3651,7 @@ class Invariants_test : public beast::unit_test::Suite "shares issuer must be a pseudo-account", "shares issuer pseudo-account must point back to the vault"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); auto sleVault = ac.view().peek(keylet); if (!sleVault) return false; @@ -3669,7 +3680,7 @@ class Invariants_test : public beast::unit_test::Suite // the invariants holding. Except one: it is created by the // wrong transaction type. auto const sequence = ac.view().seq(); - auto const vaultKeylet = keylet::vault(a1.id(), sequence); + auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(sequence)); auto sleVault = std::make_shared(vaultKeylet); auto const vaultPage = ac.view().dirInsert( keylet::ownerDir(a1.id()), sleVault->key(), describeOwnerDir(a1.id())); @@ -3725,7 +3736,7 @@ class Invariants_test : public beast::unit_test::Suite "shares issuer pseudo-account must point back to the vault"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { auto const sequence = ac.view().seq(); - auto const vaultKeylet = keylet::vault(a1.id(), sequence); + auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(sequence)); auto sleVault = std::make_shared(vaultKeylet); auto const vaultPage = ac.view().dirInsert( keylet::ownerDir(a1.id()), sleVault->key(), describeOwnerDir(a1.id())); @@ -3784,7 +3795,7 @@ class Invariants_test : public beast::unit_test::Suite {"shares issuer and vault pseudo-account must be the same", "shares issuer must exist"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { auto const sequence = ac.view().seq(); - auto const vaultKeylet = keylet::vault(a1.id(), sequence); + auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(sequence)); auto sleVault = std::make_shared(vaultKeylet); auto const vaultPage = ac.view().dirInsert( keylet::ownerDir(a1.id()), sleVault->key(), describeOwnerDir(a1.id())); @@ -3825,7 +3836,7 @@ class Invariants_test : public beast::unit_test::Suite doInvariantCheck( {"deposit must change vault balance"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [](Adjustments& sample) { sample.vaultAssets.reset(); })); @@ -3838,7 +3849,7 @@ class Invariants_test : public beast::unit_test::Suite doInvariantCheck( {"deposit assets outstanding must not exceed assets maximum"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); return kAdjust(ac.view(), keylet, kArgs(a2.id(), 200, [&](Adjustments& sample) { sample.assetsMaximum = 1; })); @@ -3857,7 +3868,7 @@ class Invariants_test : public beast::unit_test::Suite doInvariantCheck( {"deposit must increase vault balance", "deposit must change depositor balance"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); // Move 10 drops to A4 to enforce total XRP balance auto sleA4 = ac.view().peek(keylet::account(a4.id())); @@ -3887,7 +3898,7 @@ class Invariants_test : public beast::unit_test::Suite "deposit and assets outstanding must add up", "deposit and assets available must add up"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); // Move 10 drops from A2 to A3 to enforce total XRP balance auto sleA3 = ac.view().peek(keylet::account(a3.id())); @@ -3910,7 +3921,7 @@ class Invariants_test : public beast::unit_test::Suite doInvariantCheck( {"deposit must change depositor balance"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); // Move 10 drops from A3 to vault to enforce total XRP balance auto sleA3 = ac.view().peek(keylet::account(a3.id())); @@ -3932,7 +3943,7 @@ class Invariants_test : public beast::unit_test::Suite doInvariantCheck( {"deposit must change depositor shares"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) { sample.accountShares.reset(); })); @@ -3946,7 +3957,7 @@ class Invariants_test : public beast::unit_test::Suite doInvariantCheck( {"deposit must change vault shares"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [](Adjustments& sample) { sample.sharesTotal = 0; @@ -3964,7 +3975,7 @@ class Invariants_test : public beast::unit_test::Suite "deposit must not change vault balance by more than deposited " "amount"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) { sample.accountShares->amount = -5; sample.sharesTotal = -10; @@ -3983,7 +3994,7 @@ class Invariants_test : public beast::unit_test::Suite (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] - 2000; ac.view().update(sleA3); - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) { sample.assetsTotal = 11; })); @@ -4004,7 +4015,7 @@ class Invariants_test : public beast::unit_test::Suite {"deposit and assets outstanding must add up", "deposit and assets available must add up"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) { sample.assetsTotal = 7; sample.assetsAvailable = 7; @@ -4020,7 +4031,7 @@ class Invariants_test : public beast::unit_test::Suite doInvariantCheck( {"withdrawal must change vault balance"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [](Adjustments& sample) { sample.vaultAssets.reset(); })); @@ -4037,7 +4048,7 @@ class Invariants_test : public beast::unit_test::Suite doInvariantCheck( {"withdrawal must change one destination balance"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); // Move 10 drops to A4 to enforce total XRP balance auto sleA4 = ac.view().peek(keylet::account(a4.id())); @@ -4071,7 +4082,7 @@ class Invariants_test : public beast::unit_test::Suite "withdrawal and assets available must add up", }, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); // Move 10 drops from A2 to A3 to enforce total XRP balance auto sleA3 = ac.view().peek(keylet::account(a3.id())); @@ -4094,7 +4105,7 @@ class Invariants_test : public beast::unit_test::Suite doInvariantCheck( {"withdrawal must change one destination balance"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); if (!kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) { *sample.vaultAssets -= 5; }))) @@ -4115,7 +4126,7 @@ class Invariants_test : public beast::unit_test::Suite doInvariantCheck( {"withdrawal must change depositor shares"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) { sample.accountShares.reset(); })); @@ -4129,7 +4140,7 @@ class Invariants_test : public beast::unit_test::Suite doInvariantCheck( {"withdrawal must change vault shares"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [](Adjustments& sample) { sample.sharesTotal = 0; })); @@ -4145,7 +4156,7 @@ class Invariants_test : public beast::unit_test::Suite "withdrawal must change depositor and vault shares by equal " "amount"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) { sample.accountShares->amount = 5; sample.sharesTotal = 10; @@ -4161,7 +4172,7 @@ class Invariants_test : public beast::unit_test::Suite {"withdrawal and assets outstanding must add up", "withdrawal and assets available must add up"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) { sample.assetsTotal = -15; sample.assetsAvailable = -15; @@ -4180,7 +4191,7 @@ class Invariants_test : public beast::unit_test::Suite (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] - 2000; ac.view().update(sleA3); - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) { sample.assetsTotal = -7; })); @@ -4248,7 +4259,8 @@ class Invariants_test : public beast::unit_test::Suite "withdrawal must change depositor and vault shares by equal " "amount"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq() - 2); + auto const keylet = + keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2)); return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) { sample.accountShares->amount = 5; })); @@ -4263,7 +4275,8 @@ class Invariants_test : public beast::unit_test::Suite doInvariantCheck( {"clawback must change vault balance"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq() - 2); + auto const keylet = + keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2)); return kAdjust(ac.view(), keylet, kArgs(a2.id(), -1, [&](Adjustments& sample) { sample.vaultAssets.reset(); })); @@ -4277,7 +4290,7 @@ class Invariants_test : public beast::unit_test::Suite doInvariantCheck( {"clawback may only be performed by the asset issuer"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq()); + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) {})); }, XRPAmount{}, @@ -4289,7 +4302,8 @@ class Invariants_test : public beast::unit_test::Suite doInvariantCheck( {"clawback may only be performed by the asset issuer"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq() - 2); + auto const keylet = + keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2)); return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) {})); }, XRPAmount{}, @@ -4302,7 +4316,8 @@ class Invariants_test : public beast::unit_test::Suite "clawback must decrease holder shares", "clawback must change vault shares"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq() - 2); + auto const keylet = + keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2)); return kAdjust(ac.view(), keylet, kArgs(a4.id(), 10, [&](Adjustments& sample) { sample.sharesTotal = 0; })); @@ -4320,7 +4335,8 @@ class Invariants_test : public beast::unit_test::Suite doInvariantCheck( {"clawback must change holder shares"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq() - 2); + auto const keylet = + keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2)); return kAdjust(ac.view(), keylet, kArgs(a4.id(), -10, [&](Adjustments& sample) { sample.accountShares.reset(); })); @@ -4340,7 +4356,8 @@ class Invariants_test : public beast::unit_test::Suite "clawback and assets outstanding must add up", "clawback and assets available must add up"}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), ac.view().seq() - 2); + auto const keylet = + keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2)); return kAdjust(ac.view(), keylet, kArgs(a4.id(), -10, [&](Adjustments& sample) { sample.accountShares->amount = -8; sample.assetsTotal = -7; @@ -4398,7 +4415,8 @@ class Invariants_test : public beast::unit_test::Suite if (!sle) return false; - auto sleNew = std::make_shared(keylet::check(a1.id(), (*sle)[sfSequence])); + auto sleNew = std::make_shared( + keylet::check(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence]))); sleNew->setAccountID(sfAccount, a1.id()); sleNew->setAccountID(sfDestination, a2.id()); sleNew->setFieldAmount(sfSendMax, nonCanonicalMPTAmount(sfSendMax)); @@ -4413,7 +4431,8 @@ class Invariants_test : public beast::unit_test::Suite if (!sle) return false; - auto sleNew = std::make_shared(keylet::check(a1.id(), (*sle)[sfSequence])); + auto sleNew = std::make_shared( + keylet::check(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence]))); sleNew->setAccountID(sfAccount, a1.id()); sleNew->setAccountID(sfDestination, a2.id()); sleNew->setFieldAmount(sfSendMax, negativeMPTAmount(sfSendMax)); @@ -5797,7 +5816,7 @@ class Invariants_test : public beast::unit_test::Suite STTx{ttACCOUNT_SET, [](STObject&) {}}, {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, [&checkID](Account const& a1, Account const& a2, Env& env) { - checkID = keylet::check(a1.id(), env.seq(a1)).key; + checkID = keylet::check(a1.id(), SeqProxy::rawSequence(env.seq(a1))).key; env(check::create(a1, a2, XRP(1))); return true; }); @@ -5923,7 +5942,7 @@ class Invariants_test : public beast::unit_test::Suite OpenView ov{*env.current()}; - auto const vaultKeylet = keylet::vault(a1.id(), ov.seq()); + auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ov.seq())); auto sleVault = std::make_shared(vaultKeylet); sleVault->makeFieldAbsent(sfAccount); ov.rawInsert(sleVault); diff --git a/src/test/app/LPTokenTransfer_test.cpp b/src/test/app/LPTokenTransfer_test.cpp index 2947b3a3ce..e30e37ed98 100644 --- a/src/test/app/LPTokenTransfer_test.cpp +++ b/src/test/app/LPTokenTransfer_test.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include @@ -310,7 +311,7 @@ class LPTokenTransfer_test : public jtx::AMMTest // carol_ can always create a check with lptoken that has frozen // token - uint256 const carolChkId{keylet::check(carol_, env.seq(carol_)).key}; + uint256 const carolChkId{keylet::check(carol_, SeqProxy::rawSequence(env.seq(carol_))).key}; env(check::create(carol_, bob_, STAmount{lpIssue, 10})); env.close(); @@ -327,7 +328,7 @@ class LPTokenTransfer_test : public jtx::AMMTest env.close(); // bob_ creates a check - uint256 const bobChkId{keylet::check(bob_, env.seq(bob_)).key}; + uint256 const bobChkId{keylet::check(bob_, SeqProxy::rawSequence(env.seq(bob_))).key}; env(check::create(bob_, carol_, STAmount{lpIssue, 10})); env.close(); @@ -359,7 +360,8 @@ class LPTokenTransfer_test : public jtx::AMMTest env.close(); // bob_ creates a sell offer for lptoken - uint256 const sellOfferIndex = keylet::nftokenOffer(bob_, env.seq(bob_)).key; + uint256 const sellOfferIndex = + keylet::nftokenOffer(bob_, SeqProxy::rawSequence(env.seq(bob_))).key; env(token::createOffer(bob_, nftID, STAmount{lpIssue, 10}), Txflags(tfSellNFToken)); env.close(); @@ -420,7 +422,8 @@ class LPTokenTransfer_test : public jtx::AMMTest env.close(); // bob_ creates a buy offer with lptoken despite bob_'s USD is frozen - uint256 const buyOfferIndex = keylet::nftokenOffer(bob_, env.seq(bob_)).key; + uint256 const buyOfferIndex = + keylet::nftokenOffer(bob_, SeqProxy::rawSequence(env.seq(bob_))).key; env(token::createOffer(bob_, nftID, STAmount{lpIssue, 10}), token::Owner(carol_)); env.close(); diff --git a/src/test/app/MPToken_test.cpp b/src/test/app/MPToken_test.cpp index c9adce0305..b392dca758 100644 --- a/src/test/app/MPToken_test.cpp +++ b/src/test/app/MPToken_test.cpp @@ -53,6 +53,7 @@ #include #include #include +#include #include #include #include @@ -2385,7 +2386,7 @@ class MPToken_test : public beast::unit_test::Suite env.submit(tx); env.close(); - auto const checkKeylet = keylet::check(alice.id(), checkSeq); + auto const checkKeylet = keylet::check(alice.id(), SeqProxy::rawSequence(checkSeq)); auto const sleCheck = env.le(checkKeylet); BEAST_EXPECT((sleCheck != nullptr) == !bad.negative); if (sleCheck && !bad.negative) @@ -2413,7 +2414,7 @@ class MPToken_test : public beast::unit_test::Suite env.submit(tx); env.close(); - auto const checkKeylet = keylet::check(alice.id(), checkSeq); + auto const checkKeylet = keylet::check(alice.id(), SeqProxy::rawSequence(checkSeq)); BEAST_EXPECT((env.le(checkKeylet) != nullptr) == !bad.negative); if (!bad.negative) { @@ -2441,7 +2442,7 @@ class MPToken_test : public beast::unit_test::Suite env.submit(tx); env.close(); - auto const checkKeylet = keylet::check(alice.id(), checkSeq); + auto const checkKeylet = keylet::check(alice.id(), SeqProxy::rawSequence(checkSeq)); BEAST_EXPECT((env.le(checkKeylet) != nullptr) == !bad.negative); if (!bad.negative) { @@ -2472,7 +2473,7 @@ class MPToken_test : public beast::unit_test::Suite env.submit(tx); env.close(); - auto const checkKeylet = keylet::check(alice.id(), checkSeq); + auto const checkKeylet = keylet::check(alice.id(), SeqProxy::rawSequence(checkSeq)); BEAST_EXPECT((env.le(checkKeylet) != nullptr) == !bad.negative); if (!bad.negative) { @@ -2501,7 +2502,7 @@ class MPToken_test : public beast::unit_test::Suite env.jt( check::cash( bob, - keylet::check(alice.id(), checkSeq).key, + keylet::check(alice.id(), SeqProxy::rawSequence(checkSeq)).key, STAmount{issue, std::uint64_t{1}})), sfAmount, badCashAmount, @@ -2510,7 +2511,8 @@ class MPToken_test : public beast::unit_test::Suite tx.ter = bad.holderSourcePreFixTer; env.submit(tx); env.close(); - BEAST_EXPECT(env.le(keylet::check(alice.id(), checkSeq)) != nullptr); + BEAST_EXPECT( + env.le(keylet::check(alice.id(), SeqProxy::rawSequence(checkSeq))) != nullptr); BEAST_EXPECT( (env.balance(alice, issue).value() == STAmount{MPTAmount{10'000}, issue})); BEAST_EXPECT( @@ -2534,7 +2536,7 @@ class MPToken_test : public beast::unit_test::Suite env.jt( check::cash( bob, - keylet::check(alice.id(), checkSeq).key, + keylet::check(alice.id(), SeqProxy::rawSequence(checkSeq)).key, STAmount{issue, std::uint64_t{1}})), sfAmount, badCashAmount, @@ -2562,7 +2564,9 @@ class MPToken_test : public beast::unit_test::Suite tx.ter = bad.negative ? TER{temBAD_AMOUNT} : TER{tecINSUFFICIENT_FUNDS}; env.submit(tx); env.close(); - BEAST_EXPECT(env.le(keylet::escrow(alice.id(), escrowSeq)) == nullptr); + BEAST_EXPECT( + env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(escrowSeq))) == + nullptr); } { Env env{*this, withFix}; @@ -2963,7 +2967,7 @@ class MPToken_test : public beast::unit_test::Suite auto const issue = makeIssue(env); auto const badAmount = badMPTAmount(issue, bad); - uint256 const fakeVaultId = keylet::vault(gw.id(), 1).key; + uint256 const fakeVaultId = keylet::vault(gw.id(), SeqProxy::rawSequence(1)).key; auto tx = withNonCanonicalMPTAmount( env.jt( Vault::clawback( @@ -6561,7 +6565,7 @@ class MPToken_test : public beast::unit_test::Suite auto const mpt = mptTester["MPT"]; mptTester.authorize({.account = alice}); - uint256 const checkId{keylet::check(gw, env.seq(gw)).key}; + uint256 const checkId{keylet::check(gw, SeqProxy::rawSequence(env.seq(gw))).key}; env(check::create(gw, alice, mpt(100)), Ter(temDISABLED)); env.close(); @@ -6582,7 +6586,7 @@ class MPToken_test : public beast::unit_test::Suite mptTester.authorize({.account = alice}); mptTester.pay(gw, alice, 50); - uint256 const checkId{keylet::check(alice, env.seq(alice)).key}; + uint256 const checkId{keylet::check(alice, SeqProxy::rawSequence(env.seq(alice))).key}; // can create env(check::create(alice, carol, mpt(100))); @@ -6612,7 +6616,7 @@ class MPToken_test : public beast::unit_test::Suite .flags = tfMPTCanTransfer | tfMPTCanTrade}); auto const mpt = mptTester["MPT"]; - uint256 const checkId{keylet::check(gw, env.seq(gw)).key}; + uint256 const checkId{keylet::check(gw, SeqProxy::rawSequence(env.seq(gw))).key}; // can create env(check::create(gw, alice, mpt(200))); @@ -6766,7 +6770,7 @@ class MPToken_test : public beast::unit_test::Suite {.env = env, .issuer = gw, .holders = {alice, carol}, .flags = tfMPTCanTrade}); // src is issuer - uint256 checkId{keylet::check(gw, env.seq(gw)).key}; + uint256 checkId{keylet::check(gw, SeqProxy::rawSequence(env.seq(gw))).key}; // can create env(check::create(gw, alice, mpt(100))); @@ -6780,7 +6784,7 @@ class MPToken_test : public beast::unit_test::Suite BEAST_EXPECT(env.balance(gw, mpt) == mpt(-100)); // dst is issuer - checkId = keylet::check(alice, env.seq(alice)).key; + checkId = keylet::check(alice, SeqProxy::rawSequence(env.seq(alice))).key; // can create env(check::create(alice, gw, mpt(100))); @@ -6794,13 +6798,13 @@ class MPToken_test : public beast::unit_test::Suite BEAST_EXPECT(env.balance(gw, mpt) == mpt(0)); // neither src nor dst is issuer, can't create - checkId = keylet::check(alice, env.seq(alice)).key; + checkId = keylet::check(alice, SeqProxy::rawSequence(env.seq(alice))).key; env(check::create(alice, carol, mpt(100)), Ter(tecNO_AUTH)); env.close(); // can create now mpt.set({.account = gw, .flags = tfMPTSetCanTransfer}); - checkId = keylet::check(alice, env.seq(alice)).key; + checkId = keylet::check(alice, SeqProxy::rawSequence(env.seq(alice))).key; env(check::create(alice, carol, mpt(100))); env.close(); env(pay(gw, alice, mpt(10))); @@ -6824,7 +6828,7 @@ class MPToken_test : public beast::unit_test::Suite .pay = 10, .flags = tfMPTCanTransfer}); - uint256 const checkId{keylet::check(alice, env.seq(alice)).key}; + uint256 const checkId{keylet::check(alice, SeqProxy::rawSequence(env.seq(alice))).key}; // can create env(check::create(alice, carol, mpt(100))); @@ -6898,7 +6902,7 @@ class MPToken_test : public beast::unit_test::Suite env.fund(XRP(1'000), alice, carol); // src is issuer - uint256 const checkId{keylet::check(alice, env.seq(alice)).key}; + uint256 const checkId{keylet::check(alice, SeqProxy::rawSequence(env.seq(alice))).key}; // can create env(check::create(alice, carol, mpt(100))); @@ -6926,7 +6930,7 @@ class MPToken_test : public beast::unit_test::Suite auto const mpt = mptTester["MPT"]; mptTester.authorize({.account = alice}); - uint256 const checkId{keylet::check(gw, env.seq(gw)).key}; + uint256 const checkId{keylet::check(gw, SeqProxy::rawSequence(env.seq(gw))).key}; env(check::create(gw, alice, mpt(100))); env.close(); diff --git a/src/test/app/NFTokenAuth_test.cpp b/src/test/app/NFTokenAuth_test.cpp index 66716a13b7..e82a47a5d7 100644 --- a/src/test/app/NFTokenAuth_test.cpp +++ b/src/test/app/NFTokenAuth_test.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include @@ -43,7 +44,8 @@ class NFTokenAuth_test : public beast::unit_test::Suite env(token::mint(account, 0), token::XferFee(xfee), Txflags(tfTransferable)); env.close(); - auto const sellIdx = keylet::nftokenOffer(account, env.seq(account)).key; + auto const sellIdx = + keylet::nftokenOffer(account, SeqProxy::rawSequence(env.seq(account))).key; env(token::createOffer(account, nftID, currency), Txflags(tfSellNFToken)); env.close(); @@ -74,7 +76,7 @@ public: env(pay(g1, a1, usd(1000))); auto const [nftID, _] = mintAndOfferNFT(env, a2, drops(1)); - auto const buyIdx = keylet::nftokenOffer(a1, env.seq(a1)).key; + auto const buyIdx = keylet::nftokenOffer(a1, SeqProxy::rawSequence(env.seq(a1))).key; // It should be possible to create a buy offer even if NFT owner is not // authorized @@ -179,7 +181,7 @@ public: env(pay(g1, a2, usd(10))); env.close(); - auto const buyIdx = keylet::nftokenOffer(a1, env.seq(a1)).key; + auto const buyIdx = keylet::nftokenOffer(a1, SeqProxy::rawSequence(env.seq(a1))).key; env(token::createOffer(a1, nftID, usd(10)), token::Owner(a2)); env.close(); @@ -244,7 +246,7 @@ public: // Authorizing trustline to make an offer creation possible env(trust(g1, usd(0), a2, tfSetfAuth)); env.close(); - auto const sellIdx = keylet::nftokenOffer(a2, env.seq(a2)).key; + auto const sellIdx = keylet::nftokenOffer(a2, SeqProxy::rawSequence(env.seq(a2))).key; env(token::createOffer(a2, nftID, usd(10)), Txflags(tfSellNFToken)); env.close(); // @@ -268,7 +270,7 @@ public: } else { - auto const sellIdx = keylet::nftokenOffer(a2, env.seq(a2)).key; + auto const sellIdx = keylet::nftokenOffer(a2, SeqProxy::rawSequence(env.seq(a2))).key; // Old behavior: sell offer can be created without authorization env(token::createOffer(a2, nftID, usd(10)), Txflags(tfSellNFToken)); @@ -353,7 +355,7 @@ public: env.close(); auto const [nftID, sellIdx] = mintAndOfferNFT(env, a2, usd(10)); - auto const buyIdx = keylet::nftokenOffer(a1, env.seq(a1)).key; + auto const buyIdx = keylet::nftokenOffer(a1, SeqProxy::rawSequence(env.seq(a1))).key; env(token::createOffer(a1, nftID, usd(11)), token::Owner(a2)); env.close(); @@ -422,7 +424,7 @@ public: env.close(); auto const [nftID, sellIdx] = mintAndOfferNFT(env, a2, usd(10)); - auto const buyIdx = keylet::nftokenOffer(a1, env.seq(a1)).key; + auto const buyIdx = keylet::nftokenOffer(a1, SeqProxy::rawSequence(env.seq(a1))).key; env(token::createOffer(a1, nftID, usd(11)), token::Owner(a2)); env.close(); @@ -483,7 +485,7 @@ public: env.close(); auto const [nftID, sellIdx] = mintAndOfferNFT(env, a2, usd(10)); - auto const buyIdx = keylet::nftokenOffer(a1, env.seq(a1)).key; + auto const buyIdx = keylet::nftokenOffer(a1, SeqProxy::rawSequence(env.seq(a1))).key; env(token::createOffer(a1, nftID, usd(11)), token::Owner(a2)); env.close(); @@ -559,7 +561,7 @@ public: auto const [nftID, minterSellIdx] = mintAndOfferNFT(env, minter, drops(1), 1); env(token::acceptSellOffer(a1, minterSellIdx)); - uint256 const sellIdx = keylet::nftokenOffer(a1, env.seq(a1)).key; + uint256 const sellIdx = keylet::nftokenOffer(a1, SeqProxy::rawSequence(env.seq(a1))).key; env(token::createOffer(a1, nftID, usd(100)), Txflags(tfSellNFToken)); if (features[fixEnforceNFTokenTrustlineV2]) diff --git a/src/test/app/NFTokenBurn_test.cpp b/src/test/app/NFTokenBurn_test.cpp index 140fe2de15..52565432a9 100644 --- a/src/test/app/NFTokenBurn_test.cpp +++ b/src/test/app/NFTokenBurn_test.cpp @@ -25,6 +25,7 @@ #include #include #include +#include #include #include #include @@ -76,7 +77,8 @@ class NFTokenBurn_test : public beast::unit_test::Suite for (uint32_t i = 0; i < tokenCancelCount; ++i) { // Create sell offer - offerIndexes.push_back(keylet::nftokenOffer(owner, env.seq(owner)).key); + offerIndexes.push_back( + keylet::nftokenOffer(owner, SeqProxy::rawSequence(env.seq(owner))).key); env(token::createOffer(owner, nftokenID, drops(1)), Txflags(tfSellNFToken)); env.close(); } @@ -237,7 +239,8 @@ class NFTokenBurn_test : public beast::unit_test::Suite // We do the same work on alice and minter, so make a lambda. auto xferNFT = [&env, &becky](AcctStat& acct, auto& iter) { uint256 const offerIndex = - keylet::nftokenOffer(acct.acct, env.seq(acct.acct)).key; + keylet::nftokenOffer(acct.acct, SeqProxy::rawSequence(env.seq(acct.acct))) + .key; env(token::createOffer(acct, *iter, XRP(0)), Txflags(tfSellNFToken)); env.close(); env(token::acceptSellOffer(becky, offerIndex)); @@ -871,7 +874,8 @@ class NFTokenBurn_test : public beast::unit_test::Suite } // Becky creates a buy offer - uint256 const beckyOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key; + uint256 const beckyOfferIndex = + keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key; env(token::createOffer(becky, nftokenID, drops(1)), token::Owner(alice)); env.close(); @@ -1046,7 +1050,8 @@ class NFTokenBurn_test : public beast::unit_test::Suite env.close(); // Minter creates an offer for the NFToken. - uint256 const minterOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key; + uint256 const minterOfferIndex = + keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key; env(token::createOffer(minter, nfts.back(), XRP(0)), Txflags(tfSellNFToken)); env.close(); @@ -1117,7 +1122,8 @@ class NFTokenBurn_test : public beast::unit_test::Suite nfts.pop_back(); // alice creates an offer for the NFToken. - uint256 const aliceOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key; + uint256 const aliceOfferIndex = + keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key; env(token::createOffer(alice, last32NFTs.back(), XRP(0)), Txflags(tfSellNFToken)); env.close(); @@ -1151,7 +1157,8 @@ class NFTokenBurn_test : public beast::unit_test::Suite for (uint256 const nftID : last32NFTs) { // minter creates an offer for the NFToken. - uint256 const minterOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key; + uint256 const minterOfferIndex = + keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key; env(token::createOffer(minter, nftID, XRP(0)), Txflags(tfSellNFToken)); env.close(); diff --git a/src/test/app/NFTokenDir_test.cpp b/src/test/app/NFTokenDir_test.cpp index 7dd0b14fe5..7770741a36 100644 --- a/src/test/app/NFTokenDir_test.cpp +++ b/src/test/app/NFTokenDir_test.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -142,7 +143,8 @@ class NFTokenDir_test : public beast::unit_test::Suite std::vector offers; for (uint256 const& nftID : nftIDs) { - offers.emplace_back(keylet::nftokenOffer(issuer, env.seq(issuer)).key); + offers.emplace_back( + keylet::nftokenOffer(issuer, SeqProxy::rawSequence(env.seq(issuer))).key); env(token::createOffer(issuer, nftID, XRP(0)), Txflags(tfSellNFToken)); env.close(); } @@ -214,7 +216,8 @@ class NFTokenDir_test : public beast::unit_test::Suite env.close(); // Create an offer to give the NFT to buyer for free. - offers.emplace_back(keylet::nftokenOffer(account, env.seq(account)).key); + offers.emplace_back( + keylet::nftokenOffer(account, SeqProxy::rawSequence(env.seq(account))).key); env(token::createOffer(account, nftID, XRP(0)), token::Destination(buyer), Txflags(tfSellNFToken)); @@ -237,7 +240,8 @@ class NFTokenDir_test : public beast::unit_test::Suite // generates a non-tesSUCCESS error code. for (uint256 const& nftID : nftIDs) { - uint256 const offerID = keylet::nftokenOffer(buyer, env.seq(buyer)).key; + uint256 const offerID = + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key; env(token::createOffer(buyer, nftID, XRP(100)), Txflags(tfSellNFToken)); env.close(); @@ -418,7 +422,8 @@ class NFTokenDir_test : public beast::unit_test::Suite env.close(); // Create an offer to give the NFT to buyer for free. - offers.emplace_back(keylet::nftokenOffer(account, env.seq(account)).key); + offers.emplace_back( + keylet::nftokenOffer(account, SeqProxy::rawSequence(env.seq(account))).key); env(token::createOffer(account, nftID, XRP(0)), token::Destination(buyer), Txflags(tfSellNFToken)); @@ -445,7 +450,8 @@ class NFTokenDir_test : public beast::unit_test::Suite // generates a non-tesSUCCESS error code. for (uint256 const& nftID : nftIDs) { - uint256 const offerID = keylet::nftokenOffer(buyer, env.seq(buyer)).key; + uint256 const offerID = + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key; env(token::createOffer(buyer, nftID, XRP(100)), Txflags(tfSellNFToken)); env.close(); @@ -648,7 +654,8 @@ class NFTokenDir_test : public beast::unit_test::Suite env.close(); // Create an offer to give the NFT to buyer for free. - offers.emplace_back(keylet::nftokenOffer(account, env.seq(account)).key); + offers.emplace_back( + keylet::nftokenOffer(account, SeqProxy::rawSequence(env.seq(account))).key); env(token::createOffer(account, nftID, XRP(0)), token::Destination(buyer), Txflags(tfSellNFToken)); @@ -684,7 +691,8 @@ class NFTokenDir_test : public beast::unit_test::Suite // a non-tesSUCCESS error code. for (uint256 const& nftID : nftIDs) { - uint256 const offerID = keylet::nftokenOffer(buyer, env.seq(buyer)).key; + uint256 const offerID = + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key; env(token::createOffer(buyer, nftID, XRP(100)), Txflags(tfSellNFToken)); env.close(); @@ -820,7 +828,8 @@ class NFTokenDir_test : public beast::unit_test::Suite env.close(); // Create an offer to give the NFT to buyer for free. - offers[i].emplace_back(keylet::nftokenOffer(account, env.seq(account)).key); + offers[i].emplace_back( + keylet::nftokenOffer(account, SeqProxy::rawSequence(env.seq(account))).key); env(token::createOffer(account, nftID, XRP(0)), token::Destination(buyer), Txflags(tfSellNFToken)); diff --git a/src/test/app/NFToken_test.cpp b/src/test/app/NFToken_test.cpp index acd54ae26a..a7437eea7f 100644 --- a/src/test/app/NFToken_test.cpp +++ b/src/test/app/NFToken_test.cpp @@ -33,6 +33,7 @@ #include #include #include +#include #include #include #include @@ -143,7 +144,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite Account const alice{"alice"}; env.fund(XRP(10000), alice); env.close(); - uint256 const aliceOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key; + uint256 const aliceOfferIndex = + keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key; env(token::createOffer(alice, nftId1, XRP(1000)), token::Owner(master)); env.close(); @@ -861,7 +863,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite BEAST_EXPECT(ownerCount(env, alice) == 1); // This is the offer we'll try to cancel. - uint256 const buyerOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key; + uint256 const buyerOfferIndex = + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key; env(token::createOffer(buyer, nftAlice0ID, XRP(1)), token::Owner(alice), Ter(tesSUCCESS)); env.close(); BEAST_EXPECT(ownerCount(env, buyer) == 1); @@ -904,7 +907,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // List of offer IDs containing zero is invalid. // craftedIndex is not a valid offer index but it is not zero. - auto const craftedIndex = keylet::nftokenOffer(gw, env.seq(gw)).key; + auto const craftedIndex = + keylet::nftokenOffer(gw, SeqProxy::rawSequence(env.seq(gw))).key; env(token::cancelOffer(buyer, {buyerOfferIndex, uint256{}, craftedIndex}), Ter(temMALFORMED)); env.close(); @@ -944,7 +948,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite { // gw attempts to cancel a Check as through it is an NFTokenOffer. - auto const gwCheckId = keylet::check(gw, env.seq(gw)).key; + auto const gwCheckId = keylet::check(gw, SeqProxy::rawSequence(env.seq(gw))).key; env(check::create(gw, env.master, XRP(300))); env.close(); @@ -1006,32 +1010,37 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite BEAST_EXPECT(ownerCount(env, alice) == aliceCount); // alice creates sell offers for her nfts. - uint256 const plainOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key; + uint256 const plainOfferIndex = + keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key; env(token::createOffer(alice, nftAlice0ID, XRP(10)), Txflags(tfSellNFToken)); env.close(); aliceCount++; BEAST_EXPECT(ownerCount(env, alice) == aliceCount); - uint256 const audOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key; + uint256 const audOfferIndex = + keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key; env(token::createOffer(alice, nftAlice0ID, gwAUD(30)), Txflags(tfSellNFToken)); env.close(); aliceCount++; BEAST_EXPECT(ownerCount(env, alice) == aliceCount); - uint256 const xrpOnlyOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key; + uint256 const xrpOnlyOfferIndex = + keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key; env(token::createOffer(alice, nftXrpOnlyID, XRP(20)), Txflags(tfSellNFToken)); env.close(); aliceCount++; BEAST_EXPECT(ownerCount(env, alice) == aliceCount); - uint256 const noXferOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key; + uint256 const noXferOfferIndex = + keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key; env(token::createOffer(alice, nftNoXferID, XRP(30)), Txflags(tfSellNFToken)); env.close(); aliceCount++; BEAST_EXPECT(ownerCount(env, alice) == aliceCount); // alice creates a sell offer that will expire soon. - uint256 const aliceExpOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key; + uint256 const aliceExpOfferIndex = + keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key; env(token::createOffer(alice, nftNoXferID, XRP(40)), Txflags(tfSellNFToken), token::Expiration(lastClose(env) + 5)); @@ -1040,7 +1049,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite BEAST_EXPECT(ownerCount(env, alice) == aliceCount); // buyer creates a Buy offer that will expire soon. - uint256 const buyerExpOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key; + uint256 const buyerExpOfferIndex = + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key; env(token::createOffer(buyer, nftAlice0ID, XRP(40)), token::Owner(alice), token::Expiration(lastClose(env) + 5)); @@ -1108,7 +1118,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite BEAST_EXPECT(ownerCount(env, buyer) == buyerCount); // The buy offer must be present in the ledger. - uint256 const missingOfferIndex = keylet::nftokenOffer(alice, 1).key; + uint256 const missingOfferIndex = keylet::nftokenOffer(alice, SeqProxy::rawSequence(1)).key; env(token::acceptBuyOffer(buyer, missingOfferIndex), Ter(tecOBJECT_NOT_FOUND)); env.close(); BEAST_EXPECT(ownerCount(env, buyer) == buyerCount); @@ -1171,7 +1181,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // corresponding buy and sell offers. { // buyer creates a buy offer for one of alice's nfts. - uint256 const buyerOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key; + uint256 const buyerOfferIndex = + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key; env(token::createOffer(buyer, nftAlice0ID, gwAUD(29)), token::Owner(alice)); env.close(); buyerCount++; @@ -1204,7 +1215,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite } { // buyer creates a buy offer for one of alice's nfts. - uint256 const buyerOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key; + uint256 const buyerOfferIndex = + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key; env(token::createOffer(buyer, nftAlice0ID, gwAUD(31)), token::Owner(alice)); env.close(); buyerCount++; @@ -1243,7 +1255,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // preclaim buy { // buyer creates a buy offer for one of alice's nfts. - uint256 const buyerOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key; + uint256 const buyerOfferIndex = + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key; env(token::createOffer(buyer, nftAlice0ID, gwAUD(30)), token::Owner(alice)); env.close(); buyerCount++; @@ -1270,7 +1283,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // alice gives her NFT to gw, so alice no longer owns nftAlice0. { - uint256 const offerIndex = keylet::nftokenOffer(alice, env.seq(alice)).key; + uint256 const offerIndex = + keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key; env(token::createOffer(alice, nftAlice0ID, XRP(0)), Txflags(tfSellNFToken)); env.close(); env(token::acceptSellOffer(gw, offerIndex)); @@ -1295,7 +1309,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // preclaim sell { // buyer creates a buy offer for one of alice's nfts. - uint256 const buyerOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key; + uint256 const buyerOfferIndex = + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key; env(token::createOffer(buyer, nftXrpOnlyID, XRP(30)), token::Owner(alice)); env.close(); buyerCount++; @@ -1323,7 +1338,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // buyer attempting to accept one of alice's offers with // insufficient funds. { - uint256 const offerIndex = keylet::nftokenOffer(gw, env.seq(gw)).key; + uint256 const offerIndex = + keylet::nftokenOffer(gw, SeqProxy::rawSequence(env.seq(gw))).key; env(token::createOffer(gw, nftAlice0ID, XRP(0)), Txflags(tfSellNFToken)); env.close(); env(token::acceptSellOffer(alice, offerIndex)); @@ -1376,7 +1392,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite env(token::mint(minter1, 0u), token::Issuer(alice), Txflags(flags)); env.close(); - uint256 const offerIndex = keylet::nftokenOffer(minter1, env.seq(minter1)).key; + uint256 const offerIndex = + keylet::nftokenOffer(minter1, SeqProxy::rawSequence(env.seq(minter1))).key; env(token::createOffer(minter1, nftID, XRP(0)), Txflags(tfSellNFToken)); env.close(); @@ -1479,13 +1496,15 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite env.close(); BEAST_EXPECT(ownerCount(env, alice) == 2); - uint256 const aliceOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key; + uint256 const aliceOfferIndex = + keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key; env(token::createOffer(alice, nftIOUsOkayID, gwAUD(50)), Txflags(tfSellNFToken)); env.close(); BEAST_EXPECT(ownerCount(env, alice) == 3); BEAST_EXPECT(ownerCount(env, buyer) == 1); - uint256 const buyerOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key; + uint256 const buyerOfferIndex = + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key; env(token::createOffer(buyer, nftIOUsOkayID, gwAUD(50)), token::Owner(alice)); env.close(); BEAST_EXPECT(ownerCount(env, buyer) == 2); @@ -1588,7 +1607,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite env.close(); // becky buys the nft for 1 drop. - uint256 const beckyBuyOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key; + uint256 const beckyBuyOfferIndex = + keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key; env(token::createOffer(becky, nftNoAutoTrustID, drops(1)), token::Owner(alice)); env.close(); env(token::acceptBuyOffer(alice, beckyBuyOfferIndex)); @@ -1596,14 +1616,16 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // becky attempts to sell the nft for AUD. TER const createOfferTER = (xferFee != 0u) ? TER(tecNO_LINE) : TER(tesSUCCESS); - uint256 const beckyOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key; + uint256 const beckyOfferIndex = + keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key; env(token::createOffer(becky, nftNoAutoTrustID, gwAUD(100)), Txflags(tfSellNFToken), Ter(createOfferTER)); env.close(); // cheri offers to buy the nft for CAD. - uint256 const cheriOfferIndex = keylet::nftokenOffer(cheri, env.seq(cheri)).key; + uint256 const cheriOfferIndex = + keylet::nftokenOffer(cheri, SeqProxy::rawSequence(env.seq(cheri))).key; env(token::createOffer(cheri, nftNoAutoTrustID, gwCAD(100)), token::Owner(becky), Ter(createOfferTER)); @@ -1641,14 +1663,16 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite break; } // becky buys the nft for 1 drop. - uint256 const beckyBuyOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key; + uint256 const beckyBuyOfferIndex = + keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key; env(token::createOffer(becky, nftAutoTrustID, drops(1)), token::Owner(alice)); env.close(); env(token::acceptBuyOffer(alice, beckyBuyOfferIndex)); env.close(); // becky sells the nft for AUD. - uint256 const beckySellOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key; + uint256 const beckySellOfferIndex = + keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key; env(token::createOffer(becky, nftAutoTrustID, gwAUD(100)), Txflags(tfSellNFToken)); env.close(); env(token::acceptSellOffer(cheri, beckySellOfferIndex)); @@ -1659,7 +1683,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // becky buys the nft back for CAD. uint256 const beckyBuyBackOfferIndex = - keylet::nftokenOffer(becky, env.seq(becky)).key; + keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key; env(token::createOffer(becky, nftAutoTrustID, gwCAD(50)), token::Owner(cheri)); env.close(); env(token::acceptBuyOffer(cheri, beckyBuyBackOfferIndex)); @@ -1679,7 +1703,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite env.close(); // alice sells the nft using AUD. - uint256 const aliceSellOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key; + uint256 const aliceSellOfferIndex = + keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key; env(token::createOffer(alice, nftNoAutoTrustID, gwAUD(200)), Txflags(tfSellNFToken)); env.close(); @@ -1696,7 +1721,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite Txflags(tfSellNFToken), Ter(tecNO_LINE)); env.close(); - uint256 const cheriSellOfferIndex = keylet::nftokenOffer(cheri, env.seq(cheri)).key; + uint256 const cheriSellOfferIndex = + keylet::nftokenOffer(cheri, SeqProxy::rawSequence(env.seq(cheri))).key; env(token::createOffer(cheri, nftNoAutoTrustID, gwCAD(100)), Txflags(tfSellNFToken)); env.close(); @@ -1743,7 +1769,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite Ter(tefNFTOKEN_IS_NOT_TRANSFERABLE)); // alice offers to sell the nft and becky accepts the offer. - uint256 const aliceSellOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key; + uint256 const aliceSellOfferIndex = + keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key; env(token::createOffer(alice, nftAliceNoTransferID, XRP(20)), Txflags(tfSellNFToken)); env.close(); env(token::acceptSellOffer(becky, aliceSellOfferIndex)); @@ -1771,7 +1798,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // alice offers to buy the nft back from becky. becky accepts // the offer. - uint256 const aliceBuyOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key; + uint256 const aliceBuyOfferIndex = + keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key; env(token::createOffer(alice, nftAliceNoTransferID, XRP(22)), token::Owner(becky)); env.close(); env(token::acceptBuyOffer(becky, aliceBuyOfferIndex)); @@ -1827,7 +1855,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // minter successfully offers their nft for sale. BEAST_EXPECT(ownerCount(env, minter) == 1); - uint256 const minterSellOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key; + uint256 const minterSellOfferIndex = + keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key; env(token::createOffer(minter, nftMinterNoTransferID, XRP(22)), Txflags(tfSellNFToken)); env.close(); BEAST_EXPECT(ownerCount(env, minter) == 2); @@ -1862,7 +1891,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // alice can create an offer to buy the nft. BEAST_EXPECT(ownerCount(env, alice) == 0); - uint256 const aliceBuyOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key; + uint256 const aliceBuyOfferIndex = + keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key; env(token::createOffer(alice, nftMinterNoTransferID, XRP(25)), token::Owner(becky)); env.close(); BEAST_EXPECT(ownerCount(env, alice) == 1); @@ -1877,7 +1907,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // Now minter can create an offer to buy the nft. BEAST_EXPECT(ownerCount(env, minter) == 0); - uint256 const minterBuyOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key; + uint256 const minterBuyOfferIndex = + keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key; env(token::createOffer(minter, nftMinterNoTransferID, XRP(26)), token::Owner(becky)); env.close(); BEAST_EXPECT(ownerCount(env, minter) == 1); @@ -1916,12 +1947,14 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite BEAST_EXPECT(ownerCount(env, alice) == 1); // Both alice and becky can make offers for alice's nft. - uint256 const aliceSellOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key; + uint256 const aliceSellOfferIndex = + keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key; env(token::createOffer(alice, nftAliceID, XRP(20)), Txflags(tfSellNFToken)); env.close(); BEAST_EXPECT(ownerCount(env, alice) == 2); - uint256 const beckyBuyOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key; + uint256 const beckyBuyOfferIndex = + keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key; env(token::createOffer(becky, nftAliceID, XRP(21)), token::Owner(alice)); env.close(); BEAST_EXPECT(ownerCount(env, alice) == 2); @@ -1933,7 +1966,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite BEAST_EXPECT(ownerCount(env, becky) == 2); // becky offers to sell the nft. - uint256 const beckySellOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key; + uint256 const beckySellOfferIndex = + keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key; env(token::createOffer(becky, nftAliceID, XRP(22)), Txflags(tfSellNFToken)); env.close(); BEAST_EXPECT(ownerCount(env, alice) == 0); @@ -1948,7 +1982,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite BEAST_EXPECT(ownerCount(env, minter) == 1); // minter offers to sell the nft. - uint256 const minterSellOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key; + uint256 const minterSellOfferIndex = + keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key; env(token::createOffer(minter, nftAliceID, XRP(23)), Txflags(tfSellNFToken)); env.close(); BEAST_EXPECT(ownerCount(env, alice) == 0); @@ -2030,7 +2065,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite env.close(); // Becky buys the nft for XAU(10). Check balances. - uint256 const beckyBuyOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key; + uint256 const beckyBuyOfferIndex = + keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key; env(token::createOffer(becky, nftID, gwXAU(10)), token::Owner(alice)); env.close(); BEAST_EXPECT(env.balance(alice, gwXAU) == gwXAU(1000)); @@ -2042,7 +2078,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite BEAST_EXPECT(env.balance(becky, gwXAU) == gwXAU(990)); // becky sells nft to carol. alice's balance should not change. - uint256 const beckySellOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key; + uint256 const beckySellOfferIndex = + keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key; env(token::createOffer(becky, nftID, gwXAU(10)), Txflags(tfSellNFToken)); env.close(); env(token::acceptSellOffer(carol, beckySellOfferIndex)); @@ -2052,7 +2089,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite BEAST_EXPECT(env.balance(carol, gwXAU) == gwXAU(990)); // minter buys nft from carol. alice's balance should not change. - uint256 const minterBuyOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key; + uint256 const minterBuyOfferIndex = + keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key; env(token::createOffer(minter, nftID, gwXAU(10)), token::Owner(carol)); env.close(); env(token::acceptBuyOffer(carol, minterBuyOfferIndex)); @@ -2064,7 +2102,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // minter sells the nft to alice. gwXAU balances should finish // where they started. - uint256 const minterSellOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key; + uint256 const minterSellOfferIndex = + keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key; env(token::createOffer(minter, nftID, gwXAU(10)), Txflags(tfSellNFToken)); env.close(); env(token::acceptSellOffer(alice, minterSellOfferIndex)); @@ -2091,7 +2130,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite env.close(); // Becky buys the nft for XAU(10). Check balances. - uint256 const beckyBuyOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key; + uint256 const beckyBuyOfferIndex = + keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key; env(token::createOffer(becky, nftID, gwXAU(10)), token::Owner(alice)); env.close(); BEAST_EXPECT(env.balance(alice, gwXAU) == gwXAU(1000)); @@ -2103,7 +2143,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite BEAST_EXPECT(env.balance(becky, gwXAU) == gwXAU(990)); // becky sells nft to carol. alice's balance goes up. - uint256 const beckySellOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key; + uint256 const beckySellOfferIndex = + keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key; env(token::createOffer(becky, nftID, gwXAU(10)), Txflags(tfSellNFToken)); env.close(); env(token::acceptSellOffer(carol, beckySellOfferIndex)); @@ -2114,7 +2155,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite BEAST_EXPECT(env.balance(carol, gwXAU) == gwXAU(990)); // minter buys nft from carol. alice's balance goes up. - uint256 const minterBuyOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key; + uint256 const minterBuyOfferIndex = + keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key; env(token::createOffer(minter, nftID, gwXAU(10)), token::Owner(carol)); env.close(); env(token::acceptBuyOffer(carol, minterBuyOfferIndex)); @@ -2127,7 +2169,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // minter sells the nft to alice. Because alice is part of the // transaction no transfer fee is removed. - uint256 const minterSellOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key; + uint256 const minterSellOfferIndex = + keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key; env(token::createOffer(minter, nftID, gwXAU(10)), Txflags(tfSellNFToken)); env.close(); env(token::acceptSellOffer(alice, minterSellOfferIndex)); @@ -2172,7 +2215,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite env.close(); // Becky buys the nft for XAU(10). Check balances. - uint256 const beckyBuyOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key; + uint256 const beckyBuyOfferIndex = + keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key; env(token::createOffer(becky, nftID, gwXAU(10)), token::Owner(alice)); env.close(); BEAST_EXPECT(env.balance(alice, gwXAU) == gwXAU(1000)); @@ -2184,7 +2228,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite BEAST_EXPECT(env.balance(becky, gwXAU) == gwXAU(990)); // becky sells nft to minter. alice's balance goes up. - uint256 const beckySellOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key; + uint256 const beckySellOfferIndex = + keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key; env(token::createOffer(becky, nftID, gwXAU(100)), Txflags(tfSellNFToken)); env.close(); env(token::acceptSellOffer(minter, beckySellOfferIndex)); @@ -2195,7 +2240,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite BEAST_EXPECT(env.balance(minter, gwXAU) == gwXAU(900)); // carol buys nft from minter. alice's balance goes up. - uint256 const carolBuyOfferIndex = keylet::nftokenOffer(carol, env.seq(carol)).key; + uint256 const carolBuyOfferIndex = + keylet::nftokenOffer(carol, SeqProxy::rawSequence(env.seq(carol))).key; env(token::createOffer(carol, nftID, gwXAU(10)), token::Owner(minter)); env.close(); env(token::acceptBuyOffer(minter, carolBuyOfferIndex)); @@ -2208,7 +2254,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // carol sells the nft to alice. Because alice is part of the // transaction no transfer fee is removed. - uint256 const carolSellOfferIndex = keylet::nftokenOffer(carol, env.seq(carol)).key; + uint256 const carolSellOfferIndex = + keylet::nftokenOffer(carol, SeqProxy::rawSequence(env.seq(carol))).key; env(token::createOffer(carol, nftID, gwXAU(10)), Txflags(tfSellNFToken)); env.close(); env(token::acceptSellOffer(alice, carolSellOfferIndex)); @@ -2249,7 +2296,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // alice there should be no transfer fee. STAmount aliceBalance = env.balance(alice); STAmount minterBalance = env.balance(minter); - uint256 const minterBuyOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key; + uint256 const minterBuyOfferIndex = + keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key; env(token::createOffer(minter, nftID, XRP(1)), token::Owner(alice)); env.close(); env(token::acceptBuyOffer(alice, minterBuyOfferIndex)); @@ -2263,7 +2311,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // alice does not get any transfer fee. auto pmt = drops(50000); STAmount carolBalance = env.balance(carol); - uint256 const minterSellOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key; + uint256 const minterSellOfferIndex = + keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key; env(token::createOffer(minter, nftID, pmt), Txflags(tfSellNFToken)); env.close(); env(token::acceptSellOffer(carol, minterSellOfferIndex)); @@ -2277,7 +2326,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // carol sells to becky. This is the smallest amount to pay for a // transfer that enables a transfer fee of 1 basis point. STAmount beckyBalance = env.balance(becky); - uint256 const beckyBuyOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key; + uint256 const beckyBuyOfferIndex = + keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key; pmt = drops(50001); env(token::createOffer(becky, nftID, pmt), token::Owner(carol)); env.close(); @@ -2322,7 +2372,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // alice there should be no transfer fee. STAmount aliceBalance = env.balance(alice, gwXAU); STAmount minterBalance = env.balance(minter, gwXAU); - uint256 const minterBuyOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key; + uint256 const minterBuyOfferIndex = + keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key; env(token::createOffer(minter, nftID, tinyXAU), token::Owner(alice)); env.close(); env(token::acceptBuyOffer(alice, minterBuyOfferIndex)); @@ -2334,7 +2385,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // minter sells to carol. STAmount carolBalance = env.balance(carol, gwXAU); - uint256 const minterSellOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key; + uint256 const minterSellOfferIndex = + keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key; env(token::createOffer(minter, nftID, tinyXAU), Txflags(tfSellNFToken)); env.close(); env(token::acceptSellOffer(carol, minterSellOfferIndex)); @@ -2352,7 +2404,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite STAmount const cheapNFT(gwXAU, STAmount::kMinValue, STAmount::kMinOffset + 5); STAmount beckyBalance = env.balance(becky, gwXAU); - uint256 const beckyBuyOfferIndex = keylet::nftokenOffer(becky, env.seq(becky)).key; + uint256 const beckyBuyOfferIndex = + keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key; env(token::createOffer(becky, nftID, cheapNFT), token::Owner(carol)); env.close(); env(token::acceptBuyOffer(carol, beckyBuyOfferIndex)); @@ -2582,22 +2635,26 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // Test how adding a Destination field to an offer affects permissions // for canceling offers. { - uint256 const offerMinterToIssuer = keylet::nftokenOffer(minter, env.seq(minter)).key; + uint256 const offerMinterToIssuer = + keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key; env(token::createOffer(minter, nftokenID, drops(1)), token::Destination(issuer), Txflags(tfSellNFToken)); - uint256 const offerMinterToBuyer = keylet::nftokenOffer(minter, env.seq(minter)).key; + uint256 const offerMinterToBuyer = + keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key; env(token::createOffer(minter, nftokenID, drops(1)), token::Destination(buyer), Txflags(tfSellNFToken)); - uint256 const offerIssuerToMinter = keylet::nftokenOffer(issuer, env.seq(issuer)).key; + uint256 const offerIssuerToMinter = + keylet::nftokenOffer(issuer, SeqProxy::rawSequence(env.seq(issuer))).key; env(token::createOffer(issuer, nftokenID, drops(1)), token::Owner(minter), token::Destination(minter)); - uint256 const offerIssuerToBuyer = keylet::nftokenOffer(issuer, env.seq(issuer)).key; + uint256 const offerIssuerToBuyer = + keylet::nftokenOffer(issuer, SeqProxy::rawSequence(env.seq(issuer))).key; env(token::createOffer(issuer, nftokenID, drops(1)), token::Owner(minter), token::Destination(buyer)); @@ -2639,7 +2696,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // accepting that offer. { uint256 const offerMinterSellsToBuyer = - keylet::nftokenOffer(minter, env.seq(minter)).key; + keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key; env(token::createOffer(minter, nftokenID, drops(1)), token::Destination(buyer), Txflags(tfSellNFToken)); @@ -2668,7 +2725,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // accepting that offer. { uint256 const offerMinterBuysFromBuyer = - keylet::nftokenOffer(minter, env.seq(minter)).key; + keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key; env(token::createOffer(minter, nftokenID, drops(1)), token::Owner(buyer), token::Destination(buyer)); @@ -2696,7 +2753,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // destination must act as a broker. The NFToken owner may not // simply accept the offer. uint256 const offerBuyerBuysFromMinter = - keylet::nftokenOffer(buyer, env.seq(buyer)).key; + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key; env(token::createOffer(buyer, nftokenID, drops(1)), token::Owner(minter), token::Destination(broker)); @@ -2719,12 +2776,14 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // Show that a sell offer's Destination can broker that sell offer // to another account. { - uint256 const offerMinterToBroker = keylet::nftokenOffer(minter, env.seq(minter)).key; + uint256 const offerMinterToBroker = + keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key; env(token::createOffer(minter, nftokenID, drops(1)), token::Destination(broker), Txflags(tfSellNFToken)); - uint256 const offerBuyerToMinter = keylet::nftokenOffer(buyer, env.seq(buyer)).key; + uint256 const offerBuyerToMinter = + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key; env(token::createOffer(buyer, nftokenID, drops(1)), token::Owner(minter)); env.close(); @@ -2756,15 +2815,18 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // Destination doesn't match, but can complete if the Destination // does match. { - uint256 const offerBuyerToMinter = keylet::nftokenOffer(buyer, env.seq(buyer)).key; + uint256 const offerBuyerToMinter = + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key; env(token::createOffer(buyer, nftokenID, drops(1)), token::Destination(minter), Txflags(tfSellNFToken)); - uint256 const offerMinterToBuyer = keylet::nftokenOffer(minter, env.seq(minter)).key; + uint256 const offerMinterToBuyer = + keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key; env(token::createOffer(minter, nftokenID, drops(1)), token::Owner(buyer)); - uint256 const offerIssuerToBuyer = keylet::nftokenOffer(issuer, env.seq(issuer)).key; + uint256 const offerIssuerToBuyer = + keylet::nftokenOffer(issuer, SeqProxy::rawSequence(env.seq(issuer))).key; env(token::createOffer(issuer, nftokenID, drops(1)), token::Owner(buyer)); env.close(); @@ -2812,12 +2874,14 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // Show that if a buy and a sell offer both have the same destination, // then that destination can broker the offers. { - uint256 const offerMinterToBroker = keylet::nftokenOffer(minter, env.seq(minter)).key; + uint256 const offerMinterToBroker = + keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key; env(token::createOffer(minter, nftokenID, drops(1)), token::Destination(broker), Txflags(tfSellNFToken)); - uint256 const offerBuyerToBroker = keylet::nftokenOffer(buyer, env.seq(buyer)).key; + uint256 const offerBuyerToBroker = + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key; env(token::createOffer(buyer, nftokenID, drops(1)), token::Owner(minter), token::Destination(broker)); @@ -2887,7 +2951,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // create offer (allowed now) then cancel { - uint256 const offerIndex = keylet::nftokenOffer(minter, env.seq(minter)).key; + uint256 const offerIndex = + keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key; env(token::createOffer(minter, nftokenID, drops(1)), token::Destination(buyer), @@ -2900,7 +2965,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // create offer, enable flag, then cancel { - uint256 const offerIndex = keylet::nftokenOffer(minter, env.seq(minter)).key; + uint256 const offerIndex = + keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key; env(token::createOffer(minter, nftokenID, drops(1)), token::Destination(buyer), @@ -2919,7 +2985,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // create offer then transfer { - uint256 const offerIndex = keylet::nftokenOffer(minter, env.seq(minter)).key; + uint256 const offerIndex = + keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key; env(token::createOffer(minter, nftokenID, drops(1)), token::Destination(buyer), @@ -3006,23 +3073,27 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite { std::uint32_t const expiration = lastClose(env) + 25; - uint256 const offerMinterToIssuer = keylet::nftokenOffer(minter, env.seq(minter)).key; + uint256 const offerMinterToIssuer = + keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key; env(token::createOffer(minter, nftokenID0, drops(1)), token::Destination(issuer), token::Expiration(expiration), Txflags(tfSellNFToken)); - uint256 const offerMinterToAnyone = keylet::nftokenOffer(minter, env.seq(minter)).key; + uint256 const offerMinterToAnyone = + keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key; env(token::createOffer(minter, nftokenID0, drops(1)), token::Expiration(expiration), Txflags(tfSellNFToken)); - uint256 const offerIssuerToMinter = keylet::nftokenOffer(issuer, env.seq(issuer)).key; + uint256 const offerIssuerToMinter = + keylet::nftokenOffer(issuer, SeqProxy::rawSequence(env.seq(issuer))).key; env(token::createOffer(issuer, nftokenID0, drops(1)), token::Owner(minter), token::Expiration(expiration)); - uint256 const offerBuyerToMinter = keylet::nftokenOffer(buyer, env.seq(buyer)).key; + uint256 const offerBuyerToMinter = + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key; env(token::createOffer(buyer, nftokenID0, drops(1)), token::Owner(minter), token::Expiration(expiration)); @@ -3082,13 +3153,15 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite { std::uint32_t const expiration = lastClose(env) + 25; - uint256 const offer0 = keylet::nftokenOffer(minter, env.seq(minter)).key; + uint256 const offer0 = + keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key; env(token::createOffer(minter, nftokenID0, drops(1)), token::Expiration(expiration), Txflags(tfSellNFToken)); minterCount++; - uint256 const offer1 = keylet::nftokenOffer(minter, env.seq(minter)).key; + uint256 const offer1 = + keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key; env(token::createOffer(minter, nftokenID1, drops(1)), token::Expiration(expiration), Txflags(tfSellNFToken)); @@ -3153,7 +3226,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // Transfer nftokenID0 back to minter so we start the next test in // a simple place. - uint256 const offerSellBack = keylet::nftokenOffer(buyer, env.seq(buyer)).key; + uint256 const offerSellBack = + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key; env(token::createOffer(buyer, nftokenID0, XRP(0)), Txflags(tfSellNFToken), token::Destination(minter)); @@ -3172,13 +3246,15 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite { std::uint32_t const expiration = lastClose(env) + 25; - uint256 const offer0 = keylet::nftokenOffer(buyer, env.seq(buyer)).key; + uint256 const offer0 = + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key; env(token::createOffer(buyer, nftokenID0, drops(1)), token::Owner(minter), token::Expiration(expiration)); buyerCount++; - uint256 const offer1 = keylet::nftokenOffer(buyer, env.seq(buyer)).key; + uint256 const offer1 = + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key; env(token::createOffer(buyer, nftokenID1, drops(1)), token::Owner(minter), token::Expiration(expiration)); @@ -3241,7 +3317,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // Transfer nftokenID0 back to minter so we start the next test in // a simple place. - uint256 const offerSellBack = keylet::nftokenOffer(buyer, env.seq(buyer)).key; + uint256 const offerSellBack = + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key; env(token::createOffer(buyer, nftokenID0, XRP(0)), Txflags(tfSellNFToken), token::Destination(minter)); @@ -3260,23 +3337,27 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite { std::uint32_t const expiration = lastClose(env) + 25; - uint256 const sellOffer0 = keylet::nftokenOffer(minter, env.seq(minter)).key; + uint256 const sellOffer0 = + keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key; env(token::createOffer(minter, nftokenID0, drops(1)), token::Expiration(expiration), Txflags(tfSellNFToken)); minterCount++; - uint256 const sellOffer1 = keylet::nftokenOffer(minter, env.seq(minter)).key; + uint256 const sellOffer1 = + keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key; env(token::createOffer(minter, nftokenID1, drops(1)), token::Expiration(expiration), Txflags(tfSellNFToken)); minterCount++; - uint256 const buyOffer0 = keylet::nftokenOffer(buyer, env.seq(buyer)).key; + uint256 const buyOffer0 = + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key; env(token::createOffer(buyer, nftokenID0, drops(1)), token::Owner(minter)); buyerCount++; - uint256 const buyOffer1 = keylet::nftokenOffer(buyer, env.seq(buyer)).key; + uint256 const buyOffer1 = + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key; env(token::createOffer(buyer, nftokenID1, drops(1)), token::Owner(minter)); buyerCount++; @@ -3335,7 +3416,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // Transfer nftokenID0 back to minter so we start the next test in // a simple place. - uint256 const offerSellBack = keylet::nftokenOffer(buyer, env.seq(buyer)).key; + uint256 const offerSellBack = + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key; env(token::createOffer(buyer, nftokenID0, XRP(0)), Txflags(tfSellNFToken), token::Destination(minter)); @@ -3354,18 +3436,22 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite { std::uint32_t const expiration = lastClose(env) + 25; - uint256 const sellOffer0 = keylet::nftokenOffer(minter, env.seq(minter)).key; + uint256 const sellOffer0 = + keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key; env(token::createOffer(minter, nftokenID0, drops(1)), Txflags(tfSellNFToken)); - uint256 const sellOffer1 = keylet::nftokenOffer(minter, env.seq(minter)).key; + uint256 const sellOffer1 = + keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key; env(token::createOffer(minter, nftokenID1, drops(1)), Txflags(tfSellNFToken)); - uint256 const buyOffer0 = keylet::nftokenOffer(buyer, env.seq(buyer)).key; + uint256 const buyOffer0 = + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key; env(token::createOffer(buyer, nftokenID0, drops(1)), token::Expiration(expiration), token::Owner(minter)); - uint256 const buyOffer1 = keylet::nftokenOffer(buyer, env.seq(buyer)).key; + uint256 const buyOffer1 = + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key; env(token::createOffer(buyer, nftokenID1, drops(1)), token::Expiration(expiration), token::Owner(minter)); @@ -3416,7 +3502,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // Transfer nftokenID0 back to minter so we start the next test in // a simple place. - uint256 const offerSellBack = keylet::nftokenOffer(buyer, env.seq(buyer)).key; + uint256 const offerSellBack = + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key; env(token::createOffer(buyer, nftokenID0, XRP(0)), Txflags(tfSellNFToken), token::Destination(minter)); @@ -3435,22 +3522,26 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite { std::uint32_t const expiration = lastClose(env) + 25; - uint256 const sellOffer0 = keylet::nftokenOffer(minter, env.seq(minter)).key; + uint256 const sellOffer0 = + keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key; env(token::createOffer(minter, nftokenID0, drops(1)), token::Expiration(expiration), Txflags(tfSellNFToken)); - uint256 const sellOffer1 = keylet::nftokenOffer(minter, env.seq(minter)).key; + uint256 const sellOffer1 = + keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key; env(token::createOffer(minter, nftokenID1, drops(1)), token::Expiration(expiration), Txflags(tfSellNFToken)); - uint256 const buyOffer0 = keylet::nftokenOffer(buyer, env.seq(buyer)).key; + uint256 const buyOffer0 = + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key; env(token::createOffer(buyer, nftokenID0, drops(1)), token::Expiration(expiration), token::Owner(minter)); - uint256 const buyOffer1 = keylet::nftokenOffer(buyer, env.seq(buyer)).key; + uint256 const buyOffer1 = + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key; env(token::createOffer(buyer, nftokenID1, drops(1)), token::Expiration(expiration), token::Owner(minter)); @@ -3492,7 +3583,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // Transfer nftokenID0 back to minter so we start the next test in // a simple place. - uint256 const offerSellBack = keylet::nftokenOffer(buyer, env.seq(buyer)).key; + uint256 const offerSellBack = + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key; env(token::createOffer(buyer, nftokenID0, XRP(0)), Txflags(tfSellNFToken), token::Destination(minter)); @@ -3530,7 +3622,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite env.close(); // Anyone can cancel an expired offer. - uint256 const expiredOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key; + uint256 const expiredOfferIndex = + keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key; env(token::createOffer(alice, nftokenID, XRP(1000)), Txflags(tfSellNFToken), @@ -3552,7 +3645,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // Create a couple of offers with a destination. Those offers // should be cancellable by the creator and the destination. - uint256 const dest1OfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key; + uint256 const dest1OfferIndex = + keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key; env(token::createOffer(alice, nftokenID, XRP(1000)), token::Destination(becky), @@ -3570,7 +3664,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite BEAST_EXPECT(ownerCount(env, alice) == 1); // alice can cancel her own offer, even if becky is the destination. - uint256 const dest2OfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key; + uint256 const dest2OfferIndex = + keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key; env(token::createOffer(alice, nftokenID, XRP(1000)), token::Destination(becky), @@ -3589,7 +3684,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite env(token::mint(minter, 0), token::Issuer(alice), Txflags(tfTransferable)); env.close(); - uint256 const minterOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key; + uint256 const minterOfferIndex = + keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key; env(token::createOffer(minter, mintersNFTokenID, XRP(1000)), Txflags(tfSellNFToken)); env.close(); @@ -3647,7 +3743,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite env(token::mint(nftAcct, 0), token::Uri(uri), Txflags(tfTransferable)); env.close(); - offerIndexes.push_back(keylet::nftokenOffer(offerAcct, env.seq(offerAcct)).key); + offerIndexes.push_back( + keylet::nftokenOffer(offerAcct, SeqProxy::rawSequence(env.seq(offerAcct))).key); env(token::createOffer(offerAcct, nftokenID, drops(1)), token::Owner(nftAcct), token::Expiration(lastClose(env) + 5)); @@ -3682,7 +3779,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite env(token::mint(alice, 0), token::Uri(uri), Txflags(tfTransferable)); env.close(); - offerIndexes.push_back(keylet::nftokenOffer(alice, env.seq(alice)).key); + offerIndexes.push_back( + keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key); env(token::createOffer(alice, nftokenID, drops(1)), Txflags(tfSellNFToken)); env.close(); @@ -3793,13 +3891,15 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite uint256 const nftID = mintNFT(); // minter creates their offer. - uint256 const minterOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key; + uint256 const minterOfferIndex = + keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key; env(token::createOffer(minter, nftID, XRP(0)), Txflags(tfSellNFToken)); env.close(); // buyer creates their offer. Note: a buy offer can never // offer zero. - uint256 const buyOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key; + uint256 const buyOfferIndex = + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key; env(token::createOffer(buyer, nftID, XRP(1)), token::Owner(minter)); env.close(); @@ -3835,13 +3935,15 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite uint256 const nftID = mintNFT(); // minter creates their offer. - uint256 const minterOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key; + uint256 const minterOfferIndex = + keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key; env(token::createOffer(minter, nftID, XRP(0)), Txflags(tfSellNFToken)); env.close(); // buyer creates their offer. Note: a buy offer can never // offer zero. - uint256 const buyOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key; + uint256 const buyOfferIndex = + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key; env(token::createOffer(buyer, nftID, XRP(1)), token::Owner(minter)); env.close(); @@ -3884,13 +3986,15 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite uint256 const nftID = mintNFT(kMaxTransferFee); // minter creates their offer. - uint256 const minterOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key; + uint256 const minterOfferIndex = + keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key; env(token::createOffer(minter, nftID, XRP(0)), Txflags(tfSellNFToken)); env.close(); // buyer creates their offer. Note: a buy offer can never // offer zero. - uint256 const buyOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key; + uint256 const buyOfferIndex = + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key; env(token::createOffer(buyer, nftID, XRP(1)), token::Owner(minter)); env.close(); @@ -3926,13 +4030,15 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite uint256 const nftID = mintNFT(kMaxTransferFee); // minter creates their offer. - uint256 const minterOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key; + uint256 const minterOfferIndex = + keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key; env(token::createOffer(minter, nftID, XRP(0)), Txflags(tfSellNFToken)); env.close(); // buyer creates their offer. Note: a buy offer can never // offer zero. - uint256 const buyOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key; + uint256 const buyOfferIndex = + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key; env(token::createOffer(buyer, nftID, XRP(1)), token::Owner(minter)); env.close(); @@ -3999,14 +4105,16 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite uint256 const nftID = mintNFT(); // minter creates their offer. - uint256 const minterOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key; + uint256 const minterOfferIndex = + keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key; env(token::createOffer(minter, nftID, gwXAU(1000)), Txflags(tfSellNFToken)); env.close(); { // buyer creates an offer for more XAU than they currently // own. - uint256 const buyOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key; + uint256 const buyOfferIndex = + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key; env(token::createOffer(buyer, nftID, gwXAU(1001)), token::Owner(minter)); env.close(); @@ -4023,7 +4131,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite { // buyer creates an offer for less that what minter is // asking. - uint256 const buyOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key; + uint256 const buyOfferIndex = + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key; env(token::createOffer(buyer, nftID, gwXAU(999)), token::Owner(minter)); env.close(); @@ -4039,7 +4148,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite } // buyer creates a large enough offer. - uint256 const buyOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key; + uint256 const buyOfferIndex = + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key; env(token::createOffer(buyer, nftID, gwXAU(1000)), token::Owner(minter)); env.close(); @@ -4076,13 +4186,15 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite uint256 const nftID = mintNFT(kMaxTransferFee); // minter creates their offer. - uint256 const minterOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key; + uint256 const minterOfferIndex = + keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key; env(token::createOffer(minter, nftID, gwXAU(900)), Txflags(tfSellNFToken)); env.close(); { // buyer creates an offer for more XAU than they currently // own. - uint256 const buyOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key; + uint256 const buyOfferIndex = + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key; env(token::createOffer(buyer, nftID, gwXAU(1001)), token::Owner(minter)); env.close(); @@ -4099,7 +4211,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite { // buyer creates an offer for less that what minter is // asking. - uint256 const buyOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key; + uint256 const buyOfferIndex = + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key; env(token::createOffer(buyer, nftID, gwXAU(899)), token::Owner(minter)); env.close(); @@ -4114,7 +4227,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite env.close(); } // buyer creates a large enough offer. - uint256 const buyOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key; + uint256 const buyOfferIndex = + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key; env(token::createOffer(buyer, nftID, gwXAU(1000)), token::Owner(minter)); env.close(); @@ -4154,12 +4268,14 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite uint256 const nftID = mintNFT(kMaxTransferFee / 2); // 25% // minter creates their offer. - uint256 const minterOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key; + uint256 const minterOfferIndex = + keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key; env(token::createOffer(minter, nftID, gwXAU(900)), Txflags(tfSellNFToken)); env.close(); // buyer creates a large enough offer. - uint256 const buyOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key; + uint256 const buyOfferIndex = + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key; env(token::createOffer(buyer, nftID, gwXAU(1000)), token::Owner(minter)); env.close(); @@ -4191,12 +4307,14 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite uint256 const nftID = mintNFT(kMaxTransferFee / 2); // 25% // minter creates their offer. - uint256 const minterOfferIndex = keylet::nftokenOffer(minter, env.seq(minter)).key; + uint256 const minterOfferIndex = + keylet::nftokenOffer(minter, SeqProxy::rawSequence(env.seq(minter))).key; env(token::createOffer(minter, nftID, gwXAU(900)), Txflags(tfSellNFToken)); env.close(); // buyer creates a large enough offer. - uint256 const buyOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key; + uint256 const buyOfferIndex = + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key; env(token::createOffer(buyer, nftID, gwXAU(1000)), token::Owner(minter)); env.close(); @@ -4246,9 +4364,11 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite BEAST_EXPECT(nftCount(env, buyer2) == 0); // Both buyer1 and buyer2 create buy offers for nftId. - uint256 const buyer1OfferIndex = keylet::nftokenOffer(buyer1, env.seq(buyer1)).key; + uint256 const buyer1OfferIndex = + keylet::nftokenOffer(buyer1, SeqProxy::rawSequence(env.seq(buyer1))).key; env(token::createOffer(buyer1, nftId, XRP(100)), token::Owner(issuer)); - uint256 const buyer2OfferIndex = keylet::nftokenOffer(buyer2, env.seq(buyer2)).key; + uint256 const buyer2OfferIndex = + keylet::nftokenOffer(buyer2, SeqProxy::rawSequence(env.seq(buyer2))).key; env(token::createOffer(buyer2, nftId, XRP(100)), token::Owner(issuer)); env.close(); @@ -4336,7 +4456,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // NFTokenCreateOffer BEAST_EXPECT(ownerCount(env, buyer) == 10); - uint256 const offerIndex0 = keylet::nftokenOffer(buyer, buyerTicketSeq).key; + uint256 const offerIndex0 = + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(buyerTicketSeq)).key; env(token::createOffer(buyer, nftId, XRP(1)), token::Owner(issuer), ticket::Use(buyerTicketSeq++)); @@ -4351,7 +4472,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite BEAST_EXPECT(ticketCount(env, buyer) == 8); // NFTokenCreateOffer. buyer tries again. - uint256 const offerIndex1 = keylet::nftokenOffer(buyer, buyerTicketSeq).key; + uint256 const offerIndex1 = + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(buyerTicketSeq)).key; env(token::createOffer(buyer, nftId, XRP(2)), token::Owner(issuer), ticket::Use(buyerTicketSeq++)); @@ -4428,7 +4550,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite env(token::createOffer(becky, nftId, XRP(2)), token::Owner(minter)); env.close(); - uint256 const carlaOfferIndex = keylet::nftokenOffer(carla, env.seq(carla)).key; + uint256 const carlaOfferIndex = + keylet::nftokenOffer(carla, SeqProxy::rawSequence(env.seq(carla))).key; env(token::createOffer(carla, nftId, XRP(3)), token::Owner(minter)); env.close(); @@ -4706,25 +4829,29 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite TER const offerCreateTER = temBAD_AMOUNT; // Make offers with negative amounts for the NFTs - uint256 const sellNegXrpOfferIndex = keylet::nftokenOffer(issuer, env.seq(issuer)).key; + uint256 const sellNegXrpOfferIndex = + keylet::nftokenOffer(issuer, SeqProxy::rawSequence(env.seq(issuer))).key; env(token::createOffer(issuer, nftID0, XRP(-2)), Txflags(tfSellNFToken), Ter(offerCreateTER)); env.close(); - uint256 const sellNegIouOfferIndex = keylet::nftokenOffer(issuer, env.seq(issuer)).key; + uint256 const sellNegIouOfferIndex = + keylet::nftokenOffer(issuer, SeqProxy::rawSequence(env.seq(issuer))).key; env(token::createOffer(issuer, nftID1, gwXAU(-2)), Txflags(tfSellNFToken), Ter(offerCreateTER)); env.close(); - uint256 const buyNegXrpOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key; + uint256 const buyNegXrpOfferIndex = + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key; env(token::createOffer(buyer, nftID0, XRP(-1)), token::Owner(issuer), Ter(offerCreateTER)); env.close(); - uint256 const buyNegIouOfferIndex = keylet::nftokenOffer(buyer, env.seq(buyer)).key; + uint256 const buyNegIouOfferIndex = + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key; env(token::createOffer(buyer, nftID1, gwXAU(-1)), token::Owner(issuer), Ter(offerCreateTER)); @@ -4887,7 +5014,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite uint256 const& nftID, STAmount const& amount, std::optional const terCode = {}) { - uint256 const offerID = keylet::nftokenOffer(offerer, env.seq(offerer)).key; + uint256 const offerID = + keylet::nftokenOffer(offerer, SeqProxy::rawSequence(env.seq(offerer))).key; env(token::createOffer(offerer, nftID, amount), token::Owner(owner), terCode ? Ter(*terCode) : Ter(static_cast(tesSUCCESS))); @@ -4900,7 +5028,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite uint256 const& nftID, STAmount const& amount, std::optional const terCode = {}) { - uint256 const offerID = keylet::nftokenOffer(offerer, env.seq(offerer)).key; + uint256 const offerID = + keylet::nftokenOffer(offerer, SeqProxy::rawSequence(env.seq(offerer))).key; env(token::createOffer(offerer, nftID, amount), Txflags(tfSellNFToken), terCode ? Ter(*terCode) : Ter(static_cast(tesSUCCESS))); @@ -5413,10 +5542,12 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // Bob creates a buy offer for 5 XRP. Alice creates a sell offer // for 0 XRP. - uint256 const bobBuyOfferIndex = keylet::nftokenOffer(bob, env.seq(bob)).key; + uint256 const bobBuyOfferIndex = + keylet::nftokenOffer(bob, SeqProxy::rawSequence(env.seq(bob))).key; env(token::createOffer(bob, nftId, XRP(5)), token::Owner(alice)); - uint256 const aliceSellOfferIndex = keylet::nftokenOffer(alice, env.seq(alice)).key; + uint256 const aliceSellOfferIndex = + keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key; env(token::createOffer(alice, nftId, XRP(0)), token::Destination(bob), Txflags(tfSellNFToken)); @@ -5430,7 +5561,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite BEAST_EXPECT(env.le(keylet::nftokenOffer(bobBuyOfferIndex))); // Bob creates a sell offer for the gift NFT from alice. - uint256 const bobSellOfferIndex = keylet::nftokenOffer(bob, env.seq(bob)).key; + uint256 const bobSellOfferIndex = + keylet::nftokenOffer(bob, SeqProxy::rawSequence(env.seq(bob))).key; env(token::createOffer(bob, nftId, XRP(4)), Txflags(tfSellNFToken)); env.close(); @@ -6047,7 +6179,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite token::Amount(XRP(10)), token::Destination(buyer), token::Expiration(lastClose(env) + 25)); - uint256 const offerAliceSellsToBuyer = keylet::nftokenOffer(alice, env.seq(alice)).key; + uint256 const offerAliceSellsToBuyer = + keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key; env(token::cancelOffer(alice, {offerAliceSellsToBuyer})); env.close(); @@ -6056,7 +6189,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite token::Amount(XRP(10)), token::Destination(alice), token::Expiration(lastClose(env) + 25)); - uint256 const offerBuyerSellsToAlice = keylet::nftokenOffer(buyer, env.seq(buyer)).key; + uint256 const offerBuyerSellsToAlice = + keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key; env(token::cancelOffer(alice, {offerBuyerSellsToAlice})); env.close(); @@ -6207,12 +6341,14 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // Alice creates one sell offer for each NFT // Verify the offer indexes are correct in the NFTokenCreateOffer tx // meta - uint256 const aliceOfferIndex1 = keylet::nftokenOffer(alice, env.seq(alice)).key; + uint256 const aliceOfferIndex1 = + keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key; env(token::createOffer(alice, nftId1, drops(1)), Txflags(tfSellNFToken)); env.close(); verifyNFTokenOfferID(aliceOfferIndex1); - uint256 const aliceOfferIndex2 = keylet::nftokenOffer(alice, env.seq(alice)).key; + uint256 const aliceOfferIndex2 = + keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key; env(token::createOffer(alice, nftId2, drops(1)), Txflags(tfSellNFToken)); env.close(); verifyNFTokenOfferID(aliceOfferIndex2); @@ -6226,7 +6362,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // Bobs creates a buy offer for nftId1 // Verify the offer id is correct in the NFTokenCreateOffer tx meta - auto const bobBuyOfferIndex = keylet::nftokenOffer(bob, env.seq(bob)).key; + auto const bobBuyOfferIndex = + keylet::nftokenOffer(bob, SeqProxy::rawSequence(env.seq(bob))).key; env(token::createOffer(bob, nftId1, drops(1)), token::Owner(alice)); env.close(); verifyNFTokenOfferID(bobBuyOfferIndex); @@ -6247,7 +6384,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite verifyNFTokenID(nftId); // Alice creates sell offer and set broker as destination - uint256 const offerAliceToBroker = keylet::nftokenOffer(alice, env.seq(alice)).key; + uint256 const offerAliceToBroker = + keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key; env(token::createOffer(alice, nftId, drops(1)), token::Destination(broker), Txflags(tfSellNFToken)); @@ -6255,7 +6393,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite verifyNFTokenOfferID(offerAliceToBroker); // Bob creates buy offer - uint256 const offerBobToBroker = keylet::nftokenOffer(bob, env.seq(bob)).key; + uint256 const offerBobToBroker = + keylet::nftokenOffer(bob, SeqProxy::rawSequence(env.seq(bob))).key; env(token::createOffer(bob, nftId, drops(1)), token::Owner(alice)); env.close(); verifyNFTokenOfferID(offerBobToBroker); @@ -6276,12 +6415,14 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite verifyNFTokenID(nftId); // Alice creates 2 sell offers for the same NFT - uint256 const aliceOfferIndex1 = keylet::nftokenOffer(alice, env.seq(alice)).key; + uint256 const aliceOfferIndex1 = + keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key; env(token::createOffer(alice, nftId, drops(1)), Txflags(tfSellNFToken)); env.close(); verifyNFTokenOfferID(aliceOfferIndex1); - uint256 const aliceOfferIndex2 = keylet::nftokenOffer(alice, env.seq(alice)).key; + uint256 const aliceOfferIndex2 = + keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key; env(token::createOffer(alice, nftId, drops(1)), Txflags(tfSellNFToken)); env.close(); verifyNFTokenOfferID(aliceOfferIndex2); @@ -6296,7 +6437,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite if (features[featureNFTokenMintOffer]) { uint256 const aliceMintWithOfferIndex1 = - keylet::nftokenOffer(alice, env.seq(alice)).key; + keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key; env(token::mint(alice), token::Amount(XRP(0))); env.close(); verifyNFTokenOfferID(aliceMintWithOfferIndex1); @@ -6319,7 +6460,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite env.close(); // acct makes an sell offer - uint256 const sellOfferIndex = keylet::nftokenOffer(acct, env.seq(acct)).key; + uint256 const sellOfferIndex = + keylet::nftokenOffer(acct, SeqProxy::rawSequence(env.seq(acct))).key; env(token::createOffer(acct, nftId, amt), Txflags(tfSellNFToken)); env.close(); @@ -6488,7 +6630,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite env.close(); // Bob makes a buy offer for 1 XRP - auto const buyOfferIndex = keylet::nftokenOffer(bob, env.seq(bob)).key; + auto const buyOfferIndex = + keylet::nftokenOffer(bob, SeqProxy::rawSequence(env.seq(bob))).key; env(token::createOffer(bob, nftId, XRP(1)), token::Owner(alice)); env.close(); @@ -6532,14 +6675,16 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite env.close(); // Alice creates sell offer and set broker as destination - uint256 const offerAliceToBroker = keylet::nftokenOffer(alice, env.seq(alice)).key; + uint256 const offerAliceToBroker = + keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key; env(token::createOffer(alice, nftId, XRP(1)), token::Destination(broker), Txflags(tfSellNFToken)); env.close(); // Bob creates buy offer - uint256 const offerBobToBroker = keylet::nftokenOffer(bob, env.seq(bob)).key; + uint256 const offerBobToBroker = + keylet::nftokenOffer(bob, SeqProxy::rawSequence(env.seq(bob))).key; env(token::createOffer(bob, nftId, XRP(1)), token::Owner(alice)); env.close(); @@ -6633,10 +6778,12 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // becky buys the nfts for 1 drop each. { - uint256 const beckyBuyOfferIndex1 = keylet::nftokenOffer(becky, env.seq(becky)).key; + uint256 const beckyBuyOfferIndex1 = + keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key; env(token::createOffer(becky, nftAutoTrustID, drops(1)), token::Owner(issuer)); - uint256 const beckyBuyOfferIndex2 = keylet::nftokenOffer(becky, env.seq(becky)).key; + uint256 const beckyBuyOfferIndex2 = + keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key; env(token::createOffer(becky, nftNoAutoTrustID, drops(1)), token::Owner(issuer)); env.close(); @@ -6647,7 +6794,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // becky creates offers to sell the nfts for AUD. uint256 const beckyAutoTrustOfferIndex = - keylet::nftokenOffer(becky, env.seq(becky)).key; + keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key; env(token::createOffer(becky, nftAutoTrustID, gwAUD(100)), Txflags(tfSellNFToken)); env.close(); @@ -6666,7 +6813,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite BEAST_EXPECT(ownerCount(env, issuer) == 1); uint256 const beckyNoAutoTrustOfferIndex = - keylet::nftokenOffer(becky, env.seq(becky)).key; + keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key; env(token::createOffer(becky, nftNoAutoTrustID, gwAUD(100)), Txflags(tfSellNFToken)); env.close(); @@ -6790,10 +6937,12 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // becky buys the nfts for 1 drop each. { - uint256 const beckyBuyOfferIndex1 = keylet::nftokenOffer(becky, env.seq(becky)).key; + uint256 const beckyBuyOfferIndex1 = + keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key; env(token::createOffer(becky, nftAutoTrustID, drops(1)), token::Owner(issuer)); - uint256 const beckyBuyOfferIndex2 = keylet::nftokenOffer(becky, env.seq(becky)).key; + uint256 const beckyBuyOfferIndex2 = + keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key; env(token::createOffer(becky, nftNoAutoTrustID, drops(1)), token::Owner(issuer)); env.close(); @@ -6821,7 +6970,7 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // However if the NFToken has the tfTrustLine flag set, // then becky can create the offer. uint256 const beckyAutoTrustOfferIndex = - keylet::nftokenOffer(becky, env.seq(becky)).key; + keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key; env(token::createOffer(becky, nftAutoTrustID, isISU(100)), Txflags(tfSellNFToken)); env.close(); @@ -6839,11 +6988,11 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite // With featureNFTokenMintOffer things go better. // becky creates offers to sell the nfts for ISU. uint256 const beckyNoAutoTrustOfferIndex = - keylet::nftokenOffer(becky, env.seq(becky)).key; + keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key; env(token::createOffer(becky, nftNoAutoTrustID, isISU(100)), Txflags(tfSellNFToken)); env.close(); uint256 const beckyAutoTrustOfferIndex = - keylet::nftokenOffer(becky, env.seq(becky)).key; + keylet::nftokenOffer(becky, SeqProxy::rawSequence(env.seq(becky))).key; env(token::createOffer(becky, nftAutoTrustID, isISU(100)), Txflags(tfSellNFToken)); env.close(); @@ -7077,7 +7226,8 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite checkURI(issuer, "uri", __LINE__); // Account != Owner - uint256 const offerID = keylet::nftokenOffer(issuer, env.seq(issuer)).key; + uint256 const offerID = + keylet::nftokenOffer(issuer, SeqProxy::rawSequence(env.seq(issuer))).key; env(token::createOffer(issuer, nftId, XRP(0)), Txflags(tfSellNFToken)); env.close(); env(token::acceptSellOffer(alice, offerID)); diff --git a/src/test/app/Offer_test.cpp b/src/test/app/Offer_test.cpp index 33721e91d8..500372bca3 100644 --- a/src/test/app/Offer_test.cpp +++ b/src/test/app/Offer_test.cpp @@ -38,6 +38,7 @@ #include #include #include +#include #include #include #include @@ -797,13 +798,15 @@ public: // The offer expires (it's not removed yet). env.close(); env.require(Owners(bob, 1), offers(bob, 1)); - auto const expiredBobOffer = keylet::offer(bob, env.seq(bob) - 1); + auto const expiredBobOffer = + keylet::offer(bob, SeqProxy::rawSequence(env.seq(bob) - 1)); // bob creates the offer that will be crossed. env(offer(bob, usd(500), XRP(500)), Ter(tesSUCCESS)); env.close(); env.require(Owners(bob, 2), offers(bob, 2)); - auto const crossedBobOffer = keylet::offer(bob, env.seq(bob) - 1); + auto const crossedBobOffer = + keylet::offer(bob, SeqProxy::rawSequence(env.seq(bob) - 1)); env(trust(alice, usd(1000)), Ter(tesSUCCESS)); env(pay(gw, alice, usd(1000)), Ter(tesSUCCESS)); @@ -850,7 +853,7 @@ public: env(offer(bob, usd(500), XRP(500)), Ter(tesSUCCESS)); env.close(); - auto const bobOffer = keylet::offer(bob, env.seq(bob) - 1); + auto const bobOffer = keylet::offer(bob, SeqProxy::rawSequence(env.seq(bob) - 1)); env(trust(alice, usd(1000)), Ter(tesSUCCESS)); env(pay(gw, alice, usd(1000)), Ter(tesSUCCESS)); diff --git a/src/test/app/PayChan_test.cpp b/src/test/app/PayChan_test.cpp index 592b0ef326..96fe094c62 100644 --- a/src/test/app/PayChan_test.cpp +++ b/src/test/app/PayChan_test.cpp @@ -40,6 +40,7 @@ #include #include #include +#include #include #include #include @@ -71,7 +72,8 @@ struct PayChan_test : public beast::unit_test::Suite auto const sle = view.read(keylet::account(account)); if (!sle) return {}; - auto const k = keylet::payChannel(account, dst, (*sle)[sfSequence] - 1); + auto const k = + keylet::payChannel(account, dst, SeqProxy::rawSequence((*sle)[sfSequence] - 1)); return {k.key, view.read(k)}; } diff --git a/src/test/app/PermissionedDEX_test.cpp b/src/test/app/PermissionedDEX_test.cpp index 68b2fa99a7..a7e4cd7615 100644 --- a/src/test/app/PermissionedDEX_test.cpp +++ b/src/test/app/PermissionedDEX_test.cpp @@ -35,6 +35,7 @@ #include #include #include +#include #include #include #include @@ -59,7 +60,8 @@ class PermissionedDEX_test : public beast::unit_test::Suite [[nodiscard]] static bool offerExists(Env const& env, Account const& account, std::uint32_t offerSeq) { - return static_cast(env.le(keylet::offer(account.id(), offerSeq))); + return static_cast( + env.le(keylet::offer(account.id(), SeqProxy::rawSequence(offerSeq)))); } [[nodiscard]] static bool @@ -84,11 +86,11 @@ class PermissionedDEX_test : public beast::unit_test::Suite auto const& indexes = page->getFieldV256(sfIndexes); return std::ranges::any_of(indexes, [&](auto const& index) { - return index == keylet::offer(account, offerSeq).key; + return index == keylet::offer(account, SeqProxy::rawSequence(offerSeq)).key; }); }; - auto const sle = env.le(keylet::offer(account.id(), offerSeq)); + auto const sle = env.le(keylet::offer(account.id(), SeqProxy::rawSequence(offerSeq))); if (!sle) return false; if (sle->getFieldAmount(sfTakerGets) != takerGets) @@ -147,7 +149,7 @@ class PermissionedDEX_test : public beast::unit_test::Suite static std::optional getDefaultOfferDirKey(Env const& env, Account const& account, std::uint32_t offerSeq) { - if (auto const sle = env.le(keylet::offer(account.id(), offerSeq))) + if (auto const sle = env.le(keylet::offer(account.id(), SeqProxy::rawSequence(offerSeq)))) return Keylet(ltDIR_NODE, (*sle)[sfBookDirectory]).key; return {}; @@ -1244,7 +1246,8 @@ class PermissionedDEX_test : public beast::unit_test::Suite env.close(); BEAST_EXPECT(checkOffer(env, bob, regularOfferSeq, XRP(10), USD(10))); - auto const sleHybridOffer = env.le(keylet::offer(bob.id(), hybridOfferSeq)); + auto const sleHybridOffer = + env.le(keylet::offer(bob.id(), SeqProxy::rawSequence(hybridOfferSeq))); if (!BEAST_EXPECT(sleHybridOffer)) return; auto const openDir = @@ -1277,7 +1280,8 @@ class PermissionedDEX_test : public beast::unit_test::Suite BEAST_EXPECT(offerExists(env, bob, regularOfferSeq)); BEAST_EXPECT(checkOffer(env, bob, regularOfferSeq, XRP(10), USD(10))); - auto const sleHybridOffer = env.le(keylet::offer(bob.id(), hybridOfferSeq)); + auto const sleHybridOffer = + env.le(keylet::offer(bob.id(), SeqProxy::rawSequence(hybridOfferSeq))); if (!BEAST_EXPECT(sleHybridOffer)) return; auto const openDir = @@ -1570,7 +1574,8 @@ class PermissionedDEX_test : public beast::unit_test::Suite env(offer(bob, XRP(10), USD(10)), Txflags(tfHybrid), Domain(domainID)); env.close(); - auto const sleOffer = env.le(keylet::offer(bob.id(), bobOfferSeq)); + auto const sleOffer = + env.le(keylet::offer(bob.id(), SeqProxy::rawSequence(bobOfferSeq))); BEAST_EXPECT(sleOffer); BEAST_EXPECT(sleOffer->getFieldH256(sfBookDirectory) == domainDir); BEAST_EXPECT(sleOffer->getFieldArray(sfAdditionalBooks).size() == 1); @@ -1666,7 +1671,7 @@ class PermissionedDEX_test : public beast::unit_test::Suite // Directly manipulate the offer SLE in the open ledger so that // sfAdditionalBooks is present but empty (size 0). This is the // malformed state that fixCleanup3_1_3 is designed to catch. - auto const offerKey = keylet::offer(bob.id(), bobOfferSeq); + auto const offerKey = keylet::offer(bob.id(), SeqProxy::rawSequence(bobOfferSeq)); env.app().getOpenLedger().modify([&offerKey](OpenView& view, beast::Journal) { auto const sle = view.read(offerKey); if (!sle) @@ -1735,7 +1740,7 @@ class PermissionedDEX_test : public beast::unit_test::Suite env.close(); // After crossing, Alice's remaining offer should be placed. - auto const sle = env.le(keylet::offer(alice_.id(), aliceOfferSeq)); + auto const sle = env.le(keylet::offer(alice_.id(), SeqProxy::rawSequence(aliceOfferSeq))); BEAST_EXPECT(sle); BEAST_EXPECT(sle->isFieldPresent(sfAdditionalBooks)); BEAST_EXPECT(sle->getFieldArray(sfAdditionalBooks).size() == 1); @@ -1873,7 +1878,8 @@ class PermissionedDEX_test : public beast::unit_test::Suite env(offer(setup.bob, XRP(100), setup.usd(40))); env.close(); - auto const sle = env.le(keylet::offer(setup.bob.id(), bobOfferSeq)); + auto const sle = + env.le(keylet::offer(setup.bob.id(), SeqProxy::rawSequence(bobOfferSeq))); BEAST_EXPECT(sle); auto const dirKey = sle->getFieldH256(sfBookDirectory); @@ -1907,7 +1913,8 @@ class PermissionedDEX_test : public beast::unit_test::Suite env(offer(alice_, USD(100), XRP(300)), Txflags(tfHybrid), Domain(domainID)); env.close(); - auto const sle = env.le(keylet::offer(alice_.id(), aliceOfferSeq)); + auto const sle = + env.le(keylet::offer(alice_.id(), SeqProxy::rawSequence(aliceOfferSeq))); BEAST_EXPECT(sle); auto const openDirKey = @@ -2023,7 +2030,7 @@ class PermissionedDEX_test : public beast::unit_test::Suite env.close(); BEAST_EXPECT(checkOffer(env, alice, oldSeq, USD(100), XRP(1), 0, true)); - auto const oldOffer = env.le(keylet::offer(alice.id(), oldSeq)); + auto const oldOffer = env.le(keylet::offer(alice.id(), SeqProxy::rawSequence(oldSeq))); if (!BEAST_EXPECT(oldOffer)) return; BEAST_EXPECT(oldOffer->getFieldH256(sfDomainID) == domainA); @@ -2038,7 +2045,7 @@ class PermissionedDEX_test : public beast::unit_test::Suite BEAST_EXPECT(!offerExists(env, alice, oldSeq)); BEAST_EXPECT(checkOffer(env, alice, newSeq, USD(100), XRP(2), 0, true)); - auto const newOffer = env.le(keylet::offer(alice.id(), newSeq)); + auto const newOffer = env.le(keylet::offer(alice.id(), SeqProxy::rawSequence(newSeq))); if (!BEAST_EXPECT(newOffer)) return; BEAST_EXPECT(newOffer->getFieldH256(sfDomainID) == domainB); diff --git a/src/test/app/PermissionedDomains_test.cpp b/src/test/app/PermissionedDomains_test.cpp index 784c2b4f56..1a2472b397 100644 --- a/src/test/app/PermissionedDomains_test.cpp +++ b/src/test/app/PermissionedDomains_test.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -552,11 +553,14 @@ class PermissionedDomains_test : public beast::unit_test::Suite auto domain = pdomain::getNewDomain(env.meta()); if (features[fixCleanup3_1_3]) { - BEAST_EXPECT(domain == keylet::permissionedDomain(alice.id(), seq).key); + BEAST_EXPECT( + domain == + keylet::permissionedDomain(alice.id(), SeqProxy::rawSequence(seq)).key); } else { - BEAST_EXPECT(domain == keylet::permissionedDomain(alice.id(), 0).key); + BEAST_EXPECT( + domain == keylet::permissionedDomain(alice.id(), SeqProxy::rawSequence(0)).key); } } diff --git a/src/test/app/Sponsor_test.cpp b/src/test/app/Sponsor_test.cpp index b1c762d733..bcd31bc6a0 100644 --- a/src/test/app/Sponsor_test.cpp +++ b/src/test/app/Sponsor_test.cpp @@ -49,6 +49,7 @@ #include #include #include +#include #include #include #include @@ -988,7 +989,8 @@ public: BEAST_EXPECT(sle->at(sfRemainingOwnerCount) == 99); BEAST_EXPECT(sle->at(sfFeeAmount) == XRP(99)); - env(check::cancel(alice, keylet::check(alice, checkSeq).key), Ter(tesSUCCESS)); + env(check::cancel(alice, keylet::check(alice, SeqProxy::rawSequence(checkSeq)).key), + Ter(tesSUCCESS)); env.close(); sle = env.le(keylet::sponsorship(sponsor, alice)); @@ -1377,7 +1379,7 @@ public: env(check::create(alice, bob, XRP(1))); env.close(); - auto const checkId = keylet::check(alice, seq).key; + auto const checkId = keylet::check(alice, SeqProxy::rawSequence(seq)).key; BEAST_EXPECT(env.le(keylet::unchecked(checkId)) != nullptr); env(sponsor::transfer(alice, tfSponsorshipCreate, checkId), @@ -1390,7 +1392,8 @@ public: env.close(); // Invalid ObjectID (not found) - env(sponsor::transfer(alice, tfSponsorshipCreate, keylet::check(alice, 0).key), + env(sponsor::transfer( + alice, tfSponsorshipCreate, keylet::check(alice, SeqProxy::rawSequence(0)).key), sponsor::As(sponsor1, spfSponsorReserve), Sig(sfSponsorSignature, sponsor1), Ter(tecNO_ENTRY)); @@ -1497,7 +1500,7 @@ public: auto const ticketSeq = env.seq(alice); env(ticket::create(alice, 1)); env.close(); - auto ticketId = keylet::ticket(alice, ticketSeq + 1).key; + auto ticketId = keylet::ticket(alice, SeqProxy::rawTicket(ticketSeq + 1)).key; BEAST_EXPECT(env.le(keylet::unchecked(ticketId))); env(sponsor::transfer(alice, tfSponsorshipEnd, ticketId), Ter(tecNO_PERMISSION)); env.close(); @@ -1518,7 +1521,7 @@ public: env(check::create(alice, bob, XRP(1))); env.close(); - auto const checkId = keylet::check(alice, seq).key; + auto const checkId = keylet::check(alice, SeqProxy::rawSequence(seq)).key; BEAST_EXPECT(env.le(keylet::unchecked(checkId)) != nullptr); env(sponsor::transfer(alice, tfSponsorshipCreate, checkId), @@ -1552,7 +1555,7 @@ public: env(check::create(alice, bob, XRP(1))); env.close(); - auto const checkId = keylet::check(alice, seq).key; + auto const checkId = keylet::check(alice, SeqProxy::rawSequence(seq)).key; BEAST_EXPECT(env.le(keylet::unchecked(checkId)) != nullptr); // insufficient reserve count @@ -1654,7 +1657,7 @@ public: env(check::create(alice, bob, XRP(1))); env.close(); - auto const checkId = keylet::check(alice, seq).key; + auto const checkId = keylet::check(alice, SeqProxy::rawSequence(seq)).key; BEAST_EXPECT(env.le(keylet::unchecked(checkId)) != nullptr); env(sponsor::transfer(alice, tfSponsorshipCreate, checkId), @@ -1694,7 +1697,7 @@ public: env(check::create(alice, bob, XRP(1))); env.close(); - auto const checkId = keylet::check(alice, seq).key; + auto const checkId = keylet::check(alice, SeqProxy::rawSequence(seq)).key; BEAST_EXPECT(env.le(keylet::unchecked(checkId)) != nullptr); env(sponsor::transfer(alice, tfSponsorshipCreate, checkId), @@ -1839,7 +1842,7 @@ public: auto const ticketSeq = env.seq(alice); env(ticket::create(alice, 1)); env.close(); - auto const ticketID = keylet::ticket(alice, ticketSeq + 1).key; + auto const ticketID = keylet::ticket(alice, SeqProxy::rawTicket(ticketSeq + 1)).key; BEAST_EXPECT(env.le(keylet::unchecked(ticketID))); checkBlocked(alice, ticketID); @@ -1869,7 +1872,8 @@ public: {.depositor = alice, .id = vaultKeylet.key, .amount = xrpAsset(1000)})); env.close(); - auto const brokerKeylet = keylet::loanBroker(alice.id(), env.seq(alice)); + auto const brokerKeylet = + keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice))); env(loan_broker::set(alice, vaultKeylet.key), loan_broker::kDebtMaximum(xrpAsset(1000).value()), loan_broker::kManagementFeeRate(TenthBips16{0}), @@ -1877,7 +1881,7 @@ public: loan_broker::kCoverRateLiquidation(TenthBips32{0})); env.close(); - auto const loanKeylet = keylet::loan(brokerKeylet.key, 1); + auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1)); env(loan::set(borrower, brokerKeylet.key, xrpAsset(100).value()), Sig(sfCounterpartySignature, alice), Fee(env.current()->fees().base * 2)); @@ -2803,7 +2807,7 @@ public: BEAST_EXPECT(sponsoringOwnerCount(env, alice) == 0); BEAST_EXPECT(sponsoringOwnerCount(env, sponsor) == 1); - auto const keylet = keylet::check(alice, seq); + auto const keylet = keylet::check(alice, SeqProxy::rawSequence(seq)); BEAST_EXPECT(env.le(keylet)->getAccountID(sfSponsor) == sponsor.id()); if (cosigning) @@ -2867,7 +2871,7 @@ public: BEAST_EXPECT(sponsoredOwnerCount(env, bob) == 0); // CheckCash - auto const checkId2 = keylet::check(alice, seq2).key; + auto const checkId2 = keylet::check(alice, SeqProxy::rawSequence(seq2)).key; env(check::cash(bob, checkId2, XRP(1))); env.close(); @@ -2907,7 +2911,7 @@ public: BEAST_EXPECT(ownerCount(env, bob) == 0); BEAST_EXPECT(sponsoredOwnerCount(env, bob) == 0); - auto const keylet = keylet::check(alice, seq2); + auto const keylet = keylet::check(alice, SeqProxy::rawSequence(seq2)); BEAST_EXPECT(env.le(keylet)->getAccountID(sfSponsor) == sponsor.id()); // CheckCash @@ -2973,7 +2977,7 @@ public: submit(check::create(alice, bob, mpt(1))); }); - auto const checkKeylet = keylet::check(alice, seq2); + auto const checkKeylet = keylet::check(alice, SeqProxy::rawSequence(seq2)); BEAST_EXPECT(env.le(checkKeylet)->getAccountID(sfSponsor) == sponsor.id()); BEAST_EXPECT(ownerCount(env, bob) == 0); @@ -3382,12 +3386,16 @@ public: escrow::kCancelTime(env.now() + 100s)); }); BEAST_EXPECT( - env.le(keylet::escrow(alice, seq))->getAccountID(sfSponsor) == sponsor.id()); + env.le(keylet::escrow(alice, SeqProxy::rawSequence(seq))) + ->getAccountID(sfSponsor) == sponsor.id()); // transfer sponsor if (cosigning) { - env(sponsor::transfer(alice, tfSponsorshipReassign, keylet::escrow(alice, seq).key), + env(sponsor::transfer( + alice, + tfSponsorshipReassign, + keylet::escrow(alice, SeqProxy::rawSequence(seq)).key), sponsor::As(sponsor2, spfSponsorReserve), Sig(sfSponsorSignature, sponsor2)); env.close(); @@ -3397,7 +3405,10 @@ public: env(sponsor::set_reserve(sponsor2, 0, 1), sponsor::SponseeAcc(alice)); env.close(); - env(sponsor::transfer(alice, tfSponsorshipReassign, keylet::escrow(alice, seq).key), + env(sponsor::transfer( + alice, + tfSponsorshipReassign, + keylet::escrow(alice, SeqProxy::rawSequence(seq)).key), sponsor::As(sponsor2, spfSponsorReserve)); env.close(); } @@ -3408,7 +3419,8 @@ public: BEAST_EXPECT(sponsoringOwnerCount(env, sponsor2) == 1); BEAST_EXPECT( - env.le(keylet::escrow(alice, seq))->getAccountID(sfSponsor) == sponsor2.id()); + env.le(keylet::escrow(alice, SeqProxy::rawSequence(seq))) + ->getAccountID(sfSponsor) == sponsor2.id()); // EscrowFinish env(escrow::finish(bob, alice, seq), @@ -3462,7 +3474,8 @@ public: }); BEAST_EXPECT( - env.le(keylet::escrow(alice, seq))->getAccountID(sfSponsor) == sponsor.id()); + env.le(keylet::escrow(alice, SeqProxy::rawSequence(seq))) + ->getAccountID(sfSponsor) == sponsor.id()); // EscrowFinish testEachSponsorship( @@ -3524,7 +3537,8 @@ public: }); BEAST_EXPECT( - env.le(keylet::escrow(alice, seq))->getAccountID(sfSponsor) == sponsor.id()); + env.le(keylet::escrow(alice, SeqProxy::rawSequence(seq))) + ->getAccountID(sfSponsor) == sponsor.id()); if (cosigning) { @@ -3625,7 +3639,7 @@ public: tecNO_LINE_INSUF_RESERVE, [&](Env& env, auto const& submit) { submit(escrow::cancel(alice, alice, seq)); }, [&]() { - BEAST_EXPECT(!env.le(keylet::escrow(alice, seq))); + BEAST_EXPECT(!env.le(keylet::escrow(alice, SeqProxy::rawSequence(seq)))); auto const trustSle = env.le(keylet::trustLine(alice, gw, usd.currency)); BEAST_EXPECT(trustSle); if (trustSle) @@ -3728,7 +3742,8 @@ public: }); BEAST_EXPECT( - env.le(keylet::escrow(alice, seq))->getAccountID(sfSponsor) == sponsor.id()); + env.le(keylet::escrow(alice, SeqProxy::rawSequence(seq))) + ->getAccountID(sfSponsor) == sponsor.id()); if (cosigning) { @@ -3810,7 +3825,7 @@ public: } env.close(); - BEAST_EXPECT(!env.le(keylet::escrow(alice, seq))); + BEAST_EXPECT(!env.le(keylet::escrow(alice, SeqProxy::rawSequence(seq)))); BEAST_EXPECT(ownerCount(env, alice) == 0); BEAST_EXPECT(sponsoredOwnerCount(env, alice) == 0); BEAST_EXPECT(sponsoringOwnerCount(env, sponsor) == 0); @@ -4754,7 +4769,7 @@ public: env(check::create(alice, bob, XRP(1))); env.close(); - auto const keylet = keylet::check(alice, seq); + auto const keylet = keylet::check(alice, SeqProxy::rawSequence(seq)); env(sponsor::transfer(alice, tfSponsorshipCreate, keylet.key), sponsor::As(bob, spfSponsorReserve), @@ -5491,14 +5506,14 @@ public: if (expected == tesSUCCESS) { - BEAST_EXPECT(!env.le(keylet::escrow(alice, seq))); + BEAST_EXPECT(!env.le(keylet::escrow(alice, SeqProxy::rawSequence(seq)))); BEAST_EXPECT(env.le(keylet::trustLine(alice, gw, usd.currency))); BEAST_EXPECT(env.balance(alice, usd) == usd(100)); BEAST_EXPECT(ownerCount(env, alice) == 1); // the new line } else { - BEAST_EXPECT(env.le(keylet::escrow(alice, seq))); + BEAST_EXPECT(env.le(keylet::escrow(alice, SeqProxy::rawSequence(seq)))); BEAST_EXPECT(!env.le(keylet::trustLine(alice, gw, usd.currency))); BEAST_EXPECT(ownerCount(env, alice) == 1); // still the escrow } @@ -5598,7 +5613,8 @@ public: BEAST_EXPECT(sponsorCountBefore == 1); // check costs 1 owner count // Cancel (delete) the check. - env(check::cancel(checkOwner, keylet::check(checkOwner, checkSeq).key)); + env(check::cancel( + checkOwner, keylet::check(checkOwner, SeqProxy::rawSequence(checkSeq)).key)); env.close(); auto sponsorCountAfter = sponsoringOwnerCount(env, sponsor); diff --git a/src/test/app/TxQ_test.cpp b/src/test/app/TxQ_test.cpp index 1fe48add27..5b257449e0 100644 --- a/src/test/app/TxQ_test.cpp +++ b/src/test/app/TxQ_test.cpp @@ -2889,7 +2889,7 @@ public: checkMetrics(*this, env, 5, std::nullopt, 7, 6); { auto aliceStat = txQ.getAccountTxs(alice.id()); - SeqProxy seq = SeqProxy::sequence(aliceSeq); + SeqProxy seq = SeqProxy::rawSequence(aliceSeq); BEAST_EXPECT(aliceStat.size() == 5); for (auto const& tx : aliceStat) { @@ -3754,7 +3754,7 @@ public: checkMetrics(*this, env, 2, 24, 16, 12); auto const aliceQueue = env.app().getTxQ().getAccountTxs(alice.id()); BEAST_EXPECT(aliceQueue.size() == 2); - SeqProxy seq = SeqProxy::sequence(aliceSeq); + SeqProxy seq = SeqProxy::rawSequence(aliceSeq); for (auto const& tx : aliceQueue) { BEAST_EXPECT(tx.seqProxy == seq); diff --git a/src/test/app/Vault_test.cpp b/src/test/app/Vault_test.cpp index 773ce28963..70527f570d 100644 --- a/src/test/app/Vault_test.cpp +++ b/src/test/app/Vault_test.cpp @@ -51,6 +51,7 @@ #include #include #include +#include #include #include #include @@ -3125,7 +3126,7 @@ class Vault_test : public beast::unit_test::Suite Vault const vault{env}; env.fund(XRP(1000), owner); - auto const keylet = keylet::vault(owner.id(), env.seq(owner)); + auto const keylet = keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner))); for (int i = 0; i < 256; ++i) { AccountID const accountId = xrpl::pseudoAccountAddress(*env.current(), keylet.key); @@ -3947,7 +3948,8 @@ class Vault_test : public beast::unit_test::Suite BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(1000)); // Create a loan broker backed by this vault - auto const brokerKeylet = keylet::loanBroker(d.owner.id(), env.seq(d.owner)); + auto const brokerKeylet = + keylet::loanBroker(d.owner.id(), SeqProxy::rawSequence(env.seq(d.owner))); env(set(d.owner, d.keylet.key)); env.close(); @@ -4461,12 +4463,13 @@ class Vault_test : public beast::unit_test::Suite env.close(); auto const& sharesAvailable = vaultShareBalance(vaultKeylet); - auto const& brokerKeylet = keylet::loanBroker(owner.id(), env.seq(owner)); + auto const& brokerKeylet = + keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); env(set(owner, vaultKeylet.key)); env.close(); - auto const& loanKeylet = keylet::loan(brokerKeylet.key, 1); + auto const& loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1)); // Create a simple Loan for the full amount of Vault assets env(set(depositor, brokerKeylet.key, asset(100).value()), @@ -4854,7 +4857,8 @@ class Vault_test : public beast::unit_test::Suite PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID)); // Create a loan broker backed by this vault - auto const brokerKeylet = keylet::loanBroker(owner.id(), env.seq(owner)); + auto const brokerKeylet = + keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); env(set(owner, vaultKeylet.key)); env.close(); @@ -4912,7 +4916,8 @@ class Vault_test : public beast::unit_test::Suite PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID)); // Create a loan broker backed by this vault - auto const brokerKeylet = keylet::loanBroker(owner.id(), env.seq(owner)); + auto const brokerKeylet = + keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); env(set(owner, vaultKeylet.key)); env.close(); @@ -4967,7 +4972,8 @@ class Vault_test : public beast::unit_test::Suite PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID)); // Create a loan broker backed by this vault - auto const brokerKeylet = keylet::loanBroker(owner.id(), env.seq(owner)); + auto const brokerKeylet = + keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); env(set(owner, vaultKeylet.key)); env.close(); @@ -5021,7 +5027,8 @@ class Vault_test : public beast::unit_test::Suite return; PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID)); - auto const brokerKeylet = keylet::loanBroker(owner.id(), env.seq(owner)); + auto const brokerKeylet = + keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); env(set(owner, vaultKeylet.key)); env.close(); @@ -5069,7 +5076,8 @@ class Vault_test : public beast::unit_test::Suite return; PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID)); - auto const brokerKeylet = keylet::loanBroker(owner.id(), env.seq(owner)); + auto const brokerKeylet = + keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); env(set(owner, vaultKeylet.key)); env.close(); @@ -5176,7 +5184,8 @@ class Vault_test : public beast::unit_test::Suite PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID)); // Create a loan broker backed by this vault - auto const brokerKeylet = keylet::loanBroker(owner.id(), env.seq(owner)); + auto const brokerKeylet = + keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); env(set(owner, vaultKeylet.key)); env.close(); @@ -5310,7 +5319,7 @@ class Vault_test : public beast::unit_test::Suite "invalidParamsField 'tx_json.AssetsMaximum' has invalid data."); } - auto const newKeylet = keylet::vault(owner.id(), env.seq(owner)); + auto const newKeylet = keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner))); try { auto const insertAt = maxInt64Plus2.size() - 3; @@ -5378,7 +5387,7 @@ class Vault_test : public beast::unit_test::Suite "invalidParamsField 'tx_json.AssetsMaximum' has invalid data."); } - auto const newKeylet = keylet::vault(owner.id(), env.seq(owner)); + auto const newKeylet = keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner))); try { auto const insertAt = maxInt64Plus2.size() - 1; @@ -5450,7 +5459,8 @@ class Vault_test : public beast::unit_test::Suite // These values will be rounded to 15 significant digits { - auto const newKeylet = keylet::vault(owner.id(), env.seq(owner)); + auto const newKeylet = + keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner))); try { auto const insertAt = maxInt64Plus2.size() - 1; @@ -5474,7 +5484,8 @@ class Vault_test : public beast::unit_test::Suite } { tx[sfAssetsMaximum] = "9223372036854775807e40"; // max int64 * 10^40 - auto const newKeylet = keylet::vault(owner.id(), env.seq(owner)); + auto const newKeylet = + keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner))); env(tx); env.close(); @@ -5488,7 +5499,8 @@ class Vault_test : public beast::unit_test::Suite } { tx[sfAssetsMaximum] = "9223372036854775807e-40"; // max int64 * 10^-40 - auto const newKeylet = keylet::vault(owner.id(), env.seq(owner)); + auto const newKeylet = + keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner))); env(tx); env.close(); @@ -5502,7 +5514,8 @@ class Vault_test : public beast::unit_test::Suite } { tx[sfAssetsMaximum] = "9223372036854775807e-100"; // max int64 * 10^-100 - auto const newKeylet = keylet::vault(owner.id(), env.seq(owner)); + auto const newKeylet = + keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner))); env(tx); env.close(); @@ -6088,7 +6101,8 @@ class Vault_test : public beast::unit_test::Suite env.close(); // Loan broker: no cover, no management fee, debt cap 10x principal. - f.brokerID = keylet::loanBroker(f.lender.id(), env.seq(f.lender)).key; + f.brokerID = + keylet::loanBroker(f.lender.id(), SeqProxy::rawSequence(env.seq(f.lender))).key; { using namespace loan_broker; env(set(f.lender, vaultKeylet.key), @@ -6100,7 +6114,8 @@ class Vault_test : public beast::unit_test::Suite auto const sleBroker = env.le(keylet::loanBroker(f.brokerID)); if (!BEAST_EXPECT(sleBroker)) return f; - f.loanKeylet = keylet::loan(f.brokerID, sleBroker->at(sfLoanSequence)); + f.loanKeylet = + keylet::loan(f.brokerID, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence))); { using namespace loan; @@ -7587,7 +7602,7 @@ class Vault_test : public beast::unit_test::Suite Vault const vault{env}; - auto const keylet = keylet::vault(owner.id(), 1); + auto const keylet = keylet::vault(owner.id(), SeqProxy::rawSequence(1)); auto delTx = vault.del({.owner = owner, .id = keylet.key}); // Test VaultDelete with featureLendingProtocolV1_1 disabled @@ -7619,7 +7634,7 @@ class Vault_test : public beast::unit_test::Suite { testcase("VaultDelete memo data featureLendingProtocolV1_1 enabled no vault"); - auto const keylet = keylet::vault(owner.id(), env.seq(owner)); + auto const keylet = keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner))); // Recreate the transaction as the vault keylet changed auto delTx = vault.del({.owner = owner, .id = keylet.key}); diff --git a/src/test/app/lending/LoanBroker_test.cpp b/src/test/app/lending/LoanBroker_test.cpp index a610dbe931..c5efb59194 100644 --- a/src/test/app/lending/LoanBroker_test.cpp +++ b/src/test/app/lending/LoanBroker_test.cpp @@ -41,6 +41,7 @@ #include #include #include +#include #include #include #include @@ -96,7 +97,8 @@ class LoanBroker_test : public beast::unit_test::Suite using namespace loan_broker; // Can't create a loan broker regardless of whether the vault exists env(set(alice, keylet.key), Ter(temDISABLED)); - auto const brokerKeylet = keylet::loanBroker(alice.id(), env.seq(alice)); + auto const brokerKeylet = + keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice))); // Other LoanBroker transactions are disabled, too. // 1. LoanBrokerCoverDeposit env(coverDeposit(alice, brokerKeylet.key, asset(1000)), Ter(temDISABLED)); @@ -182,7 +184,8 @@ class LoanBroker_test : public beast::unit_test::Suite static PrettyAsset const kGhostIouAsset = kNonExistent["GST"]; PrettyAsset const vaultPseudoIouAsset = vault.pseudoAccount["PSD"]; - auto const badKeylet = keylet::loanBroker(alice.id(), env.seq(alice)); + auto const badKeylet = + keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice))); env(set(alice, badVault.vaultID)); env.close(); auto const badBrokerPseudo = [&]() { @@ -195,7 +198,7 @@ class LoanBroker_test : public beast::unit_test::Suite }(); PrettyAsset const badBrokerPseudoIouAsset = badBrokerPseudo["WAT"]; - auto const keylet = keylet::loanBroker(alice.id(), env.seq(alice)); + auto const keylet = keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice))); { // Start with default values auto jtx = env.jt(set(alice, vault.vaultID)); @@ -650,7 +653,7 @@ class LoanBroker_test : public beast::unit_test::Suite TenthBips32 const tenthBipsZero{0}; - auto badKeylet = keylet::vault(alice.id(), env.seq(alice)); + auto badKeylet = keylet::vault(alice.id(), SeqProxy::rawSequence(env.seq(alice))); // Try some failure cases // not the vault owner env(set(evan, vault.vaultID), Ter(tecNO_PERMISSION)); @@ -741,7 +744,8 @@ class LoanBroker_test : public beast::unit_test::Suite // Modifications // Update the fields - auto const nextKeylet = keylet::loanBroker(alice.id(), env.seq(alice)); + auto const nextKeylet = + keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice))); // fields that can't be changed // LoanBrokerID @@ -897,7 +901,8 @@ class LoanBroker_test : public beast::unit_test::Suite env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = asset(50)})); env.close(); - auto const brokerKeylet = keylet::loanBroker(alice.id(), env.seq(alice)); + auto const brokerKeylet = + keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice))); env(set(alice, vaultInfo.vaultID)); env.close(); @@ -1040,7 +1045,7 @@ class LoanBroker_test : public beast::unit_test::Suite env(del(alice, brokerKeylet.key), Ter(tecHAS_OBLIGATIONS)); // Repay and delete the loan - auto const loanKeylet = keylet::loan(brokerKeylet.key, 1); + auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1)); env(loan::pay(borrower, loanKeylet.key, asset(50).value())); env(loan::del(alice, loanKeylet.key)); @@ -1217,7 +1222,8 @@ class LoanBroker_test : public beast::unit_test::Suite env.close(); // Predict LoanBroker key using alice's current sequence BEFORE submit - auto const brokerKeylet = keylet::loanBroker(alice.id(), env.seq(alice)); + auto const brokerKeylet = + keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice))); // Create LoanBroker pointing to the vault env(loan_broker::set(alice, vaultKeylet.key)); @@ -1323,7 +1329,8 @@ class LoanBroker_test : public beast::unit_test::Suite err); }); - auto const brokerKeylet = keylet::loanBroker(alice.id(), env.seq(alice)); + auto const brokerKeylet = + keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice))); // Can create LoanBroker if the vault owner is not authorized forUnauthAuth([&](auto) { env(set(alice, vaultInfo.vaultID)); }); @@ -1401,7 +1408,8 @@ class LoanBroker_test : public beast::unit_test::Suite env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = asset(50)})); env.close(); - auto const brokerKeylet = keylet::loanBroker(alice.id(), env.seq(alice)); + auto const brokerKeylet = + keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice))); env(set(alice, vaultInfo.vaultID)); env.close(); @@ -1548,7 +1556,8 @@ class LoanBroker_test : public beast::unit_test::Suite Ter(err)); env.close(); - auto const brokerKeylet = keylet::loanBroker(broker, env.seq(broker)); + auto const brokerKeylet = + keylet::loanBroker(broker, SeqProxy::rawSequence(env.seq(broker))); env(loan_broker::set(broker, keylet.key)); env.close(); @@ -1662,7 +1671,8 @@ class LoanBroker_test : public beast::unit_test::Suite env.close(); // Create loan broker - auto const brokerKeylet = keylet::loanBroker(alice.id(), env.seq(alice)); + auto const brokerKeylet = + keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice))); env(set(alice, vaultKeylet.key)); env.close(); @@ -1779,7 +1789,8 @@ class LoanBroker_test : public beast::unit_test::Suite env.close(); // Create loan broker - auto const brokerKeylet = keylet::loanBroker(alice.id(), env.seq(alice)); + auto const brokerKeylet = + keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice))); env(set(alice, vaultKeylet.key)); env.close(); @@ -1856,7 +1867,8 @@ class LoanBroker_test : public beast::unit_test::Suite env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = asset(50)})); env.close(); - auto const brokerKeylet = keylet::loanBroker(alice.id(), env.seq(alice)); + auto const brokerKeylet = + keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice))); env(set(alice, vaultKeylet.key)); env.close(); @@ -1926,7 +1938,8 @@ class LoanBroker_test : public beast::unit_test::Suite env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = mpt(50)})); env.close(); - auto const brokerKeylet = keylet::loanBroker(alice.id(), env.seq(alice)); + auto const brokerKeylet = + keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice))); env(set(alice, vaultKeylet.key)); env.close(); @@ -2006,7 +2019,8 @@ class LoanBroker_test : public beast::unit_test::Suite env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = asset(50)})); env.close(); - auto const brokerKeylet = keylet::loanBroker(alice.id(), env.seq(alice)); + auto const brokerKeylet = + keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice))); env(set(alice, vaultKeylet.key)); env.close(); @@ -2069,7 +2083,8 @@ class LoanBroker_test : public beast::unit_test::Suite env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = asset(50)})); env.close(); - auto const brokerKeylet = keylet::loanBroker(alice.id(), env.seq(alice)); + auto const brokerKeylet = + keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice))); env(set(alice, vaultKeylet.key)); env.close(); @@ -2184,7 +2199,8 @@ class LoanBroker_test : public beast::unit_test::Suite env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = mpt(50)})); env.close(); - auto const brokerKeylet = keylet::loanBroker(alice.id(), env.seq(alice)); + auto const brokerKeylet = + keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice))); env(set(alice, vaultKeylet.key)); env.close(); @@ -2359,7 +2375,8 @@ class LoanBroker_test : public beast::unit_test::Suite env(vault.withdraw({.depositor = broker, .id = keylet.key, .amount = token(1'000)})); // Test LoanBroker withdraw - auto const brokerKeylet = keylet::loanBroker(broker, env.seq(broker)); + auto const brokerKeylet = + keylet::loanBroker(broker, SeqProxy::rawSequence(env.seq(broker))); env(loan_broker::set(broker, keylet.key)); env.close(); @@ -2487,7 +2504,8 @@ class LoanBroker_test : public beast::unit_test::Suite } // Test LoanBroker withdraw - auto const brokerKeylet = keylet::loanBroker(broker, env.seq(broker)); + auto const brokerKeylet = + keylet::loanBroker(broker, SeqProxy::rawSequence(env.seq(broker))); env(loan_broker::set(broker, keylet.key)); env.close(); @@ -2551,7 +2569,8 @@ class LoanBroker_test : public beast::unit_test::Suite env(createTx); env.close(); - auto const brokerKeylet = keylet::loanBroker(alice.id(), env.seq(alice)); + auto const brokerKeylet = + keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice))); env(set(alice, vaultKeylet.key)); env.close(); @@ -2698,7 +2717,8 @@ class LoanBroker_test : public beast::unit_test::Suite env(createTx); env.close(); - auto const brokerKeylet = keylet::loanBroker(alice.id(), env.seq(alice)); + auto const brokerKeylet = + keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice))); env(set(alice, vaultKeylet.key)); env.close(); diff --git a/src/test/app/lending/LoanCashBasis_test.cpp b/src/test/app/lending/LoanCashBasis_test.cpp index c1334180c1..11053b6fd0 100644 --- a/src/test/app/lending/LoanCashBasis_test.cpp +++ b/src/test/app/lending/LoanCashBasis_test.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include @@ -86,7 +87,8 @@ private: Number const debtTotalBefore = brokerBefore->at(sfDebtTotal); auto const loanSequence = brokerBefore->at(sfLoanSequence); - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + auto const loanKeylet = + keylet::loan(broker.brokerID, SeqProxy::rawSequence(loanSequence)); env(set(borrower, broker.brokerID, xrpAsset(principalRequest).value()), kCounterparty(lender), @@ -309,7 +311,8 @@ private: auto const brokerBeforeLoan = env.le(broker.brokerKeylet()); BEAST_EXPECT(brokerBeforeLoan); auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence); - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + auto const loanKeylet = + keylet::loan(broker.brokerID, SeqProxy::rawSequence(loanSequence)); env(loanParams(env, broker)); env.close(); @@ -530,7 +533,8 @@ private: auto const brokerBeforeLoan = env.le(broker.brokerKeylet()); BEAST_EXPECT(brokerBeforeLoan); auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence); - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + auto const loanKeylet = + keylet::loan(broker.brokerID, SeqProxy::rawSequence(loanSequence)); env(loanParams(env, broker)); env.close(); @@ -735,7 +739,7 @@ private: auto const brokerBeforeLoan = env.le(broker.brokerKeylet()); BEAST_EXPECT(brokerBeforeLoan); auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence); - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + auto const loanKeylet = keylet::loan(broker.brokerID, SeqProxy::rawSequence(loanSequence)); // ---- LoanSet origination: whole-life formulas expected ---- auto const vaultBeforeSet = env.le(broker.vaultKeylet()); @@ -943,7 +947,7 @@ private: auto const brokerBeforeLoan = env.le(brokerKeylet); BEAST_EXPECT(brokerBeforeLoan); auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence); - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + auto const loanKeylet = keylet::loan(broker.brokerID, SeqProxy::rawSequence(loanSequence)); LoanParameters const loanParams{ .account = borrower, diff --git a/src/test/app/lending/LoanCoverFreezeAuth_test.cpp b/src/test/app/lending/LoanCoverFreezeAuth_test.cpp index ec0238c173..a9b3542c4e 100644 --- a/src/test/app/lending/LoanCoverFreezeAuth_test.cpp +++ b/src/test/app/lending/LoanCoverFreezeAuth_test.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -90,7 +91,7 @@ private: env(loanATx); env.close(); - auto const loanAKeylet = keylet::loan(brokerKeylet.key, 1); + auto const loanAKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1)); // Create Loan B auto loanBTx = env.jt( @@ -104,7 +105,7 @@ private: env(loanBTx); env.close(); - auto const loanBKeylet = keylet::loan(brokerKeylet.key, 2); + auto const loanBKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(2)); auto loanASle = env.le(loanAKeylet); if (!BEAST_EXPECT(loanASle)) @@ -224,7 +225,8 @@ private: if (!BEAST_EXPECT(sleBroker)) return; - auto const loanKeylet = keylet::loan(broker.brokerID, sleBroker->at(sfLoanSequence)); + auto const loanKeylet = + keylet::loan(broker.brokerID, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence))); env(set(borrower, broker.brokerID, kPrincipalAmount), Sig(sfCounterpartySignature, lender), @@ -324,7 +326,7 @@ private: BEAST_EXPECT(getCoverBalance(brokerInfo, sfAccount) == iou(1'000)); - auto const keylet = keylet::loan(brokerInfo.brokerID, 1); + auto const keylet = keylet::loan(brokerInfo.brokerID, SeqProxy::rawSequence(1)); env(set(borrower, brokerInfo.brokerID, 10'000), Sig(sfCounterpartySignature, broker), @@ -388,7 +390,7 @@ private: // Create vault and broker auto const brokerInfo = createVaultAndBroker(env, iou, broker); // Create a loan first (this creates debt) - auto const keylet = keylet::loan(brokerInfo.brokerID, 1); + auto const keylet = keylet::loan(brokerInfo.brokerID, SeqProxy::rawSequence(1)); env(set(borrower, brokerInfo.brokerID, 10'000), Sig(sfCounterpartySignature, broker), kLoanServiceFee(iou(100).value()), @@ -468,7 +470,7 @@ private: // Create vault and broker auto const brokerInfo = createVaultAndBroker(env, mpt, broker); // Create a loan first (this creates debt) - auto const keylet = keylet::loan(brokerInfo.brokerID, 1); + auto const keylet = keylet::loan(brokerInfo.brokerID, SeqProxy::rawSequence(1)); env(set(borrower, brokerInfo.brokerID, 10'000), Sig(sfCounterpartySignature, broker), kLoanServiceFee(mpt(100).value()), @@ -567,7 +569,7 @@ private: // Create vault and broker auto const brokerInfo = createVaultAndBroker(env, mpt, broker); // Create a loan first (this creates debt) - auto const keylet = keylet::loan(brokerInfo.brokerID, 1); + auto const keylet = keylet::loan(brokerInfo.brokerID, SeqProxy::rawSequence(1)); env(set(borrower, brokerInfo.brokerID, 10'000), Sig(sfCounterpartySignature, broker), kLoanServiceFee(mpt(100).value()), diff --git a/src/test/app/lending/LoanInvariants_test.cpp b/src/test/app/lending/LoanInvariants_test.cpp index 381a3f8b48..264dbdcd24 100644 --- a/src/test/app/lending/LoanInvariants_test.cpp +++ b/src/test/app/lending/LoanInvariants_test.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include @@ -542,7 +543,8 @@ private: if (!BEAST_EXPECT(brokerSle1)) return std::nullopt; auto const tinyLoanSeq = brokerSle1->at(sfLoanSequence); - auto const tinyLoanKeylet = keylet::loan(c.broker.brokerID, tinyLoanSeq); + auto const tinyLoanKeylet = + keylet::loan(c.broker.brokerID, SeqProxy::rawSequence(tinyLoanSeq)); env(set(c.borrower, c.broker.brokerID, Number{1, -2}), Sig(sfCounterpartySignature, c.lender), @@ -559,7 +561,8 @@ private: if (!BEAST_EXPECT(brokerSle2)) return std::nullopt; auto const bigLoanSeq = brokerSle2->at(sfLoanSequence); - auto const bigLoanKeylet = keylet::loan(c.broker.brokerID, bigLoanSeq); + auto const bigLoanKeylet = + keylet::loan(c.broker.brokerID, SeqProxy::rawSequence(bigLoanSeq)); env(set(c.borrower, c.broker.brokerID, Number{500}), Sig(sfCounterpartySignature, c.lender), diff --git a/src/test/app/lending/LoanLifecycle_test.cpp b/src/test/app/lending/LoanLifecycle_test.cpp index e9868769b7..6cced5c97a 100644 --- a/src/test/app/lending/LoanLifecycle_test.cpp +++ b/src/test/app/lending/LoanLifecycle_test.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -306,7 +307,7 @@ private: // Issuer "borrowed" 200, OutstandingAmount decreased by 200 BEAST_EXPECT(env.balance(issuer, asset) == asset(-kIssuerBalance + 200)); // Pay Loan - auto const loanKeylet = keylet::loan(broker.brokerID, 1); + auto const loanKeylet = keylet::loan(broker.brokerID, SeqProxy::rawSequence(1)); env(pay(borrower, loanKeylet.key, asset(200))); env.close(); // Issuer "re-payed" 200, OutstandingAmount increased by 200 @@ -355,7 +356,8 @@ private: txFee); env.close(); - auto const brokerKeylet = keylet::loanBroker(broker.id(), env.seq(broker)); + auto const brokerKeylet = + keylet::loanBroker(broker.id(), SeqProxy::rawSequence(env.seq(broker))); env(loan_broker::set(broker, vaultKeylet.key), txFee); env.close(); @@ -371,7 +373,8 @@ private: env.close(); std::uint32_t const loanSequence = 1; - auto const loanKeylet = keylet::loan(brokerKeylet.key, loanSequence); + auto const loanKeylet = + keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(loanSequence)); auto const brokerBalanceBefore = env.balance(broker, asset); diff --git a/src/test/app/lending/LoanMisc_test.cpp b/src/test/app/lending/LoanMisc_test.cpp index 44c093b3c2..2cb4f38ecf 100644 --- a/src/test/app/lending/LoanMisc_test.cpp +++ b/src/test/app/lending/LoanMisc_test.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -386,7 +387,7 @@ private: Sig(sfCounterpartySignature, lender), loanSetFee); env.close(); - auto const loanKeylet = keylet::loan(broker.brokerID, 1); + auto const loanKeylet = keylet::loan(broker.brokerID, SeqProxy::rawSequence(1)); BEAST_EXPECT(env.le(loanKeylet)); // Repayment still works. diff --git a/src/test/app/lending/LoanPay_test.cpp b/src/test/app/lending/LoanPay_test.cpp index e06d728c87..9d840fe1bf 100644 --- a/src/test/app/lending/LoanPay_test.cpp +++ b/src/test/app/lending/LoanPay_test.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -68,7 +69,7 @@ private: return; auto const loanSequence = brokerPreLoan->at(sfLoanSequence); - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + auto const loanKeylet = keylet::loan(broker.brokerID, SeqProxy::rawSequence(loanSequence)); Number const principal = asset(1'000).value(); Number const serviceFee = asset(2).value(); @@ -220,8 +221,8 @@ private: auto const brokerSle = env.le(result.brokerKeylet()); if (!BEAST_EXPECT(brokerSle)) return; - auto const loanKeylet = - keylet::loan(result.brokerKeylet().key, brokerSle->at(sfLoanSequence)); + auto const loanKeylet = keylet::loan( + result.brokerKeylet().key, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence))); env(loan::set( borrower, result.brokerKeylet().key, asset(10'000).value(), tfLoanOverpayment), Sig(sfCounterpartySignature, lender), @@ -437,7 +438,8 @@ private: auto const brokerSle = env.le(broker.brokerKeylet()); BEAST_EXPECT(brokerSle); auto const loanSequence = brokerSle ? brokerSle->at(sfLoanSequence) : 0; - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + auto const loanKeylet = + keylet::loan(broker.brokerID, SeqProxy::rawSequence(loanSequence)); env(set(borrower, broker.brokerID, principalRequested), Sig(sfCounterpartySignature, lender), @@ -690,7 +692,7 @@ private: return; // Intentionally shadow the outer values auto const loanSequence = brokerState->at(sfLoanSequence); - auto const keylet = keylet::loan(broker.brokerID, loanSequence); + auto const keylet = keylet::loan(broker.brokerID, SeqProxy::rawSequence(loanSequence)); auto const interval = maxLoanTime / total; auto createJson = env.json( diff --git a/src/test/app/lending/LoanRounding_test.cpp b/src/test/app/lending/LoanRounding_test.cpp index b914961e78..5e69c9f79e 100644 --- a/src/test/app/lending/LoanRounding_test.cpp +++ b/src/test/app/lending/LoanRounding_test.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -90,7 +91,7 @@ private: // The loan keylet is based on the LoanSequence of the // _LOAN_BROKER_ object. auto const loanSequence = brokerSle->at(sfLoanSequence); - return keylet::loan(broker.brokerID, loanSequence); + return keylet::loan(broker.brokerID, SeqProxy::rawSequence(loanSequence)); }(); if (!loanKeyletOpt) return; @@ -325,7 +326,8 @@ private: auto borrowerBalance = [&]() { return env.balance(borrower, iou); }; auto const borrowerScale = static_cast(borrowerBalance()).exponent(); - auto const loanKeylet = keylet::loan(brokerInfo.brokerID, currentSeq); + auto const loanKeylet = + keylet::loan(brokerInfo.brokerID, SeqProxy::rawSequence(currentSeq)); auto const maybePeriodicPayment = [&]() -> std::optional { auto const loanSle = env.le(loanKeylet); if (!BEAST_EXPECT(loanSle)) @@ -452,7 +454,8 @@ private: env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = asset(5'000)})); env.close(); - auto const brokerKeylet = keylet::loanBroker(lender.id(), env.seq(lender)); + auto const brokerKeylet = + keylet::loanBroker(lender.id(), SeqProxy::rawSequence(env.seq(lender))); env(loan_broker::set(lender, vaultKeylet.key), loan_broker::kDebtMaximum(Number{100}), Fee(env.current()->fees().base * 2)); @@ -462,7 +465,7 @@ private: if (!BEAST_EXPECT(brokerStateBefore)) return; auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - auto const loanKeylet = keylet::loan(brokerKeylet.key, loanSequence); + auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(loanSequence)); env(loan::set(borrower, brokerKeylet.key, Number{1}), Sig(sfCounterpartySignature, lender), @@ -674,7 +677,8 @@ private: auto const brokerSle = env.le(broker.brokerKeylet()); BEAST_EXPECT(brokerSle); auto const loanSequence = brokerSle ? brokerSle->at(sfLoanSequence) : 0; - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + auto const loanKeylet = + keylet::loan(broker.brokerID, SeqProxy::rawSequence(loanSequence)); env(set(borrower, broker.brokerID, Number{p.principal}, tfLoanOverpayment), Sig(sfCounterpartySignature, lender), @@ -862,7 +866,7 @@ private: if (!BEAST_EXPECT(sleBroker)) return; auto const loanSequence = sleBroker->at(sfLoanSequence); - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + auto const loanKeylet = keylet::loan(broker.brokerID, SeqProxy::rawSequence(loanSequence)); using namespace loan; env(set(borrower, broker.brokerID, Number{1000}, tfLoanOverpayment), diff --git a/src/test/app/lending/LoanSecurity_test.cpp b/src/test/app/lending/LoanSecurity_test.cpp index b08d80b51c..21772d0617 100644 --- a/src/test/app/lending/LoanSecurity_test.cpp +++ b/src/test/app/lending/LoanSecurity_test.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include @@ -103,7 +104,7 @@ private: auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID)); BEAST_EXPECT(brokerSle); auto const loanSequence = brokerSle ? brokerSle->at(sfLoanSequence) : 0; - auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + auto const loanKeylet = keylet::loan(broker.brokerID, SeqProxy::rawSequence(loanSequence)); env(createJtx); env.close(); @@ -424,7 +425,8 @@ private: txFee); env.close(); - auto const brokerKeyLet = keylet::loanBroker(lender.id(), env.seq(lender)); + auto const brokerKeyLet = + keylet::loanBroker(lender.id(), SeqProxy::rawSequence(env.seq(lender))); env(loan_broker::set(lender, vaultKeyLet.key), txFee); env.close(); @@ -441,7 +443,7 @@ private: env.close(); std::uint32_t const loanSequence = 1; - auto const loanKeylet = keylet::loan(brokerKeyLet.key, loanSequence); + auto const loanKeylet = keylet::loan(brokerKeyLet.key, SeqProxy::rawSequence(loanSequence)); if (auto loan = env.le(loanKeylet); env.test.BEAST_EXPECT(loan)) { diff --git a/src/test/app/lending/LoanTestBase.h b/src/test/app/lending/LoanTestBase.h index 35aa1e26cb..dabdfc9bed 100644 --- a/src/test/app/lending/LoanTestBase.h +++ b/src/test/app/lending/LoanTestBase.h @@ -39,6 +39,7 @@ #include #include #include +#include #include #include #include @@ -474,7 +475,7 @@ protected: BEAST_EXPECT(vault->at(sfAssetsAvailable) == deposit.value()); } - auto const keylet = keylet::loanBroker(lender.id(), env.seq(lender)); + auto const keylet = keylet::loanBroker(lender.id(), SeqProxy::rawSequence(env.seq(lender))); using namespace loan_broker; env(set(lender, vaultKeylet.key, params.flags), @@ -665,9 +666,9 @@ protected: { auto const brokerStateBefore = env.le(keylet::loanBroker(broker.brokerID)); if (!BEAST_EXPECT(brokerStateBefore)) - return keylet::loan(broker.brokerID, 0); + return keylet::loan(broker.brokerID, SeqProxy::rawSequence(0)); auto const loanSequence = brokerStateBefore->at(sfLoanSequence); - return keylet::loan(broker.brokerID, loanSequence); + return keylet::loan(broker.brokerID, SeqProxy::rawSequence(loanSequence)); } // Funds issuer/lender/borrower with XRP, creates an IOU asset issued by @@ -852,7 +853,7 @@ protected: // The loan keylet is based on the LoanSequence of the // _LOAN_BROKER_ object. auto const loanSequence = brokerSle->at(sfLoanSequence); - return keylet::loan(broker.brokerID, loanSequence); + return keylet::loan(broker.brokerID, SeqProxy::rawSequence(loanSequence)); }(); if (!loanKeyletOpt) return std::nullopt; @@ -1329,7 +1330,8 @@ protected: // The loan keylet is based on the LoanSequence of the _LOAN_BROKER_ // object. auto const loanSequence = brokerSle->at(sfLoanSequence); - return std::make_pair(keylet::loan(broker.brokerID, loanSequence), loanSequence); + return std::make_pair( + keylet::loan(broker.brokerID, SeqProxy::rawSequence(loanSequence)), loanSequence); }(); VerifyLoanStatus const verifyLoanStatus(env, broker, pseudoAcct, keylet); @@ -1666,7 +1668,7 @@ protected: auto const baseFee = env.current()->fees().base; - auto badKeylet = keylet::vault(lender.id(), env.seq(lender)); + auto badKeylet = keylet::vault(lender.id(), SeqProxy::rawSequence(env.seq(lender))); // Try some failure cases // flags are checked first env(set(evan, broker.brokerID, principalRequest, tfLoanSetMask), diff --git a/src/test/app/lending/LoanValidation_test.cpp b/src/test/app/lending/LoanValidation_test.cpp index d2985c4c30..884384db55 100644 --- a/src/test/app/lending/LoanValidation_test.cpp +++ b/src/test/app/lending/LoanValidation_test.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #include #include #include @@ -57,7 +58,7 @@ private: Account const bob{"bob"}; env.fund(XRP(10000), alice, bob); - auto const keylet = keylet::loanBroker(alice, env.seq(alice)); + auto const keylet = keylet::loanBroker(alice, SeqProxy::rawSequence(env.seq(alice))); using namespace std::chrono_literals; using namespace loan; @@ -73,7 +74,7 @@ private: env(setTx); // Actual sequence will be based off the loan broker, but we // obviously don't have one of those if the amendment is disabled - auto const loanKeylet = keylet::loan(keylet.key, env.seq(alice)); + auto const loanKeylet = keylet::loan(keylet.key, SeqProxy::rawSequence(env.seq(alice))); // Other Loan transactions are disabled, too. // 2. LoanDelete env(del(alice, loanKeylet.key), Ter(temDISABLED)); @@ -300,7 +301,8 @@ private: env.close(); std::uint32_t const loanSequence = 1; - auto const loanKeylet = keylet::loan(brokerInfo.brokerID, loanSequence); + auto const loanKeylet = + keylet::loan(brokerInfo.brokerID, SeqProxy::rawSequence(loanSequence)); env(fset(issuer, asfGlobalFreeze)); env.close(); @@ -398,7 +400,8 @@ private: }); static constexpr std::uint32_t kLoanSequence = 1; - auto const loanKeylet = keylet::loan(brokerInfo.brokerID, kLoanSequence); + auto const loanKeylet = + keylet::loan(brokerInfo.brokerID, SeqProxy::rawSequence(kLoanSequence)); // Can't loan pay if the borrower is not authorized forUnauthAuth([&](bool authorized) { diff --git a/src/test/jtx/TestHelpers.h b/src/test/jtx/TestHelpers.h index d5cd8e66b8..5c8486e6c5 100644 --- a/src/test/jtx/TestHelpers.h +++ b/src/test/jtx/TestHelpers.h @@ -26,6 +26,7 @@ #include #include // IWYU pragma: keep #include +#include #include #include #include @@ -779,9 +780,9 @@ inline constexpr FeeLevel64 kBaseFeeLevel{TxQ::kBaseLevel}; inline constexpr FeeLevel64 kMinEscalationFeeLevel = kBaseFeeLevel * 500; inline uint256 -getCheckIndex(AccountID const& account, std::uint32_t uSequence) +getCheckIndex(AccountID const& account, std::uint32_t const sequence) { - return keylet::check(account, uSequence).key; + return keylet::check(account, SeqProxy::rawSequence(sequence)).key; } template diff --git a/src/test/jtx/impl/TestHelpers.cpp b/src/test/jtx/impl/TestHelpers.cpp index d73eb8adf4..2fa2aebcda 100644 --- a/src/test/jtx/impl/TestHelpers.cpp +++ b/src/test/jtx/impl/TestHelpers.cpp @@ -43,6 +43,7 @@ #include #include #include +#include #include #include #include @@ -571,7 +572,8 @@ claim( uint256 channel(AccountID const& account, AccountID const& dst, std::uint32_t seqProxyValue) { - auto const k = keylet::payChannel(account, dst, seqProxyValue); + auto const seqProxy = SeqProxy::rawSequence(seqProxyValue); + auto const k = keylet::payChannel(account, dst, seqProxy); return k.key; } diff --git a/src/test/jtx/impl/batch.cpp b/src/test/jtx/impl/batch.cpp index b1061f65a3..d8d067d95a 100644 --- a/src/test/jtx/impl/batch.cpp +++ b/src/test/jtx/impl/batch.cpp @@ -102,7 +102,7 @@ Sig::operator()(Env& env, JTx& jt) const serializeBatch( msg, stx.getAccountID(sfAccount), - stx.getSeqValue(), + stx.getSeqProxy().value(), stx.getFlags(), stx.getBatchTransactionIDs()); finishMultiSigningData(e.acct.id(), msg); @@ -146,7 +146,7 @@ Msig::operator()(Env& env, JTx& jt) const serializeBatch( msg, stx.getAccountID(sfAccount), - stx.getSeqValue(), + stx.getSeqProxy().value(), stx.getFlags(), stx.getBatchTransactionIDs()); msg.addBitString(master.id()); diff --git a/src/test/jtx/impl/escrow.cpp b/src/test/jtx/impl/escrow.cpp index 61c260a5d0..c2f3f94fa3 100644 --- a/src/test/jtx/impl/escrow.cpp +++ b/src/test/jtx/impl/escrow.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -58,7 +59,7 @@ cancel(AccountID const& account, Account const& from, std::uint32_t seq) Rate rate(Env& env, Account const& account, std::uint32_t const& seq) { - auto const sle = env.le(keylet::escrow(account.id(), seq)); + auto const sle = env.le(keylet::escrow(account.id(), SeqProxy::rawSequence(seq))); if (sle->isFieldPresent(sfTransferRate)) return xrpl::Rate((*sle)[sfTransferRate]); return Rate{0}; diff --git a/src/test/jtx/impl/vault.cpp b/src/test/jtx/impl/vault.cpp index 7084347763..baff576243 100644 --- a/src/test/jtx/impl/vault.cpp +++ b/src/test/jtx/impl/vault.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -19,7 +20,8 @@ namespace xrpl::test::jtx { std::tuple Vault::create(CreateArgs const& args) const { - auto keylet = keylet::vault(args.owner.id(), env.seq(args.owner)); + auto const seqProxy = SeqProxy::rawSequence(env.seq(args.owner)); + auto keylet = keylet::vault(args.owner.id(), seqProxy); json::Value jv; jv[jss::TransactionType] = jss::VaultCreate; jv[jss::Account] = args.owner.human(); diff --git a/src/test/rpc/AccountObjects_test.cpp b/src/test/rpc/AccountObjects_test.cpp index c656c97a4c..1450709f59 100644 --- a/src/test/rpc/AccountObjects_test.cpp +++ b/src/test/rpc/AccountObjects_test.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #include #include #include @@ -1546,7 +1547,7 @@ public: env(check::create(owner, dest, XRP(1))); env.close(); - auto const checkId = keylet::check(owner, checkSeq); + auto const checkId = keylet::check(owner, SeqProxy::rawSequence(checkSeq)); if (!BEAST_EXPECT(env.le(checkId))) return; diff --git a/src/test/rpc/AccountTx_test.cpp b/src/test/rpc/AccountTx_test.cpp index b144a6dc84..735c318b21 100644 --- a/src/test/rpc/AccountTx_test.cpp +++ b/src/test/rpc/AccountTx_test.cpp @@ -35,6 +35,7 @@ #include #include #include +#include #include #include #include @@ -592,7 +593,8 @@ class AccountTx_test : public beast::unit_test::Suite env(payChanCreate, Sig(alie)); env.close(); - std::string const payChanIndex{strHex(keylet::payChannel(alice, gw, payChanSeq).key)}; + std::string const payChanIndex{ + strHex(keylet::payChannel(alice, gw, SeqProxy::rawSequence(payChanSeq)).key)}; { json::Value payChanFund; @@ -617,10 +619,11 @@ class AccountTx_test : public beast::unit_test::Suite // Check { - auto const aliceCheckId = keylet::check(alice, env.seq(alice)).key; + auto const aliceCheckId = + keylet::check(alice, SeqProxy::rawSequence(env.seq(alice))).key; env(check::create(alice, gw, XRP(300)), Sig(alie)); - auto const gwCheckId = keylet::check(gw, env.seq(gw)).key; + auto const gwCheckId = keylet::check(gw, SeqProxy::rawSequence(env.seq(gw))).key; env(check::create(gw, alice, XRP(200))); env.close(); @@ -1355,7 +1358,7 @@ class AccountTx_test : public beast::unit_test::Suite checkTx(sponsor, jss::SponsorshipSet); // create an object with sponsor - auto const checkId = keylet::check(alice, env.seq(alice)).key; + auto const checkId = keylet::check(alice, SeqProxy::rawSequence(env.seq(alice))).key; env(check::create(alice, sponsor, XRP(1)), sponsor::As(sponsor, spfSponsorReserve)); env.close(); checkTx(alice, jss::CheckCreate); diff --git a/src/test/rpc/LedgerEntry_test.cpp b/src/test/rpc/LedgerEntry_test.cpp index b8301d5656..24dde05ce1 100644 --- a/src/test/rpc/LedgerEntry_test.cpp +++ b/src/test/rpc/LedgerEntry_test.cpp @@ -46,6 +46,7 @@ #include #include #include +#include #include #include @@ -807,7 +808,7 @@ class LedgerEntry_test : public beast::unit_test::Suite env.fund(XRP(10000), alice); env.close(); - auto const checkId = keylet::check(env.master, env.seq(env.master)); + auto const checkId = keylet::check(env.master, SeqProxy::rawSequence(env.seq(env.master))); env(check::create(env.master, alice, XRP(100))); env.close(); @@ -1527,7 +1528,8 @@ class LedgerEntry_test : public beast::unit_test::Suite uint256 const nftokenID0 = token::getNextID(env, issuer, 0, tfTransferable); env(token::mint(issuer, 0), Txflags(tfTransferable)); env.close(); - uint256 const offerID = keylet::nftokenOffer(issuer, env.seq(issuer)).key; + uint256 const offerID = + keylet::nftokenOffer(issuer, SeqProxy::rawSequence(env.seq(issuer))).key; env(token::createOffer(issuer, nftokenID0, drops(1)), token::Destination(buyer), Txflags(tfSellNFToken)); @@ -1711,7 +1713,8 @@ class LedgerEntry_test : public beast::unit_test::Suite std::string const ledgerHash{to_string(env.closed()->header().hash)}; - uint256 const payChanIndex{keylet::payChannel(alice, env.master, env.seq(alice) - 1).key}; + uint256 const payChanIndex{ + keylet::payChannel(alice, env.master, SeqProxy::rawSequence(env.seq(alice) - 1)).key}; { // Request the payment channel using its index. json::Value jvParams; @@ -1949,7 +1952,7 @@ class LedgerEntry_test : public beast::unit_test::Suite env.close(); // Create two tickets. - std::uint32_t const tkt1{env.seq(env.master) + 1}; + SeqProxy tkt1 = SeqProxy::rawTicket(env.seq(env.master)); env(ticket::create(env.master, 2)); env.close(); @@ -1960,7 +1963,7 @@ class LedgerEntry_test : public beast::unit_test::Suite { // Not a valid ticket requested by index. json::Value jvParams; - jvParams[jss::ticket] = to_string(getTicketIndex(env.master, tkt1 - 1)); + jvParams[jss::ticket] = to_string(keylet::ticket(env.master, tkt1).key); jvParams[jss::ledger_hash] = ledgerHash; json::Value const jrr = env.rpc("json", "ledger_entry", to_string(jvParams))[jss::result]; @@ -1969,31 +1972,34 @@ class LedgerEntry_test : public beast::unit_test::Suite { // First real ticket requested by index. json::Value jvParams; - jvParams[jss::ticket] = to_string(getTicketIndex(env.master, tkt1)); + tkt1.advanceBy(1); + jvParams[jss::ticket] = to_string(keylet::ticket(env.master, tkt1).key); jvParams[jss::ledger_hash] = ledgerHash; json::Value const jrr = env.rpc("json", "ledger_entry", to_string(jvParams))[jss::result]; BEAST_EXPECT(jrr[jss::node][sfLedgerEntryType.jsonName] == jss::Ticket); - BEAST_EXPECT(jrr[jss::node][sfTicketSequence.jsonName] == tkt1); + BEAST_EXPECT(jrr[jss::node][sfTicketSequence.jsonName] == tkt1.value()); } { // Second real ticket requested by account and sequence. + tkt1.advanceBy(1); json::Value jvParams; jvParams[jss::ticket] = json::ValueType::Object; jvParams[jss::ticket][jss::account] = env.master.human(); - jvParams[jss::ticket][jss::ticket_seq] = tkt1 + 1; + jvParams[jss::ticket][jss::ticket_seq] = tkt1.value(); jvParams[jss::ledger_hash] = ledgerHash; json::Value const jrr = env.rpc("json", "ledger_entry", to_string(jvParams))[jss::result]; BEAST_EXPECT( - jrr[jss::node][jss::index] == to_string(getTicketIndex(env.master, tkt1 + 1))); + jrr[jss::node][jss::index] == to_string(keylet::ticket(env.master, tkt1).key)); } { // Not a valid ticket requested by account and sequence. + tkt1.advanceBy(1); json::Value jvParams; jvParams[jss::ticket] = json::ValueType::Object; jvParams[jss::ticket][jss::account] = env.master.human(); - jvParams[jss::ticket][jss::ticket_seq] = tkt1 + 2; + jvParams[jss::ticket][jss::ticket_seq] = tkt1.value(); jvParams[jss::ledger_hash] = ledgerHash; json::Value const jrr = env.rpc("json", "ledger_entry", to_string(jvParams))[jss::result]; @@ -2253,7 +2259,8 @@ class LedgerEntry_test : public beast::unit_test::Suite jv[jss::result][jss::node][sfLedgerEntryType.jsonName] == jss::PermissionedDomain); std::string const pdIdx = jv[jss::result][jss::index].asString(); - BEAST_EXPECT(strHex(keylet::permissionedDomain(alice, seq).key) == pdIdx); + BEAST_EXPECT( + strHex(keylet::permissionedDomain(alice, SeqProxy::rawSequence(seq)).key) == pdIdx); params.clear(); params[jss::ledger_index] = jss::validated; @@ -2703,7 +2710,7 @@ class LedgerEntry_test : public beast::unit_test::Suite env.fund(XRP(10000), alice); env.close(); - auto const checkId = keylet::check(env.master, env.seq(env.master)); + auto const checkId = keylet::check(env.master, SeqProxy::rawSequence(env.seq(env.master))); env(check::create(env.master, alice, XRP(100))); env.close(); diff --git a/src/test/rpc/Subscribe_test.cpp b/src/test/rpc/Subscribe_test.cpp index 567f31437a..47c2245fa5 100644 --- a/src/test/rpc/Subscribe_test.cpp +++ b/src/test/rpc/Subscribe_test.cpp @@ -35,6 +35,7 @@ #include #include #include +#include #include #include #include @@ -1453,12 +1454,14 @@ public: // Alice creates one sell offer for each NFT // Verify the offer indexes are correct in the NFTokenCreateOffer tx // meta - uint256 const aliceOfferIndex1 = keylet::nftokenOffer(alice, env.seq(alice)).key; + uint256 const aliceOfferIndex1 = + keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key; env(token::createOffer(alice, nftId1, drops(1)), Txflags(tfSellNFToken)); BEAST_EXPECT(env.syncClose()); verifyNFTokenOfferID(aliceOfferIndex1); - uint256 const aliceOfferIndex2 = keylet::nftokenOffer(alice, env.seq(alice)).key; + uint256 const aliceOfferIndex2 = + keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key; env(token::createOffer(alice, nftId2, drops(1)), Txflags(tfSellNFToken)); BEAST_EXPECT(env.syncClose()); verifyNFTokenOfferID(aliceOfferIndex2); @@ -1472,7 +1475,8 @@ public: // Bobs creates a buy offer for nftId1 // Verify the offer id is correct in the NFTokenCreateOffer tx meta - auto const bobBuyOfferIndex = keylet::nftokenOffer(bob, env.seq(bob)).key; + auto const bobBuyOfferIndex = + keylet::nftokenOffer(bob, SeqProxy::rawSequence(env.seq(bob))).key; env(token::createOffer(bob, nftId1, drops(1)), token::Owner(alice)); BEAST_EXPECT(env.syncClose()); verifyNFTokenOfferID(bobBuyOfferIndex); @@ -1493,7 +1497,8 @@ public: verifyNFTokenID(nftId); // Alice creates sell offer and set broker as destination - uint256 const offerAliceToBroker = keylet::nftokenOffer(alice, env.seq(alice)).key; + uint256 const offerAliceToBroker = + keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key; env(token::createOffer(alice, nftId, drops(1)), token::Destination(broker), Txflags(tfSellNFToken)); @@ -1501,7 +1506,8 @@ public: verifyNFTokenOfferID(offerAliceToBroker); // Bob creates buy offer - uint256 const offerBobToBroker = keylet::nftokenOffer(bob, env.seq(bob)).key; + uint256 const offerBobToBroker = + keylet::nftokenOffer(bob, SeqProxy::rawSequence(env.seq(bob))).key; env(token::createOffer(bob, nftId, drops(1)), token::Owner(alice)); BEAST_EXPECT(env.syncClose()); verifyNFTokenOfferID(offerBobToBroker); @@ -1522,12 +1528,14 @@ public: verifyNFTokenID(nftId); // Alice creates 2 sell offers for the same NFT - uint256 const aliceOfferIndex1 = keylet::nftokenOffer(alice, env.seq(alice)).key; + uint256 const aliceOfferIndex1 = + keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key; env(token::createOffer(alice, nftId, drops(1)), Txflags(tfSellNFToken)); BEAST_EXPECT(env.syncClose()); verifyNFTokenOfferID(aliceOfferIndex1); - uint256 const aliceOfferIndex2 = keylet::nftokenOffer(alice, env.seq(alice)).key; + uint256 const aliceOfferIndex2 = + keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key; env(token::createOffer(alice, nftId, drops(1)), Txflags(tfSellNFToken)); BEAST_EXPECT(env.syncClose()); verifyNFTokenOfferID(aliceOfferIndex2); @@ -1542,7 +1550,7 @@ public: if (features[featureNFTokenMintOffer]) { uint256 const aliceMintWithOfferIndex1 = - keylet::nftokenOffer(alice, env.seq(alice)).key; + keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key; env(token::mint(alice), token::Amount(XRP(0))); BEAST_EXPECT(env.syncClose()); verifyNFTokenOfferID(aliceMintWithOfferIndex1); diff --git a/src/test/rpc/Transaction_test.cpp b/src/test/rpc/Transaction_test.cpp index 2921c63c17..a65dba1d9c 100644 --- a/src/test/rpc/Transaction_test.cpp +++ b/src/test/rpc/Transaction_test.cpp @@ -345,7 +345,7 @@ class Transaction_test : public beast::unit_test::Suite auto const tx = env.jt(noop(alice), Seq(env.seq(alice))).stx; // NOLINTNEXTLINE(bugprone-unchecked-optional-access) - auto const ctid = *rpc::encodeCTID(endLegSeq, tx->getSeqValue(), netID); + auto const ctid = *rpc::encodeCTID(endLegSeq, tx->getSeqProxy().value(), netID); for (int deltaEndSeq = 0; deltaEndSeq < 2; ++deltaEndSeq) { auto const result = env.rpc( diff --git a/src/tests/libxrpl/tx/AccountSet.cpp b/src/tests/libxrpl/tx/AccountSet.cpp index 87d00c58bf..ae291791d4 100644 --- a/src/tests/libxrpl/tx/AccountSet.cpp +++ b/src/tests/libxrpl/tx/AccountSet.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -610,7 +611,7 @@ TEST(AccountSet, Ticket) // Get alice's current sequence - the ticket will be created at seq + 1 std::uint32_t const aliceSeqBefore = env.getAccountRoot(alice.id()).getSequence(); - std::uint32_t const ticketSeq = aliceSeqBefore + 1; + auto const ticketSeq = SeqProxy::rawTicket(aliceSeqBefore + 1); // Create a ticket EXPECT_EQ(env.submit(transactions::TicketCreateBuilder{alice, 1}, alice).ter, tesSUCCESS); @@ -623,7 +624,9 @@ TEST(AccountSet, Ticket) // Try using a ticket that alice doesn't have EXPECT_EQ( - env.submit(transactions::AccountSetBuilder{alice}.setTicketSequence(ticketSeq + 1), alice) + env.submit( + transactions::AccountSetBuilder{alice}.setTicketSequence(ticketSeq.value() + 1), + alice) .ter, terPRE_TICKET); env.close(); @@ -636,7 +639,9 @@ TEST(AccountSet, Ticket) // Actually use alice's ticket (noop AccountSet) EXPECT_EQ( - env.submit(transactions::AccountSetBuilder{alice}.setTicketSequence(ticketSeq), alice).ter, + env.submit( + transactions::AccountSetBuilder{alice}.setTicketSequence(ticketSeq.value()), alice) + .ter, tesSUCCESS); env.close(); @@ -649,7 +654,9 @@ TEST(AccountSet, Ticket) // Try re-using a ticket that alice already used EXPECT_EQ( - env.submit(transactions::AccountSetBuilder{alice}.setTicketSequence(ticketSeq), alice).ter, + env.submit( + transactions::AccountSetBuilder{alice}.setTicketSequence(ticketSeq.value()), alice) + .ter, tefNO_TICKET); } diff --git a/src/xrpld/app/ledger/detail/LocalTxs.cpp b/src/xrpld/app/ledger/detail/LocalTxs.cpp index 5bfe8684f0..d540134f8d 100644 --- a/src/xrpld/app/ledger/detail/LocalTxs.cpp +++ b/src/xrpld/app/ledger/detail/LocalTxs.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include @@ -147,7 +148,7 @@ public: if (!sleAcct) return false; - SeqProxy const acctSeq = SeqProxy::sequence(sleAcct->getFieldU32(sfSequence)); + SeqProxy const acctSeq = SeqProxy::rawSequence(sleAcct->getFieldU32(sfSequence)); SeqProxy const seqProx = txn.getSeqProxy(); if (seqProx.isSeq()) diff --git a/src/xrpld/app/misc/NetworkOPs.cpp b/src/xrpld/app/misc/NetworkOPs.cpp index 1af3b64161..771330367c 100644 --- a/src/xrpld/app/misc/NetworkOPs.cpp +++ b/src/xrpld/app/misc/NetworkOPs.cpp @@ -4121,7 +4121,7 @@ NetworkOPsImp::addAccountHistoryJob(SubAccountHistoryInfoWeak subInfo) if (accountId == kGenesisAccountId) { auto stx = tx->getSTransaction(); - if (stx->getAccountID(sfAccount) == accountId && stx->getSeqValue() == 1) + if (stx->getAccountID(sfAccount) == accountId && stx->getSeqProxy().value() == 1) return true; } diff --git a/src/xrpld/app/misc/detail/TxQ.cpp b/src/xrpld/app/misc/detail/TxQ.cpp index 041d2ade1e..b9cfc1d65c 100644 --- a/src/xrpld/app/misc/detail/TxQ.cpp +++ b/src/xrpld/app/misc/detail/TxQ.cpp @@ -773,7 +773,7 @@ TxQ::apply( return {terNO_ACCOUNT, false}; // If the transaction needs a Ticket is that Ticket in the ledger? - SeqProxy const acctSeqProx = SeqProxy::sequence((*sleAccount)[sfSequence]); + SeqProxy const acctSeqProx = SeqProxy::rawSequence((*sleAccount)[sfSequence]); SeqProxy const txSeqProx = tx->getSeqProxy(); if (txSeqProx.isTicket() && !view.exists(keylet::ticket(account, txSeqProx))) { @@ -1605,9 +1605,9 @@ TxQ::nextQueuableSeqImpl(SLE::const_ref sleAccount, std::scoped_lock // If the account is not in the ledger or a non-account was passed // then return zero. We have no idea. if (!sleAccount || sleAccount->getType() != ltACCOUNT_ROOT) - return SeqProxy::sequence(0); + return SeqProxy::rawSequence(0); - SeqProxy const acctSeqProx = SeqProxy::sequence((*sleAccount)[sfSequence]); + SeqProxy const acctSeqProx = SeqProxy::rawSequence((*sleAccount)[sfSequence]); // If the account is not in the queue then acctSeqProx is good enough. auto const accountIter = byAccount_.find((*sleAccount)[sfAccount]); @@ -1669,7 +1669,7 @@ TxQ::tryDirectApply( if (!sleAccount) return {}; - SeqProxy const acctSeqProx = SeqProxy::sequence((*sleAccount)[sfSequence]); + SeqProxy const acctSeqProx = SeqProxy::rawSequence((*sleAccount)[sfSequence]); SeqProxy const txSeqProx = tx->getSeqProxy(); // Can only directly apply if the transaction sequence matches the account diff --git a/src/xrpld/rpc/handlers/VaultInfo.cpp b/src/xrpld/rpc/handlers/VaultInfo.cpp index 034cd8383f..c216192ab3 100644 --- a/src/xrpld/rpc/handlers/VaultInfo.cpp +++ b/src/xrpld/rpc/handlers/VaultInfo.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include @@ -48,7 +49,8 @@ parseVault(json::Value const& params, json::Value& jvResult) return std::nullopt; } - uNodeIndex = keylet::vault(*id, params[jss::seq].asUInt()).key; + auto const seq = SeqProxy::rawSequence(params[jss::seq].asUInt()); + uNodeIndex = keylet::vault(*id, seq).key; } else { diff --git a/src/xrpld/rpc/handlers/account/AccountInfo.cpp b/src/xrpld/rpc/handlers/account/AccountInfo.cpp index c618ad3b3a..d4232cf451 100644 --- a/src/xrpld/rpc/handlers/account/AccountInfo.cpp +++ b/src/xrpld/rpc/handlers/account/AccountInfo.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -260,7 +261,7 @@ doAccountInfo(rpc::JsonContext& context) // We expect txs to be returned sorted by SeqProxy. Verify // that with a couple of asserts. - SeqProxy prevSeqProxy = SeqProxy::sequence(0); + SeqProxy prevSeqProxy = SeqProxy::rawSequence(0); for (auto const& tx : txs) { json::Value jvTx = json::ValueType::Object; diff --git a/src/xrpld/rpc/handlers/ledger/LedgerEntry.cpp b/src/xrpld/rpc/handlers/ledger/LedgerEntry.cpp index 0dd52b6776..5271720b34 100644 --- a/src/xrpld/rpc/handlers/ledger/LedgerEntry.cpp +++ b/src/xrpld/rpc/handlers/ledger/LedgerEntry.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include @@ -433,7 +434,8 @@ parseEscrow( if (!seq) return std::unexpected(seq.error()); - return keylet::escrow(*id, *seq).key; + auto const seqProxy = SeqProxy::rawSequence(*seq); + return keylet::escrow(*id, seqProxy).key; } auto const parseFeeSettings = fixed(keylet::feeSettings()); @@ -495,7 +497,8 @@ parseLoanBroker( if (!seq) return std::unexpected(seq.error()); - return keylet::loanBroker(*id, *seq).key; + auto const seqProxy = SeqProxy::rawSequence(*seq); + return keylet::loanBroker(*id, seqProxy).key; } static std::expected @@ -517,7 +520,8 @@ parseLoan( if (!seq) return std::unexpected(seq.error()); - return keylet::loan(*id, *seq).key; + auto const seqProxy = SeqProxy::rawSequence(*seq); + return keylet::loan(*id, seqProxy).key; } static std::expected @@ -600,7 +604,8 @@ parseOffer( if (!seq) return std::unexpected(seq.error()); - return keylet::offer(*id, *seq).key; + auto const seqProxy = SeqProxy::rawSequence(*seq); + return keylet::offer(*id, seqProxy).key; } static std::expected @@ -662,7 +667,8 @@ parsePermissionedDomain( if (!seq) return std::unexpected(seq.error()); - return keylet::permissionedDomain(*account, pd[jss::seq].asUInt()).key; + auto const seqProxy = SeqProxy::rawSequence(pd[jss::seq].asUInt()); + return keylet::permissionedDomain(*account, seqProxy).key; } static std::expected @@ -766,7 +772,8 @@ parseTicket( if (!seq) return std::unexpected(seq.error()); - return getTicketIndex(*id, *seq); + auto const seqProxy = SeqProxy::rawTicket(*seq); + return keylet::ticket(*id, seqProxy).key; } static std::expected @@ -788,7 +795,8 @@ parseVault( if (!seq) return std::unexpected(seq.error()); - return keylet::vault(*id, *seq).key; + auto const seqProxy = SeqProxy::rawSequence(*seq); + return keylet::vault(*id, seqProxy).key; } static std::expected From 94bccb3a5a781e342ed761e34236d4215152ab40 Mon Sep 17 00:00:00 2001 From: Gregory Tsipenyuk Date: Fri, 7 Aug 2026 17:53:54 -0400 Subject: [PATCH 52/52] fix: Fix MPT/DEX Audit/Attackathon reports (Phase 2) (#7537) Signed-off-by: dependabot[bot] Co-authored-by: Sergey Kuznetsov Co-authored-by: Ayaz Salikhov Co-authored-by: Andrzej Budzanowski Co-authored-by: Marek Foss Co-authored-by: Alex Kremer Co-authored-by: Vito Tumas <5780819+Tapanito@users.noreply.github.com> Co-authored-by: Bart Co-authored-by: Bart <11445373+bthomee@users.noreply.github.com> Co-authored-by: Mayukha Vadari Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- include/xrpl/ledger/helpers/EscrowHelpers.h | 25 ++- include/xrpl/protocol/detail/features.macro | 2 +- include/xrpl/tx/paths/AMMLiquidity.h | 10 +- src/libxrpl/tx/paths/AMMLiquidity.cpp | 19 +- src/libxrpl/tx/paths/AMMOffer.cpp | 4 + src/libxrpl/tx/paths/BookStep.cpp | 9 +- .../tx/transactors/check/CheckCash.cpp | 26 ++- src/libxrpl/tx/transactors/dex/AMMDeposit.cpp | 32 ++- .../tx/transactors/dex/AMMWithdraw.cpp | 33 ++- src/test/app/AMMMPT_test.cpp | 156 ++++++++++++++ src/test/app/AMM_test.cpp | 192 ++++++++++++++++-- src/test/app/CheckMPT_test.cpp | 53 +++++ src/test/app/EscrowToken_test.cpp | 68 +++++++ src/test/rpc/Feature_test.cpp | 6 +- 14 files changed, 567 insertions(+), 68 deletions(-) diff --git a/include/xrpl/ledger/helpers/EscrowHelpers.h b/include/xrpl/ledger/helpers/EscrowHelpers.h index 9f54e53769..062443cd92 100644 --- a/include/xrpl/ledger/helpers/EscrowHelpers.h +++ b/include/xrpl/ledger/helpers/EscrowHelpers.h @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -15,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -241,10 +243,25 @@ escrowUnlockApplyHelper( auto finalAmt = amount; if ((!senderIssuer && !receiverIssuer) && lockedRate != kParityRate) { - // compute transfer fee, if any - auto const xferFee = amount.value() - divideRound(amount, lockedRate, amount.asset(), true); - // compute balance to transfer - finalAmt = amount.value() - xferFee; + if (ctx.view.rules().enabled(fixCleanup3_4_0)) + { + XRPL_ASSERT( + lockedRate >= kParityRate, + "xrpl::escrowUnlockApplyHelper : lockedRate is at least parity"); + // MPTs are integral, so round the delivered amount down and + // charge any fractional transfer fee to the escrowed amount. + auto const delivered = + mulRatio(amount.mpt(), kParityRate.value, lockedRate.value, false); + finalAmt = STAmount(amount.asset(), delivered.value()); + } + else + { + // compute transfer fee, if any + auto const xferFee = + amount.value() - divideRound(amount, lockedRate, amount.asset(), true); + // compute balance to transfer + finalAmt = amount.value() - xferFee; + } } return unlockEscrowMPT( ctx.view, diff --git a/include/xrpl/protocol/detail/features.macro b/include/xrpl/protocol/detail/features.macro index 4f1fac82da..de02fed7d8 100644 --- a/include/xrpl/protocol/detail/features.macro +++ b/include/xrpl/protocol/detail/features.macro @@ -59,7 +59,6 @@ XRPL_FIX (PreviousTxnID, Supported::Yes, VoteBehavior::DefaultNo XRPL_FIX (XChainRewardRounding, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FIX (EmptyDID, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FEATURE(PriceOracle, Supported::Yes, VoteBehavior::DefaultNo) -XRPL_FIX (AMMOverflowOffer, Supported::Yes, VoteBehavior::DefaultYes) XRPL_FIX (FillOrKill, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FEATURE(DID, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FEATURE(XChainBridge, Supported::Yes, VoteBehavior::DefaultNo) @@ -100,6 +99,7 @@ XRPL_RETIRE_FIX(1578) XRPL_RETIRE_FIX(1623) XRPL_RETIRE_FIX(1781) XRPL_RETIRE_FIX(AmendmentMajorityCalc) +XRPL_RETIRE_FIX(AMMOverflowOffer) XRPL_RETIRE_FIX(CheckThreading) XRPL_RETIRE_FIX(DisallowIncomingV1) XRPL_RETIRE_FIX(InnerObjTemplate) diff --git a/include/xrpl/tx/paths/AMMLiquidity.h b/include/xrpl/tx/paths/AMMLiquidity.h index 1904445554..b08a0ec2f9 100644 --- a/include/xrpl/tx/paths/AMMLiquidity.h +++ b/include/xrpl/tx/paths/AMMLiquidity.h @@ -6,7 +6,6 @@ #include #include #include -#include #include #include @@ -124,17 +123,12 @@ private: generateFibSeqOffer(TAmounts const& balances) const; /** - * Generate max offer. - * If `fixAMMOverflowOffer` is active, the offer is generated as: + * Generate max offer. The offer is generated as: * takerGets = 99% * balances.out takerPays = swapOut(takerGets). * Return nullopt if takerGets is 0 or takerGets == balances.out. - * - * If `fixAMMOverflowOffer` is not active, the offer is generated as: - * takerPays = max input amount; - * takerGets = swapIn(takerPays). */ [[nodiscard]] std::optional> - maxOffer(TAmounts const& balances, Rules const& rules) const; + maxOffer(TAmounts const& balances) const; }; } // namespace xrpl diff --git a/src/libxrpl/tx/paths/AMMLiquidity.cpp b/src/libxrpl/tx/paths/AMMLiquidity.cpp index 0d1c66ead8..1b38847d7b 100644 --- a/src/libxrpl/tx/paths/AMMLiquidity.cpp +++ b/src/libxrpl/tx/paths/AMMLiquidity.cpp @@ -133,17 +133,8 @@ maxOut(T const& out, Asset const& asset) template std::optional> -AMMLiquidity::maxOffer(TAmounts const& balances, Rules const& rules) const +AMMLiquidity::maxOffer(TAmounts const& balances) const { - if (!rules.enabled(fixAMMOverflowOffer)) - { - return AMMOffer( - *this, - {maxAmount(), swapAssetIn(balances, maxAmount(), tradingFee_)}, - balances, - Quality{balances}); - } - auto const out = maxOut(balances.out, assetOut()); if (out <= TOut{0} || out >= balances.out) return std::nullopt; @@ -206,7 +197,7 @@ AMMLiquidity::getOffer(ReadView const& view, std::optional c // changed in BookStep per either deliver amount limit, or // sendmax, or available output or input funds. Might return // nullopt if the pool is small. - return maxOffer(balances, view.rules()); + return maxOffer(balances); } if (auto const amounts = changeSpotPriceQuality(balances, *clobQuality, tradingFee_, view.rules(), j_)) @@ -215,7 +206,7 @@ AMMLiquidity::getOffer(ReadView const& view, std::optional c } if (view.rules().enabled(fixAMMv1_2)) { - if (auto const maxAMMOffer = maxOffer(balances, view.rules()); + if (auto const maxAMMOffer = maxOffer(balances); maxAMMOffer && Quality{maxAMMOffer->amount()} > *clobQuality) return maxAMMOffer; } @@ -223,10 +214,6 @@ AMMLiquidity::getOffer(ReadView const& view, std::optional c catch (std::overflow_error const& e) { JLOG(j_.error()) << "AMMLiquidity::getOffer overflow " << e.what(); - if (!view.rules().enabled(fixAMMOverflowOffer)) - { - return maxOffer(balances, view.rules()); - } return std::nullopt; } diff --git a/src/libxrpl/tx/paths/AMMOffer.cpp b/src/libxrpl/tx/paths/AMMOffer.cpp index 3a7bd8f1df..a4a067c4f0 100644 --- a/src/libxrpl/tx/paths/AMMOffer.cpp +++ b/src/libxrpl/tx/paths/AMMOffer.cpp @@ -134,11 +134,13 @@ AMMOffer::checkInvariant(TAmounts const& consumed, beast:: { if (consumed.in > amounts_.in || consumed.out > amounts_.out) { + // LCOV_EXCL_START JLOG(j.error()) << "AMMOffer::checkInvariant failed: consumed " << to_string(consumed.in) << " " << to_string(consumed.out) << " amounts " << to_string(amounts_.in) << " " << to_string(amounts_.out); return false; + // LCOV_EXCL_STOP } Number const product = balances_.in * balances_.out; @@ -149,6 +151,7 @@ AMMOffer::checkInvariant(TAmounts const& consumed, beast:: if (newProduct >= product || withinRelativeDistance(product, newProduct, Number{1, -7})) return true; + // LCOV_EXCL_START JLOG(j.error()) << "AMMOffer::checkInvariant failed: balances " << to_string(balances_.in) << " " << to_string(balances_.out) << " new balances " << to_string(newBalances.in) << " " << to_string(newBalances.out) @@ -156,6 +159,7 @@ AMMOffer::checkInvariant(TAmounts const& consumed, beast:: << (product != Number{0} ? to_string((product - newProduct) / product) : "undefined"); return false; + // LCOV_EXCL_STOP } template class AMMOffer; diff --git a/src/libxrpl/tx/paths/BookStep.cpp b/src/libxrpl/tx/paths/BookStep.cpp index 71902ce8b9..e7c2e9ee29 100644 --- a/src/libxrpl/tx/paths/BookStep.cpp +++ b/src/libxrpl/tx/paths/BookStep.cpp @@ -865,12 +865,9 @@ BookStep::consumeOffer( { if (!offer.checkInvariant(ofrAmt, j_)) { - // purposely written as separate if statements so we get logging even - // when the amendment isn't active. - if (sb.rules().enabled(fixAMMOverflowOffer)) - { - Throw(tecINVARIANT_FAILED, "AMM pool product invariant failed."); - } + // LCOV_EXCL_START + Throw(tecINVARIANT_FAILED, "AMM pool product invariant failed."); + // LCOV_EXCL_STOP } // The offer owner gets the ofrAmt. The difference between ofrAmt and diff --git a/src/libxrpl/tx/transactors/check/CheckCash.cpp b/src/libxrpl/tx/transactors/check/CheckCash.cpp index a8c989f4df..e4d8f192c0 100644 --- a/src/libxrpl/tx/transactors/check/CheckCash.cpp +++ b/src/libxrpl/tx/transactors/check/CheckCash.cpp @@ -19,8 +19,9 @@ #include #include #include +#include #include -#include +#include #include #include #include @@ -376,18 +377,29 @@ CheckCash::doApply() else { // Note that for DeliverMin we don't know exactly how much - // currency we want flow to deliver. We can't ask for the - // maximum possible currency because there might be a gateway - // transfer rate to account for. Since the transfer rate cannot - // exceed 200%, we use 1/2 maxValue as our limit. + // currency we want flow to deliver. For IOUs, use a value + // higher than any real delivery as the request. MPTs are + // bounded integral amounts, so use the maximum output the check + // can actually deliver without exceeding SendMax. auto const maxDeliverMin = [&]() { return optDeliverMin->asset().visit( [&](Issue const&) { return STAmount( optDeliverMin->asset(), STAmount::kMaxValue / 2, STAmount::kMaxOffset); }, - [&](MPTIssue const&) { - return STAmount(optDeliverMin->asset(), kMaxMpTokenAmount / 2); + [&](MPTIssue const& issue) { + MPTAmount maxDeliver = sendMax.mpt(); + auto const& issuer = issue.getIssuer(); + if (srcId != issuer && accountID_ != issuer) + { + auto const rate = transferRate(psb, issue.getMptID()); + // Request at most floor(SendMax / rate). The endpoint reverse pass + // will quote ceil(output * rate), so this keeps the input + // representable and within SendMax. + maxDeliver = + mulRatio(maxDeliver, QUALITY_ONE, rate.value, /*roundUp*/ false); + } + return STAmount(maxDeliver, issue); }); }; STAmount const flowDeliver{ diff --git a/src/libxrpl/tx/transactors/dex/AMMDeposit.cpp b/src/libxrpl/tx/transactors/dex/AMMDeposit.cpp index 0d1798babc..64d6d70e67 100644 --- a/src/libxrpl/tx/transactors/dex/AMMDeposit.cpp +++ b/src/libxrpl/tx/transactors/dex/AMMDeposit.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #include namespace xrpl { @@ -437,11 +438,10 @@ AMMDeposit::applyGuts(Sandbox& sb) auto const subTxType = ctx_.tx.getFlags() & tfDepositSubTx; - auto const [result, newLPTokenBalance] = [&, - &amountBalance = amountBalance, - &amount2Balance = amount2Balance, - &lptAMMBalance = - lptAMMBalance]() -> std::pair { + auto dispatchToDeposit = [&, + &amountBalance = amountBalance, + &amount2Balance = amount2Balance, + &lptAMMBalance = lptAMMBalance]() -> std::pair { if (subTxType & tfTwoAsset) { return equalDepositLimit( @@ -493,6 +493,28 @@ AMMDeposit::applyGuts(Sandbox& sb) JLOG(j_.error()) << "AMM Deposit: invalid options."; return std::make_pair(tecINTERNAL, STAmount{}); // LCOV_EXCL_STOP + }; + + auto const [result, newLPTokenBalance] = [&]() -> std::pair { + try + { + return dispatchToDeposit(); + } + catch (std::runtime_error const& e) + { + REACHABLE("xrpl::AMMDeposit::applyGuts : deposit amount out of range reached"); + // A deposit whose solved amount exceeds the integral asset's range + // throws while converting to STAmount: past int64max + // Number::operator rep() throws std::overflow_error; above the asset + // maximum STAmount::canonicalize throws std::runtime_error. Fail + // cleanly with a tec rather than letting it escape doApply as + // tefEXCEPTION. Any other exception is left to propagate. + // Gated by fixCleanup3_4_0 to preserve the legacy result pre-amendment. + if (!sb.rules().enabled(fixCleanup3_4_0)) + throw; // LCOV_EXCL_LINE - preserve legacy tefEXCEPTION + JLOG(j_.error()) << "AMMDeposit: deposit amount out of range " << e.what(); + return std::make_pair(tecAMM_FAILED, STAmount{}); + } }(); if (isTesSuccess(result)) diff --git a/src/libxrpl/tx/transactors/dex/AMMWithdraw.cpp b/src/libxrpl/tx/transactors/dex/AMMWithdraw.cpp index 2baa7edfb4..5294dd0c7f 100644 --- a/src/libxrpl/tx/transactors/dex/AMMWithdraw.cpp +++ b/src/libxrpl/tx/transactors/dex/AMMWithdraw.cpp @@ -34,6 +34,7 @@ #include #include #include +#include #include #include @@ -374,11 +375,10 @@ AMMWithdraw::applyGuts(Sandbox& sb) auto const [amountBalance, amount2Balance, lptAMMBalance] = *expected; auto const subTxType = ctx_.tx.getFlags() & tfWithdrawSubTx; - auto const [result, newLPTokenBalance] = [&, - &amountBalance = amountBalance, - &amount2Balance = amount2Balance, - &lptAMMBalance = - lptAMMBalance]() -> std::pair { + auto dispatchToWithdraw = [&, + &amountBalance = amountBalance, + &amount2Balance = amount2Balance, + &lptAMMBalance = lptAMMBalance]() -> std::pair { if (subTxType & tfTwoAsset) { return equalWithdrawLimit( @@ -432,6 +432,29 @@ AMMWithdraw::applyGuts(Sandbox& sb) JLOG(j_.error()) << "AMM Withdraw: invalid options."; return std::make_pair(tecINTERNAL, STAmount{}); // LCOV_EXCL_STOP + }; + + auto const [result, newLPTokenBalance] = [&]() -> std::pair { + try + { + return dispatchToWithdraw(); + } + catch (std::runtime_error const& e) + { + // Defense in-depth for amount overflow/out-of-range: the withdrawal + // counterpart of the AMMDeposit guard. Unlike deposit, no known + // withdraw path can throw here - preclaim bounds the requested + // amounts by the pool balances, and the only historical throw + // (denom == 0 in singleWithdrawEPrice) is guarded under + // fixCleanup3_3_0. Gated by fixCleanup3_4_0 to preserve the + // legacy tefEXCEPTION pre-amendment. + if (!sb.rules().enabled(fixCleanup3_4_0)) + throw; + // LCOV_EXCL_START + JLOG(j_.error()) << "AMMWithdraw: amount out of range " << e.what(); + return std::make_pair(tecAMM_FAILED, STAmount{}); + // LCOV_EXCL_STOP + } }(); if (!isTesSuccess(result)) diff --git a/src/test/app/AMMMPT_test.cpp b/src/test/app/AMMMPT_test.cpp index bf0bc5c7d7..7078ea6769 100644 --- a/src/test/app/AMMMPT_test.cpp +++ b/src/test/app/AMMMPT_test.cpp @@ -7142,6 +7142,159 @@ private: } } + void + testDepositIntegralOverflowMPT(FeatureBitset features) + { + testcase("Deposit integral overflow (MPT)"); + + using namespace jtx; + + // Without fixCleanup3_4_0 the exception escapes and is converted to + // tefEXCEPTION by applySteps. With the amendment, applyGuts guards it + // and fails cleanly with tecAMM_FAILED. + auto const err = features[fixCleanup3_4_0] ? Ter(tecAMM_FAILED) : Ter(tefEXCEPTION); + + // MPT counterpart of AMM_test::testDepositIntegralOverflow. A two-asset + // deposit with a huge Amount against a tiny pool leg makes + // frac = Amount / balance enormous, so the computed deposit for the + // other (integral) leg exceeds Number's int64 range (kMaxRep ~= + // 9.22e18) and the conversion to an integral STAmount throws out of + // doApply - which applySteps would surface as tefEXCEPTION. + // + // The default amendments include fixCleanup3_4_0, under which applyGuts + // guards the overflow and fails cleanly with tecAMM_FAILED. This + // verifies the guarded path: no overflow escapes. + + // XRP/MPT - the exact pool the report (Antithesis) calls out. A tiny + // mpt(1) balance and a huge MPT Amount drive frac; the XRP leg is what + // overflows: XRP(10) is 1e7 drops, so getRoundedAsset(XRP, frac) is + // 1e7 * 1e13 = 1e20 drops, well past kMaxRep. + { + // The deposit intentionally overflows, which logs at error. + // Disable the log threshold to keep the test output clean. + Env env(*this, envconfig(), features, nullptr, beast::Severity::Disabled); + if (!features[fixCleanup3_4_0]) + env.disableFeature(fixCleanup3_4_0); + env.fund(XRP(30'000), gw_, alice_); + env.close(); + + // kMptDexFlags (CanTrade | CanTransfer), which AMMs require, is + // the default. alice must hold enough MPT to fund the pool and the + // oversized deposit. + MPT const mpt = MPTTester( + {.env = env, + .issuer = gw_, + .holders = {alice_}, + .pay = 100'000'000'000'000, // 1e14 + .maxAmt = 1'000'000'000'000'000}); // 1e15 + env.close(); + + AMM amm(env, alice_, XRP(10), mpt(1)); + amm.deposit( + DepositArg{ + .account = alice_, + .asset1In = mpt(10'000'000'000'000), // 1e13 + .asset2In = XRP(1), + .err = err}); + } + + // IOU/MPT - the MPT leg is the one that overflows. A classic IOU + // trustline drives frac (huge USD Amount vs USD(1) balance); the + // MPT-side deposit is then mptBalance * frac = 10'000 * 1e16 = 1e20. + { + // The deposit intentionally overflows, which logs at error. + // Disable the log threshold to keep the test output clean. + Env env(*this, envconfig(), features, nullptr, beast::Severity::Disabled); + env.fund(XRP(30'000), gw_, alice_); + env(trust(alice_, STAmount{USD, 1, 20})); + env(pay(gw_, alice_, STAmount{USD, 1, 18})); + env.close(); + + MPT const mpt = + MPTTester({.env = env, .issuer = gw_, .holders = {alice_}, .pay = 1'000'000}); + env.close(); + + AMM amm(env, alice_, mpt(10'000), USD(1)); + amm.deposit( + DepositArg{ + .account = alice_, + .asset1In = STAmount{USD, 1, 16}, + .asset2In = mpt(1), + .err = err}); + } + } + + void + testWithdrawIntegralNoOverflowMPT() + { + testcase("Withdraw integral no overflow (MPT)"); + + using namespace jtx; + + // MPT counterpart of AMM_test::testWithdrawIntegralNoOverflow and the + // sibling of testDepositIntegralOverflowMPT. AMMWithdraw:: + // equalWithdrawLimit has the same getRoundedAsset(integralBalance, + // frac) structure as the deposit path and is likewise not wrapped in a + // try/catch. It is safe only because withdraw preclaim (checkAmount) + // rejects a requested Amount greater than the pool balance with + // tecAMM_BALANCE *before* the math runs, so frac = Amount / balance + // stays <= 1 and the Number -> integral STAmount conversion cannot + // overflow. Deposit has no such bound, which is why only the deposit + // path was exposed. + // + // These mirror the deposit repros: the same oversized two-asset + // request is rejected cleanly. If the preclaim bound is ever weakened, + // equalWithdrawLimit would be reached with a huge frac and + // Number::operator rep() would escape as tefEXCEPTION, failing this. + + // XRP/MPT - the pool the report calls out. Requesting far more of the + // tiny MPT leg than the pool holds is rejected before the math. + { + Env env(*this); + env.fund(XRP(30'000), gw_, alice_); + env.close(); + + MPT const mpt = MPTTester( + {.env = env, + .issuer = gw_, + .holders = {alice_}, + .pay = 100'000'000'000'000, // 1e14 + .maxAmt = 1'000'000'000'000'000}); // 1e15 + env.close(); + + // alice holds all LPTokens of a tiny XRP/MPT pool. + AMM amm(env, alice_, XRP(10), mpt(1)); + amm.withdraw( + WithdrawArg{ + .account = alice_, + .asset1Out = mpt(10'000'000'000'000), // 1e13 > mpt(1) + .asset2Out = XRP(1), + .err = Ter(tecAMM_BALANCE)}); + } + + // IOU/MPT - requesting far more of the tiny IOU leg than the pool + // holds is likewise rejected. + { + Env env(*this); + env.fund(XRP(30'000), gw_, alice_); + env(trust(alice_, STAmount{USD, 1, 20})); + env(pay(gw_, alice_, STAmount{USD, 1, 18})); + env.close(); + + MPT const mpt = + MPTTester({.env = env, .issuer = gw_, .holders = {alice_}, .pay = 1'000'000}); + env.close(); + + AMM amm(env, alice_, mpt(10'000), USD(1)); + amm.withdraw( + WithdrawArg{ + .account = alice_, + .asset1Out = STAmount{USD, 1, 16}, // > USD(1) + .asset2Out = mpt(1), + .err = Ter(tecAMM_BALANCE)}); + } + } + void run() override { @@ -7178,6 +7331,9 @@ private: testAMMDepositWithFrozenAssets(); testAMMWithVaultShares(); testAutoDelete(); + testDepositIntegralOverflowMPT(all); + testDepositIntegralOverflowMPT(all - fixCleanup3_4_0); + testWithdrawIntegralNoOverflowMPT(); } }; diff --git a/src/test/app/AMM_test.cpp b/src/test/app/AMM_test.cpp index f19743026c..8f8079c34a 100644 --- a/src/test/app/AMM_test.cpp +++ b/src/test/app/AMM_test.cpp @@ -25,6 +25,7 @@ #include #include #include +#include #include #include #include @@ -2292,8 +2293,9 @@ private: // ePrice = lptAMMBalance(100) * f(0.001) / amountBalance(100) = 0.001 testAMM( [&](AMM& ammAlice, Env& env) { - auto const err = - env.enabled(fixCleanup3_3_0) ? Ter(tecAMM_FAILED) : Ter(tefEXCEPTION); + auto const err = env.enabled(fixCleanup3_3_0) || env.enabled(fixCleanup3_4_0) + ? Ter(tecAMM_FAILED) + : Ter(tefEXCEPTION); ammAlice.withdraw( WithdrawArg{ .account = alice_, @@ -2304,7 +2306,7 @@ private: {{USD(100), EUR(100)}}, 1000, std::nullopt, - {all - fixCleanup3_3_0, all}); + {all - fixCleanup3_3_0 - fixCleanup3_4_0, all - fixCleanup3_4_0, all}); } void @@ -6024,7 +6026,7 @@ private: void // NOLINTNEXTLINE(readability-convert-member-functions-to-static) - testFixOverflowOffer(FeatureBitset featuresInitial) + testOverflowOffer(FeatureBitset featuresInitial) { using namespace jtx; using namespace std::chrono; @@ -6259,7 +6261,7 @@ private: }) { testcase(input.testCase); - for (auto const& features : {all - fixAMMOverflowOffer - fixAMMv1_1 - fixAMMv1_3, all}) + for (auto const& features : {all - fixAMMv1_1 - fixAMMv1_3, all}) { Env env(*this, features, std::make_unique(&logs)); @@ -6308,11 +6310,6 @@ private: return input.lpTokenBalanceAlt.value_or(input.lpTokenBalance); }(); - if (!features[fixAMMOverflowOffer]) - { - BEAST_EXPECT(amm.expectBalances(failUsdGH, failUsdBIT, lpTokenBalance)); - } - else { BEAST_EXPECT(amm.expectBalances(goodUsdGH, goodUsdBIT, lpTokenBalance)); @@ -7210,6 +7207,172 @@ private: } } + void + testDepositIntegralOverflow() + { + testcase("Deposit integral overflow"); + + using namespace jtx; + auto const all = testableAmendments(); + + // Found by Antithesis: two-asset deposit with a huge Amount against a + // tiny pool leg makes frac = Amount/amountBalance enormous, so the + // computed XRP-side deposit exceeds the integral asset's range and the + // conversion to an STAmount throws out of doApply. + // + // applyGuts catches std::runtime_error around the deposit math, which + // covers both ways the conversion can throw: + // - value beyond int64 range: Number::operator rep() throws + // std::overflow_error (a std::runtime_error); and + // - value within int64 but above the asset maximum (kMaxNativeN): + // STAmount::canonicalize throws std::runtime_error. + // XRP(10) is 1e7 drops, so the computed XRP leg is 1e7 * frac: + // asset1In 1e15 => frac ~1e15 => ~1e22 drops, past int64max; and + // asset1In 1e11 => frac ~1e11 => ~1e18 drops, in [kMaxNativeN=1e17, + // int64max) - the canonicalize band, which would otherwise escape. + // + // Without fixCleanup3_4_0 the exception escapes and is converted to + // tefEXCEPTION by applySteps. With the amendment, applyGuts guards it + // and fails cleanly with tecAMM_FAILED. + auto const test = [this](FeatureBitset features, STAmount const& asset1In, TER expected) { + // These deposits intentionally trigger the overflow, which logs + // at error (guarded) or fatal (legacy tefEXCEPTION). Disable the + // log threshold to keep the test output clean. + Env env(*this, envconfig(), features, nullptr, beast::Severity::Disabled); + env.fund(XRP(30'000), gw_, alice_); + env(trust(alice_, STAmount{USD, 1, 20})); + env(pay(gw_, alice_, STAmount{USD, 1, 18})); + env.close(); + + AMM amm(env, gw_, XRP(10), USD(1)); + amm.deposit( + DepositArg{ + .account = alice_, + .asset1In = asset1In, + .asset2In = XRP(1), + .err = Ter(expected)}); + }; + + // int64-range band (overflow_error): legacy escapes as tefEXCEPTION, + // fixed returns a tec. + test(all - fixCleanup3_4_0, STAmount{USD, 1, 15}, tefEXCEPTION); + test(all, STAmount{USD, 1, 15}, tecAMM_FAILED); + // canonicalize band (runtime_error): same behavior. Regression guard + // for the band a plain overflow_error catch would miss. + test(all - fixCleanup3_4_0, STAmount{USD, 1, 11}, tefEXCEPTION); + test(all, STAmount{USD, 1, 11}, tecAMM_FAILED); + } + + void + testDepositEPriceIntegralOverflow() + { + testcase("Deposit EPrice integral overflow"); + + using namespace jtx; + auto const all = testableAmendments(); + + // Found by Antithesis: a one-sided tfLimitLPToken deposit (Amount and + // EPrice) with Amount = 0 and a large EPrice makes the solved pool-side + // deposit enormous, so it exceeds the integral asset's range and the + // conversion to an STAmount throws out of doApply. This is the + // singleDepositEPrice sibling of testDepositIntegralOverflow. + // + // applyGuts catches std::runtime_error around the deposit math, which + // covers both ways the conversion can throw: + // - value beyond int64 range: Number::operator rep() throws + // std::overflow_error (a std::runtime_error); and + // - value within int64 but above the asset maximum (kMaxNativeN): + // STAmount::canonicalize throws std::runtime_error. + // + // Without fixCleanup3_4_0 the exception escapes and is converted to + // tefEXCEPTION by applySteps. With the amendment, applyGuts guards it + // and fails cleanly with tecAMM_FAILED. + auto const test = [this](FeatureBitset features, STAmount const& ePrice, TER expected) { + // These deposits intentionally trigger the overflow, which logs + // at error (guarded) or fatal (legacy tefEXCEPTION). Disable the + // log threshold to keep the test output clean. + Env env(*this, envconfig(), features, nullptr, beast::Severity::Disabled); + env.fund(XRP(30'000), gw_, alice_); + env(trust(alice_, STAmount{USD, 1, 20})); + env(pay(gw_, alice_, STAmount{USD, 1, 18})); + env.close(); + + AMM amm(env, gw_, XRP(10), USD(1)); + // Amount = 0 (XRP), EPrice large => tfLimitLPToken. The solved XRP + // leg blows past the integral range. + amm.deposit( + DepositArg{ + .account = alice_, .asset1In = XRP(0), .maxEP = ePrice, .err = Ter(expected)}); + }; + + // For this XRP(10)/USD(1) pool the LPToken balance is + // sqrt(1e7 drops * 1) = 3162, so T^2/B = 1e7/1e7 = 1 and the solved + // XRP-side deposit is ~EPrice^2 drops. + // + // int64-range band (overflow_error): legacy escapes as tefEXCEPTION, + // fixed returns a tec. EPrice ~1e17 drops => solved deposit ~1e34 drops, + // past int64max, so Number::operator rep() throws. + auto const bigEP = STAmount{XRPAmount{99'999'999'999'999'999}}; + test(all - fixCleanup3_4_0, bigEP, tefEXCEPTION); + test(all, bigEP, tecAMM_FAILED); + // canonicalize band (runtime_error): same behavior. Regression guard + // for the band a plain overflow_error catch would miss. EPrice 1e9 drops + // => solved deposit ~1e18 drops, in [kMaxNativeN=1e17, int64max), so + // STAmount::canonicalize throws. + auto const midEP = STAmount{XRPAmount{1'000'000'000}}; + test(all - fixCleanup3_4_0, midEP, tefEXCEPTION); + test(all, midEP, tecAMM_FAILED); + } + + void + testWithdrawIntegralNoOverflow() + { + testcase("Withdraw integral no overflow"); + + using namespace jtx; + auto const all = testableAmendments(); + + // Regression guard for the sibling of testDepositIntegralOverflow. + // AMMWithdraw::equalWithdrawLimit has the same + // getRoundedAsset(integralBalance, frac) structure as the deposit + // path and is likewise not wrapped in a try/catch. It is safe only + // because withdraw preclaim (checkAmount) rejects a requested Amount + // greater than the pool balance with tecAMM_BALANCE *before* the math + // runs, so frac = Amount / balance stays <= 1 and the Number -> + // integral STAmount conversion cannot overflow. Deposit has no such + // bound (depositing more than the pool holds is legal), which is why + // only the deposit path was exposed. + // + // This asserts the withdrawal analog of the deposit repro fails cleanly + // with a tec. If the preclaim bound is ever weakened, equalWithdrawLimit + // would be reached with a huge frac and Number::operator rep() would + // escape as tefEXCEPTION, failing this test. + auto const test = [this](FeatureBitset features) { + Env env(*this, features); + env.fund(XRP(30'000), gw_, alice_); + env(trust(alice_, STAmount{USD, 1, 20})); + env(pay(gw_, alice_, STAmount{USD, 1, 18})); + env.close(); + + // gw holds all LPTokens of a tiny XRP/USD pool. + AMM amm(env, gw_, XRP(10), USD(1)); + + // Two-asset limit withdraw (tfTwoAsset) requesting far more of the + // tiny USD leg than the pool holds - the mirror of the deposit + // repro. Rejected upstream, so no overflow is possible. + amm.withdraw( + WithdrawArg{ + .account = gw_, + .asset1Out = STAmount{USD, 1, 15}, + .asset2Out = XRP(1), + .err = Ter(tecAMM_BALANCE)}); + }; + + // Bound holds regardless of the deposit-side fix amendment. + test(all - featureMPTokensV2); + test(all); + } + void run() override { @@ -7251,9 +7414,9 @@ private: testSelection(all - fixAMMv1_1 - fixAMMv1_3); testFixDefaultInnerObj(); testMalformed(); - testFixOverflowOffer(all); - testFixOverflowOffer(all - fixAMMv1_3); - testFixOverflowOffer(all - fixAMMv1_1 - fixAMMv1_3); + testOverflowOffer(all); + testOverflowOffer(all - fixAMMv1_3); + testOverflowOffer(all - fixAMMv1_1 - fixAMMv1_3); testSwapRounding(); testFixChangeSpotPriceQuality(all); testFixChangeSpotPriceQuality(all - fixAMMv1_1 - fixAMMv1_3); @@ -7282,6 +7445,9 @@ private: testFailedPseudoAccount(); testStaleAuthAccountsAfterReinit(all); testStaleAuthAccountsAfterReinit(all - fixCleanup3_2_0); + testDepositIntegralOverflow(); + testDepositEPriceIntegralOverflow(); + testWithdrawIntegralNoOverflow(); } }; diff --git a/src/test/app/CheckMPT_test.cpp b/src/test/app/CheckMPT_test.cpp index 66cc582201..ffc9fb21b4 100644 --- a/src/test/app/CheckMPT_test.cpp +++ b/src/test/app/CheckMPT_test.cpp @@ -31,6 +31,7 @@ #include #include #include +#include #include #include #include @@ -653,6 +654,32 @@ class CheckMPT_test : public beast::unit_test::Suite BEAST_EXPECT(ownerCount(env, alice) == 1); BEAST_EXPECT(ownerCount(env, bob) == 1); } + + { + Env env{*this, features}; + + env.fund(XRP(1'000), gw, alice, bob); + + // MPT DeliverMin should not be capped at half of the legal range. + std::uint64_t constexpr deliverMin = (kMaxMpTokenAmount / 2) + 1; + MPT const usd = MPTTester( + {.env = env, .issuer = gw, .holders = {alice, bob}, .maxAmt = kMaxMpTokenAmount}); + + env(pay(gw, alice, usd(deliverMin))); + env.close(); + + uint256 const chkId{getCheckIndex(alice, env.seq(alice))}; + env(check::create(alice, bob, usd(deliverMin))); + env.close(); + + env(check::cash(bob, chkId, check::DeliverMin(usd(deliverMin)))); + verifyDeliveredAmount(env, usd(deliverMin)); + env.require(Balance(alice, usd(0))); + env.require(Balance(bob, usd(deliverMin))); + BEAST_EXPECT(checksOnAccount(env, alice).empty()); + BEAST_EXPECT(checksOnAccount(env, bob).empty()); + } + { // Examine the effects of the asfRequireAuth flag. Env env(*this, features); @@ -807,6 +834,32 @@ class CheckMPT_test : public beast::unit_test::Suite env.require(Balance(bob, usd(0 + 100))); BEAST_EXPECT(checksOnAccount(env, alice).empty()); BEAST_EXPECT(checksOnAccount(env, bob).empty()); + + // With the maximum transfer fee, this is the largest output whose + // fee-adjusted debit is still within SendMax. + std::uint64_t constexpr maxDeliver = (kMaxMpTokenAmount / 3) * 2; + MPT const eur = MPTTester( + {.env = env, + .issuer = gw, + .holders = {alice, bob}, + .transferFee = kMaxTransferFee, + .maxAmt = kMaxMpTokenAmount}); + + env(pay(gw, alice, eur(kMaxMpTokenAmount))); + env.close(); + + uint256 const chkIdMax{getCheckIndex(alice, env.seq(alice))}; + env(check::create(alice, bob, eur(kMaxMpTokenAmount))); + env.close(); + + // The DeliverMin cap must divide SendMax by the rate before flow() + // computes the fee-adjusted input. + env(check::cash(bob, chkIdMax, check::DeliverMin(eur(maxDeliver)))); + verifyDeliveredAmount(env, eur(maxDeliver)); + env.require(Balance(alice, eur(1))); + env.require(Balance(bob, eur(maxDeliver))); + BEAST_EXPECT(checksOnAccount(env, alice).empty()); + BEAST_EXPECT(checksOnAccount(env, bob).empty()); } void diff --git a/src/test/app/EscrowToken_test.cpp b/src/test/app/EscrowToken_test.cpp index d0decaf497..7e7509c3b7 100644 --- a/src/test/app/EscrowToken_test.cpp +++ b/src/test/app/EscrowToken_test.cpp @@ -3683,6 +3683,72 @@ struct EscrowToken_test : public beast::unit_test::Suite } } + void + testMPTSplitEscrowTransferFee(FeatureBitset features) + { + using namespace test::jtx; + using namespace std::literals; + + bool const withCleanup340 = features[fixCleanup3_4_0]; + testcase( + std::string("MPT Split Escrow Transfer Fee ") + + (withCleanup340 ? "with Cleanup340" : "without Cleanup340")); + + Env env{*this, features}; + auto const baseFee = env.current()->fees().base; + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + env.fund(XRP(1'000), alice, bob, gw); + env.close(); + + MPTTester const mpt({ + .env = env, + .issuer = gw, + .holders = {alice, bob}, + .transferFee = 1'000, + .flags = tfMPTCanEscrow | tfMPTCanTransfer, + }); + env(pay(gw, alice, mpt(10'000))); + env.close(); + + static constexpr int escrowCount = 10; + static constexpr int splitAmount = 10; + static constexpr int totalLocked = escrowCount * splitAmount; + std::array seqs{}; + for (auto& seq : seqs) + { + seq = env.seq(alice); + env(escrow::create(alice, bob, mpt(splitAmount)), + escrow::kCondition(escrow::kCb1), + escrow::kFinishTime(env.now() + 1s), + Fee(baseFee * 150)); + env.close(); + } + + BEAST_EXPECT(env.balance(alice, mpt) == mpt(10'000 - totalLocked)); + BEAST_EXPECT(env.balance(bob, mpt) == mpt(0)); + BEAST_EXPECT(env.balance(gw, mpt) == mpt(-10'000)); + BEAST_EXPECT(mptEscrowed(env, alice, mpt) == totalLocked); + BEAST_EXPECT(issuerMPTEscrowed(env, mpt) == totalLocked); + + for (auto const seq : seqs) + { + env(escrow::finish(bob, alice, seq), + escrow::kCondition(escrow::kCb1), + escrow::kFulfillment(escrow::kFb1), + Fee(baseFee * 150)); + env.close(); + } + + auto const feeBurned = withCleanup340 ? escrowCount : 0; + BEAST_EXPECT(env.balance(alice, mpt) == mpt(10'000 - totalLocked)); + BEAST_EXPECT(env.balance(bob, mpt) == mpt(totalLocked - feeBurned)); + BEAST_EXPECT(env.balance(gw, mpt) == mpt(-10'000 + feeBurned)); + BEAST_EXPECT(mptEscrowed(env, alice, mpt) == 0); + BEAST_EXPECT(issuerMPTEscrowed(env, mpt) == 0); + } + void testMPTRequireAuth(FeatureBitset features) { @@ -4001,6 +4067,8 @@ public: testMPTWithFeats(feats); testMPTWithFeats(feats - fixTokenEscrowV1); } + testMPTSplitEscrowTransferFee(all - fixCleanup3_4_0); + testMPTSplitEscrowTransferFee(all); } }; diff --git a/src/test/rpc/Feature_test.cpp b/src/test/rpc/Feature_test.cpp index a36e51cb6f..1e2504bf7f 100644 --- a/src/test/rpc/Feature_test.cpp +++ b/src/test/rpc/Feature_test.cpp @@ -187,13 +187,13 @@ class Feature_test : public beast::unit_test::Suite using namespace test::jtx; Env env{*this}; - std::string const name = "fixAMMOverflowOffer"; + std::string const name = "fixCleanup3_1_3"; auto jrr = env.rpc("feature", name)[jss::result]; BEAST_EXPECTS(jrr[jss::status] == jss::success, "status"); jrr.removeMember(jss::status); BEAST_EXPECT(jrr.size() == 1); auto const expected = to_string(sha512Half(Slice(name.data(), name.size()))); - char const sha[] = "12523DF04B553A0B1AD74F42DDB741DE8DC06A03FC089A0EF197E2A87F1D8107"; + char const sha[] = "303ACB16CF8DBD3B5C34F131A9D19A7DE01AE05F480A8A682B869D1B4AAC8CFC"; BEAST_EXPECT(expected == sha); BEAST_EXPECT(jrr.isMember(expected)); auto feature = *(jrr.begin()); @@ -475,7 +475,7 @@ class Feature_test : public beast::unit_test::Suite using namespace test::jtx; Env env{*this, FeatureBitset{featurePriceOracle}}; - static constexpr char const* kFeatureName = "fixAMMOverflowOffer"; + static constexpr char const* kFeatureName = "fixCleanup3_1_3"; auto jrr = env.rpc("feature", kFeatureName)[jss::result]; if (!BEAST_EXPECTS(jrr[jss::status] == jss::success, "status"))