From a12ab0496cb450fa6ea235a612cd15d2dc4937bc Mon Sep 17 00:00:00 2001 From: Mayukha Vadari Date: Thu, 16 Jul 2026 11:18:26 -0400 Subject: [PATCH 001/102] 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 002/102] 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 003/102] 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 004/102] 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 005/102] 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 006/102] 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 007/102] 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 008/102] 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 009/102] 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 010/102] 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 011/102] 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 012/102] 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 013/102] 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 014/102] 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 015/102] 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 016/102] 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 017/102] 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 018/102] 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 019/102] 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 020/102] 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 021/102] 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 022/102] 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 023/102] 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 024/102] 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 025/102] 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 026/102] 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 027/102] 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 028/102] 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 029/102] 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 030/102] 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 031/102] 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 032/102] 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 033/102] 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 034/102] 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 035/102] 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 036/102] 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 037/102] 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 038/102] 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 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 039/102] 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 040/102] 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 00a178fb92ca49521b937ae1a99d863765ea8a90 Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Thu, 6 Aug 2026 17:34:39 +0100 Subject: [PATCH 041/102] 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 042/102] 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 043/102] 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 044/102] 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 045/102] 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 046/102] 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")) From 63d8772f69fc8fff90bc6b5f982978d7fd7b1df1 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov Date: Mon, 10 Aug 2026 12:58:49 +0100 Subject: [PATCH 047/102] chore: Remove corrosion from nix (#7982) --- nix/packages.nix | 1 - 1 file changed, 1 deletion(-) diff --git a/nix/packages.nix b/nix/packages.nix index 01ab2ecf9a..3dfc6dff98 100644 --- a/nix/packages.nix +++ b/nix/packages.nix @@ -146,7 +146,6 @@ in cargo-audit cargo-llvm-cov cargo-nextest - corrosion rustToolchain ]; } From 07b9c59b89aae10aec31e012f8782fec388c84fa Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Mon, 10 Aug 2026 14:08:15 +0100 Subject: [PATCH 048/102] build: Remove protobuf dependencies from Nix (#7984) --- nix/packages.nix | 9 --------- 1 file changed, 9 deletions(-) diff --git a/nix/packages.nix b/nix/packages.nix index 3dfc6dff98..0623ff51b9 100644 --- a/nix/packages.nix +++ b/nix/packages.nix @@ -129,15 +129,6 @@ in perl # needed for openssl pkg-config pre-commit - # protoc generates the Go gRPC bindings and embeds its own version string into every committed - # .pb.go file. To allow CI to verify those files with a plain `git diff`, we pin the version to - # `protobuf_34` rather than the rolling `protobuf` to keep regeneration reproducible across the - # Nix frequently changing unstable channel. The protoc-gen-go* plugins have no versioned - # attributes in nixpkgs; protoc-gen-go's version is in turn constrained by the go.mod require - # on google.golang.org/protobuf. - protobuf_34 # provides protoc - protoc-gen-go # protoc plugin for the Go message bindings - protoc-gen-go-grpc # protoc plugin for the Go gRPC service stubs python3 runClangTidy vim From a24caaa6eae9fb9d1cdfa80a4ba2e26ccaa55753 Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Mon, 10 Aug 2026 16:00:30 +0100 Subject: [PATCH 049/102] docs: Rearrange & simplify build/nix/environment docs (#7985) --- BUILD.md | 54 +++++++++----------------- docs/build/environment.md | 81 +++++++++++++++++++++++++-------------- docs/build/nix.md | 16 +++----- 3 files changed, 77 insertions(+), 74 deletions(-) diff --git a/BUILD.md b/BUILD.md index 238c10e17c..ad4666b141 100644 --- a/BUILD.md +++ b/BUILD.md @@ -4,34 +4,14 @@ ## Minimum Requirements -See [System Requirements](https://xrpl.org/system-requirements.html). +For the hardware needed to run a node, see +[System Requirements](https://xrpl.org/system-requirements.html). -Building xrpld generally requires Git, Python, Conan, CMake, and a C++ -compiler. - -- [Python](https://www.python.org/downloads/) -- [Conan](https://conan.io/downloads.html) -- [CMake](https://cmake.org/download/) - -You can verify that the required tools are installed and runnable with: - -```bash -./bin/check-tools.sh -``` - -`xrpld` is written in the C++23 dialect. The [tested compiler versions][cpp23-support] are: - -| Compiler | Version | -| ----------- | --------------- | -| GCC | 15.2 | -| Clang | 22 | -| Apple Clang | 21 | -| MSVC | 19.44[^windows] | +For the software needed to build xrpld, see the +[environment setup guide](./docs/build/environment.md). ## Operating Systems -Please see the [environment setup guide](./docs/build/environment.md) for detailed instructions for all platforms. - ### Linux The Ubuntu Linux distribution has received the highest level of quality @@ -47,9 +27,8 @@ CI testing is done in macOS 26 (Tahoe), but the build defaults `CMAKE_OSX_DEPLOY ### Windows -Windows is used by some engineers for development only. - -[^windows]: Windows is not recommended for production use. +Windows is used by some engineers for development only, and is not recommended +for production use. ## Steps @@ -74,12 +53,8 @@ releases](https://github.com/XRPLF/rippled/releases). ### Set Up Conan -After you have a [C++ development environment](./docs/build/environment.md) ready with Git, Python, -Conan, CMake, and a C++ compiler, you may need to set up your Conan profile. - -These instructions assume a basic familiarity with Conan and CMake. If you are -unfamiliar with Conan, then please read [this crash course](./docs/build/conan.md) or the official -[Getting Started][conan-getting-started] walkthrough. +Once your [development environment](./docs/build/environment.md) is ready, you +may need to set up your Conan profile. #### Profiles @@ -269,10 +244,14 @@ which is only enabled when the `coverage` option is set, e.g. with Prerequisites for the coverage report: - [gcovr tool][gcovr] (can be installed e.g. with [pip][python-pip]) -- `gcov` for GCC (installed with the compiler by default) or -- `llvm-cov` for Clang (installed with the compiler by default) +- `gcov` for GCC or `llvm-cov` for Clang, usually installed with the compiler - `Debug` build type +> [!NOTE] +> Clang coverage is not available in the [Nix development shell](./docs/build/nix.md#building-xrpld-in-the-nix-shell): +> its `clang` shells do not ship `llvm-cov`. Use a `gcc` shell instead (`.#gcc`, +> or `.#gcc-plain` on Linux), which provides a `gcov` matching its compiler. + A coverage report is created when the following steps are completed, in order: 1. `xrpld` binary built with instrumentation data, enabled by the `coverage` @@ -389,6 +368,10 @@ After any updates or changes to dependencies, you may need to do the following: 4. [Regenerate lockfile](./docs/build/advanced_conan.md#conan-lockfile). 5. Re-run [conan install](#build-and-test). +If you are using the Nix development shell, prebuilt Conan binaries may be +incompatible with it — see +[Building xrpld in the Nix shell](./docs/build/nix.md#building-xrpld-in-the-nix-shell). + #### ERROR: Package not resolved If you're seeing an error like `ERROR: Package 'snappy/1.1.10' not resolved: Unable to find 'snappy/1.1.10#968fef506ff261592ec30c574d4a7809%1756234314.246' in remotes.`, @@ -412,7 +395,6 @@ For example, if you want to build Debug: 1. For conan install, pass `--settings build_type=Debug` 2. For cmake, pass `-DCMAKE_BUILD_TYPE=Debug` -[cpp23-support]: https://en.cppreference.com/w/cpp/compiler_support/23 [conan-getting-started]: https://docs.conan.io/en/latest/getting_started.html [unity-build]: https://en.wikipedia.org/wiki/Unity_build [gcovr]: https://gcovr.com/en/stable/getting-started.html diff --git a/docs/build/environment.md b/docs/build/environment.md index e639ed2d5f..5616f32f37 100644 --- a/docs/build/environment.md +++ b/docs/build/environment.md @@ -6,22 +6,52 @@ This document explains how to set one up. ## Tested compiler versions -`xrpld` is built in the **C++23** dialect by default. -Make sure your toolchain is recent enough — the compiler versions currently tested in CI are: +`xrpld` is built in the **C++23** dialect by default, so your toolchain has to +support it — see [compiler support for C++23][cpp23-support]. +The versions currently tested in CI are: -| Compiler | Version | -| ----------- | ------- | -| GCC | 15.2 | -| Clang | 22 | -| Apple Clang | 17 | -| MSVC | 19.44 | +| Compiler | Version | +| ----------- | ------------------ | +| GCC | 15.2 | +| Clang | 22 | +| Apple Clang | 21 | +| MSVC | Visual Studio 2026 | LLVM tools (`clang-tidy` and `clang-format`) are also pinned to version 22. +### Older compilers + Older compilers may fail to build the latest `develop` code: the codebase now relies on C++23 features and has been adjusted for `clang-tidy`. If the latest code doesn't build for you, update your build toolchain first. +If updating isn't an option for you, we do accept pull requests that fix builds +on older compilers, as long as the change is small and doesn't make the code +harder to read. What we can't promise is that older compilers will keep working: +only the versions in the table above are tested in CI, and we won't hold back +the use of C++23 features or add invasive workarounds to keep an untested +compiler building. Treat support for anything outside the table as best-effort. + +## Required tools + +Besides a compiler, building `xrpld` requires: + +| Tool | Minimum version | +| ------------------------------------------- | --------------- | +| [Git](https://git-scm.com/downloads) | any recent | +| [Python](https://www.python.org/downloads/) | 3.11 | +| [Conan](https://conan.io/downloads.html) | 2.17 | +| [CMake](https://cmake.org/download/) | 3.16 | + +On Linux and macOS, the [Nix development shell](./nix.md) provides all of them +(see below). On Windows they have to be installed manually. + +Once they are in place, verify that everything is installed and runnable with: + +```bash +./bin/check-tools.sh +``` + ## Linux and macOS The **recommended way** to get a development environment on Linux and macOS is @@ -39,20 +69,15 @@ Clang. If you instead opt to use your system-wide Apple Clang (via below). See [Using the Nix development shell](./nix.md) for installation and usage -details, including how to select a different compiler. - -> [!NOTE] -> Using Nix is not mandatory. Any custom environment (Homebrew packages or -> anything else) will continue to work, but then it is up to you to keep it in -> sync with the environment used in CI. Nix unifies the development environment -> for everyone and synchronizes updates, which is why we recommend it. +details, including how to select a different compiler and why we recommend Nix +over a hand-maintained environment. ### macOS: managing the Apple Clang version If you use your system-wide Apple Clang on macOS (via `nix develop .#apple-clang`), the compiler version is whatever your installed Xcode (or Command Line Tools) provides. The following command should return a version greater than or equal to -the [minimum required](#tested-compiler-versions): +the [tested one](#tested-compiler-versions): ```bash clang --version @@ -89,23 +114,23 @@ building xrpld. You may want to install and pin a specific version of Xcode: Nix is not available on Windows, so the required tools have to be installed manually: -- [Visual Studio 2022](https://visualstudio.microsoft.com/) with the +- [Visual Studio 2026](https://visualstudio.microsoft.com/) with the **"Desktop development with C++"** workload — this provides MSVC and the - "x64 Native Tools Command Prompt". + "x64 Native Tools Command Prompt". CI configures CMake with the + `Visual Studio 18 2026` generator. - [Git for Windows](https://git-scm.com/download/win) -- [Python 3.11](https://www.python.org/downloads/), or higher -- [Conan 2.17](https://conan.io/downloads.html), or higher -- [CMake 3.22](https://cmake.org/download/), or higher - -> [!NOTE] -> Windows is used for development only and is not recommended for production. +- Python, Conan, and CMake, at the versions listed in + [Required tools](#required-tools). ## Clang-tidy `clang-tidy` is required to run static analysis checks locally (see [CONTRIBUTING.md](../../CONTRIBUTING.md)). It is not required to build the -project. This project currently uses `clang-tidy` version 22. +project. The version this project uses is listed in +[Tested compiler versions](#tested-compiler-versions). -On Linux and macOS, the [Nix development shell](./nix.md) provides `clang-tidy` -22 out of the box — run it via `run-clang-tidy`. No separate installation is -needed. +On Linux and macOS, the [Nix development shell](./nix.md) provides that exact +version out of the box — run it via `run-clang-tidy`. No separate installation +is needed. + +[cpp23-support]: https://en.cppreference.com/w/cpp/compiler_support/23 diff --git a/docs/build/nix.md b/docs/build/nix.md index d0001294e3..fad8bc701d 100644 --- a/docs/build/nix.md +++ b/docs/build/nix.md @@ -120,11 +120,15 @@ nix develop -c "$SHELL" > > If it doesn't, either adjust your shell configuration so it doesn't override `$PATH`, or use [direnv](#automatic-activation-with-direnv) (below), which loads the environment _after_ your shell config and so takes precedence regardless of the shell you use. -## Building xrpld with Nix +## Building xrpld in the Nix shell Once inside the Nix development shell, follow the standard [build instructions](../../BUILD.md#steps). The Nix shell provides all necessary tools (CMake, Ninja, Conan, etc.). -Coverage builds (`-Dcoverage=ON`) work in the `gcc` shell (and `gcc-plain` on Linux): +Two things differ from a system environment: + +**Prebuilt Conan packages.** There is no guarantee that binaries from the Conan cache will work when using Nix. If you encounter any errors, add `--build '*'` to the `conan install` command in [Build and Test](../../BUILD.md#build-and-test) to force Conan to compile everything from source. Keep the rest of the command as it is there, so it rebuilds the `build_type` you are actually configuring. + +**Coverage builds.** `-Dcoverage=ON` works in the `gcc` shell (and `gcc-plain` on Linux): each ships a `gcov` matching its compiler, since Nix's cc-wrapper does not expose one. The `clang` shells do not include `llvm-cov`, so use a `gcc` shell for coverage. @@ -142,14 +146,6 @@ The repository already ships an `.envrc` at its root that activates the Nix flak > [!NOTE] > direnv only caches the `.direnv` directory (already listed in `.gitignore`); no other repository files are affected. -## Conan and Prebuilt Packages - -Please note that there is no guarantee that binaries from conan cache will work when using nix. If you encounter any errors, please use `--build '*'` to force conan to compile everything from source: - -```bash -conan install .. --output-folder . --build '*' --settings build_type=Release -``` - ## Updating `flake.lock` file To update `flake.lock` to the latest revision use `nix flake update` command. From 71e972cbed9006a475ae326cf41b8959ef8d25f9 Mon Sep 17 00:00:00 2001 From: Mayukha Vadari Date: Mon, 10 Aug 2026 13:05:18 -0400 Subject: [PATCH 050/102] refactor: Act on TODOs that are unblocked by C++23 (#7990) --- src/libxrpl/protocol/ErrorCodes.cpp | 5 ++- src/test/app/Invariants_test.cpp | 10 ++---- src/test/jtx/TestHelpers.h | 13 ++------ src/xrpld/app/misc/FeeVoteImpl.cpp | 28 +++++++--------- src/xrpld/overlay/detail/ProtocolVersion.cpp | 33 ++++++------------- .../server_info/ServerDefinitions.cpp | 1 - 6 files changed, 29 insertions(+), 61 deletions(-) diff --git a/src/libxrpl/protocol/ErrorCodes.cpp b/src/libxrpl/protocol/ErrorCodes.cpp index e81f975844..802bae100d 100644 --- a/src/libxrpl/protocol/ErrorCodes.cpp +++ b/src/libxrpl/protocol/ErrorCodes.cpp @@ -105,10 +105,9 @@ static constexpr ErrorInfo kUnorderedErrorInfos[]{ }; // clang-format on -// Sort and validate unorderedErrorInfos at compile time. Should be -// converted to consteval when get to C++20. +// Sort and validate unorderedErrorInfos at compile time. template -constexpr auto +consteval auto sortErrorInfos(ErrorInfo const (&unordered)[N]) -> std::array { std::array ret = {}; diff --git a/src/test/app/Invariants_test.cpp b/src/test/app/Invariants_test.cpp index ffdfe6bc83..ed09b7b660 100644 --- a/src/test/app/Invariants_test.cpp +++ b/src/test/app/Invariants_test.cpp @@ -437,16 +437,10 @@ class Invariants_test : public beast::unit_test::Suite XRPAmount{}, STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}); - for (auto const& keyletInfo : kDirectAccountKeylets) + for (auto const& [keyletfunc, type, includeInTests] : kDirectAccountKeylets) { - // TODO: Use structured binding once LLVM 16 is the minimum - // supported version. See also: - // https://github.com/llvm/llvm-project/issues/48582 - // https://github.com/llvm/llvm-project/commit/127bf44385424891eb04cff8e52d3f157fc2cb7c - if (!keyletInfo.includeInTests) + if (!includeInTests) continue; - auto const& keyletfunc = keyletInfo.function; - auto const& type = keyletInfo.expectedLEName; using namespace std::string_literals; diff --git a/src/test/jtx/TestHelpers.h b/src/test/jtx/TestHelpers.h index 5c8486e6c5..801c3627b8 100644 --- a/src/test/jtx/TestHelpers.h +++ b/src/test/jtx/TestHelpers.h @@ -43,6 +43,7 @@ #include #include #include +#include #include #include #include @@ -315,19 +316,11 @@ auto const kData = JTxFieldWrapper(sfData); auto const kAmount = JTxFieldWrapper(sfAmount); -// TODO We only need this long "requires" clause as polyfill, for C++20 -// implementations which are missing header. Replace with -// `std::ranges::range`, and accordingly use std::ranges::begin/end -// when we have moved to better compilers. -template +template auto makeVector(Input const& input) - requires requires(Input& v) { - std::begin(v); - std::end(v); - } { - return std::vector(std::begin(input), std::end(input)); + return std::vector(std::ranges::begin(input), std::ranges::end(input)); } // Functions used in debugging diff --git a/src/xrpld/app/misc/FeeVoteImpl.cpp b/src/xrpld/app/misc/FeeVoteImpl.cpp index 76a4d8f186..f1cb944a52 100644 --- a/src/xrpld/app/misc/FeeVoteImpl.cpp +++ b/src/xrpld/app/misc/FeeVoteImpl.cpp @@ -260,39 +260,35 @@ FeeVoteImpl::doVoting( } // choose our positions - // TODO: Use structured binding once LLVM 16 is the minimum supported - // version. See also: https://github.com/llvm/llvm-project/issues/48582 - // https://github.com/llvm/llvm-project/commit/127bf44385424891eb04cff8e52d3f157fc2cb7c - auto const baseFee = baseFeeVote.getVotes(); - auto const baseReserve = baseReserveVote.getVotes(); - auto const incReserve = incReserveVote.getVotes(); + auto const [baseFee, baseFeeChanged] = baseFeeVote.getVotes(); + auto const [baseReserve, baseReserveChanged] = baseReserveVote.getVotes(); + auto const [incReserve, incReserveChanged] = incReserveVote.getVotes(); auto const seq = lastClosedLedger->header().seq + 1; // add transactions to our position - if (baseFee.second || baseReserve.second || incReserve.second) + if (baseFeeChanged || baseReserveChanged || incReserveChanged) { - JLOG(journal_.warn()) << "We are voting for a fee change: " << baseFee.first << "/" - << baseReserve.first << "/" << incReserve.first; + JLOG(journal_.warn()) << "We are voting for a fee change: " << baseFee << "/" << baseReserve + << "/" << incReserve; STTx const feeTx(ttFEE, [=, &rules](auto& obj) { obj[sfAccount] = AccountID(); obj[sfLedgerSequence] = seq; if (rules.enabled(featureXRPFees)) { - obj[sfBaseFeeDrops] = baseFee.first; - obj[sfReserveBaseDrops] = baseReserve.first; - obj[sfReserveIncrementDrops] = incReserve.first; + obj[sfBaseFeeDrops] = baseFee; + obj[sfReserveBaseDrops] = baseReserve; + obj[sfReserveIncrementDrops] = incReserve; } else { // Without the featureXRPFees amendment, these fields are // required. - obj[sfBaseFee] = baseFee.first.dropsAs(baseFeeVote.current()); - obj[sfReserveBase] = - baseReserve.first.dropsAs(baseReserveVote.current()); + obj[sfBaseFee] = baseFee.dropsAs(baseFeeVote.current()); + obj[sfReserveBase] = baseReserve.dropsAs(baseReserveVote.current()); obj[sfReserveIncrement] = - incReserve.first.dropsAs(incReserveVote.current()); + incReserve.dropsAs(incReserveVote.current()); obj[sfReferenceFeeUnits] = kFeeUnitsDeprecated; } }); diff --git a/src/xrpld/overlay/detail/ProtocolVersion.cpp b/src/xrpld/overlay/detail/ProtocolVersion.cpp index 2d5d0a56f7..93d4fae156 100644 --- a/src/xrpld/overlay/detail/ProtocolVersion.cpp +++ b/src/xrpld/overlay/detail/ProtocolVersion.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include @@ -32,31 +33,17 @@ constexpr ProtocolVersion const kSupportedProtocolList[]{ {2, 3}, }; -// This ugly construct ensures that supportedProtocolList is sorted in strictly -// ascending order and doesn't contain any duplicates. -// FIXME: With C++20 we can use std::is_sorted with an appropriate comparator +// There should be at least one protocol we're willing to speak. static_assert( - []() constexpr -> bool { - auto const len = - std::distance(std::begin(kSupportedProtocolList), std::end(kSupportedProtocolList)); + !std::ranges::empty(kSupportedProtocolList), + "There must be at least one supported protocol."); - // There should be at least one protocol we're willing to speak. - if (len == 0) - return false; - - // A list with only one entry is, by definition, sorted so we don't - // need to check it. - if (len != 1) - { - for (auto i = 0; i != len - 1; ++i) - { - if (kSupportedProtocolList[i] >= kSupportedProtocolList[i + 1]) - return false; - } - } - - return true; - }(), +// Searching for an adjacent pair where the first element is not less than the +// second one proves the list is sorted in strictly ascending order, which in +// turn means it holds no duplicates. +static_assert( + std::ranges::adjacent_find(kSupportedProtocolList, std::ranges::greater_equal{}) == + std::ranges::end(kSupportedProtocolList), "The list of supported protocols isn't properly sorted."); std::string diff --git a/src/xrpld/rpc/handlers/server_info/ServerDefinitions.cpp b/src/xrpld/rpc/handlers/server_info/ServerDefinitions.cpp index b561ce6d38..32a084a833 100644 --- a/src/xrpld/rpc/handlers/server_info/ServerDefinitions.cpp +++ b/src/xrpld/rpc/handlers/server_info/ServerDefinitions.cpp @@ -64,7 +64,6 @@ ServerDefinitions::translate(std::string const& inp) return out; }; - // TODO: use string::contains with C++23 auto contains = [&](std::string_view s) -> bool { return inp.contains(s); }; if (contains("UINT")) From 2967f1f0ccac0e573ffc0a3ff6c8eaca928505c3 Mon Sep 17 00:00:00 2001 From: Mayukha Vadari Date: Mon, 10 Aug 2026 13:06:29 -0400 Subject: [PATCH 051/102] chore: Remove unreferenced legacy documents (#7989) --- docs/NodeStoreRefactoringCaseStudy.pdf | Bin 393922 -> 0 bytes docs/sample_chart.doc | 24 ------------------------ 2 files changed, 24 deletions(-) delete mode 100644 docs/NodeStoreRefactoringCaseStudy.pdf delete mode 100644 docs/sample_chart.doc diff --git a/docs/NodeStoreRefactoringCaseStudy.pdf b/docs/NodeStoreRefactoringCaseStudy.pdf deleted file mode 100644 index 6cde8a2eedd968662ee2b3c49598705be4321628..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 393922 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�MI6-|wp1>+Wf*QHH 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

CH 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

}ku;=$~rH0d$c}Tu=i0qPihxjlNJ(Gm%B@!ckLH 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%0DG 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∾M$>fBlH=v(8J;P}}9-PBZ$!mUmQJ1{}e2UNjrHwTRvf^Z}Nr4&v z-M}zHXmI^{i~Fd=;TON)rrY~)#wX9vDh(32|1qIJ(Wyka7Ujfj|fXXr`q0p?jbv3!=ko4%t4{qdS`9V>nH#_+Tc z&+u4vui_Q=r;y`XOJ!^m;pQl15H6#eVJ3q%`UbyPnC)G>25;Y0;4sON6E>I}AN$u- zbu*hJduQCPe8B_sU4p~_{9V)Eb&!szRiIsBd_`&*(NUTA$=viA#Xn8g3aW9gBff~D zZi@D;N`BEhk)o(3LZQga{PU2&pfr)3u%N_sLksP4C@hf}y4OaI6zypt#|36+Trg8( zw{u6qKmji>-KX^)f5jlUC-1F$S5uS>`$niyj_+F$ZB(04CW441T=`(MnxV~{05w5U66o71*!+qP}nwr$(CZQHgz-P7*AJ?G+_xN+XE`{z|; z?Z}l?D>G_W=BmB3R;~}TWuetT<^C^)=sqz*ffI|xaUw&SX$Ds25KuuJJ0q$S63W>rdXM1sR z5(Nds=pac#eNmKOUMTKs?m8lBd|g2M6l{H)^b252UH&~*gO?~6+&DG!VrP>q1<}RH z(dA0zC?F)DV+(!zeexONaoJ+o8(AVNRLfW^Sj%S23eM*?PtuqbS)2>=zyj_Ner?Ah;?TWK2=j|lki4>%Q!*;XdR|rvH#ffHwr9=J# zM)EfjR>b%HKFZT z+NX7~rGjl(E?(_qXXCT70~78lRWv^S&{VjQF6JGC9c^+$a@~dOZ;^eP`>j2E;x~m) z&{=GLgF5GLNy$=|xMD^3g@ygYgiIkhyphdcDT8- zO(?n>%(Yp0nDd?B1gGhkV&-y@#bQpv{f4E*^OHOB_# zPB}Gn`f10#u)-C7g@K@>_xk5X-O-+zFZ6f%O4~4IBQz1T6)S`mp(gpb&9XYF*`;0) z+3AQKe%%D5B35R{YJTQ0NJ5UiW_IIcibPeVs3{dCCDTFU^^Ac^38@vSdv4<3)iX9o z+j)E2E8l1t4w+3+;>6RTgmE{jtQcX#`7*6U6bp+6g#La`BV+{lx$Hs`3NwPX7YA(X zE_&^4HI)<@Bt=47(KivPC_rxjKYejv(VK8#($%YxOgU%`D#^ylMxhxMBU6)fDv;_g z4ln_(lxRtqNthtUu@FeIIp+|KK&4Kw-Pjbg0qsv5!wS^06gCmY<)t_~`W$7kRT^M; zKC!xshfM9Uc-}VdIiW;zIGg8Xl)nnf`nME{&znI)^zx*&0kcxTTbKb!RFPj740}%EG2NJ_3?u+;tK^0Uwf zP+_*1vONL{zKo0N2o!}nRn5NHPv(%~Brf^!z29KCh#sxH)Y0!+Pq6JeXR9^VAwk=T z=Z!4PWBkkP26OzTB^_&uBaYUIgV$zN!X>lJ_Itp1pZGgf_l24bjPWK~eYomDy(UN~VO3#8#3Q z(Y=^Mth;D={{ZGNhNCxmW>gZWf`B+40zDK-hPCNy#4W=2_;ZAnKrLzKc%fJzuZPI8 z+UzCs>^|YIJ?XUox}2UtJ-Q6;?H63mCC}Ub#drt=vsGU!+ZdSUIO048?K#M$NGT(F zi9^l$7Cl$HQT`mPQi0+-P_Q{^(K2dwGTgFj$YHdo2OD~l(QN$I04#*g0BwH$elKK} zPkqv*Q@G+uc7j?1iTUOywT-uzA<)!~j{Hr^r#F z>i(&9Z77%Nj_<^mM@h%q>m1~QDVOo(T<=Dsub`m<(VBC+%P5+fgMCL{wQah~f=EZ) z&xo11o)1zg&@YL?UNZf})jcT81cg9N#S|`XcEZ=lqwVq4BDif|=gVTcsR%K&81}rq zWI_-B#6p9)z`!qf4_FZ@kx{hZJA~mqTN8CcJOEMFFWccg3l@(Ic5LlSCqjt?Vb1-j zKZf%*AE%~lQJ9vw{quniYYXXn&x&2PGk1+Xof*Nf<0 z7_kA{plfc;J|L1FsS41?18l2OP-O4Z&#R=qUO-DVdniU&g1Eyh}qD0tB@8ti69THFMDW+l3WW z)EEQZ(Y4{`e1VnsMv)!e)xt~${gv>L!)|AAOKCY&tC+0{d!n%`bEjk$Ee+}Vm^kHV zZU!%S5~2e@6{6))(RYm2Grsws z1AWz^hC*0Aa;RgbTFJAcW>C&TXd`qG*vp%k5C6T(S#X`oMdxMwv-pUh8P@oxK@gk+ zs9=}a-bNB#nEE7*+-3;nw?Yo+jl_jQF-Gc&MYu|VO@q6sI^!UE*Y*o-1iBR}6Y(@? zu?3+U| z4}{uYg_;IzJ?xB0>phEzWlI{BP^O6_3Ki$T_KW>+_`yh7fWdqa4u)a|14yV9zsFFJ zVG1G$u`{v6el=V$^ZMtlSDsdx2rML3`as}{ihr0D{srV^k1IodB6%yt`JV70>umS= z^r>&-OZ!I4N`$nnE_eA3^#-+9!|QW9TG5N3WC~B4`AwC%^pO2LEiAX!DX)#jpP^hF zwf4RKcKytu&L{z!%hP;b9`9X6%Ua)$Dvls=(Hr1D$ackO(n=BuCkioUS}LLOXw4ch zr-;}ysG%lV{d;Rh(G~uVM6X|UnLT*=3okoy{^z3?X z4OvgfPmlv0FW5hb=vMzeh{9gb&4M73F4Pmj6UTxoBO-W z`A0hX+uP%qFj@*HYD4C-&7cn?Y!fBOzvRwgj>l)FWuEy3{JwGHd^`YbMq?%4M{DwB z@-;O9<^Jr`k#4Qz_Q!!?=9e|2HM5Q22JqM{NSm*TLbc2%M5+yl2?BK~dLHwgLx_r8 z46?V6Sc8($s`?v5pY!5bPs(1sf%HpT+nf6p*(!(@pb~VxQ$jt2dR6GGQUGK<{oWXm zI>W*-SZ?3ysTp*{a$_nYk8SjgD|88DPxT*uRF?y~w#8~Xcn_+>bmoYx&}We=UDz4;fvCtA5mV-{x> zNz&F%YQxiFFrkv;caDY49QD&$kGDK`TITC*(nUoDhNs|T(^;CD=4Y^2CzX@pC-zlH z2InQ~Dwjg#->RW#G8i(#aJ6AL5HF8s)1+&auiVyxDC zlsJY$_59!R=ST4;pQACi8%FdZd#o@Y<>6&>qe3;r&pakyrlt$?5)=Y zrYSMnz9|^GYTTb&PtrfHZVwQr6Ls747qaOzJHum~EU$yW?XY_ zIdnOA?NL12|JdBitP&bQy=$J5tjaWmUc0!o=fvlTcaiiQyvcE$y(w@VNTdtZrV)_S zA8X)Oi*eE1An2}iQF#&h`1jg<^7$b9+O0!fvQ%q*4@?WB+$KtRm?IaKVJR0($!lyN zdoxZ@MbpG0X8e^bn2)xYv29EH^InvYl@H2_p7F}r-{)o7CtH5r3q767WTmv|J2?Ef zON!FDn=9|=JJ@j4BRKsz9V`Ep`!|QVEBas~U7K5_VJF2%yrn4F=u-cceDmlhF3Tuv zKD~IBYm~LYC~A11;crQqp1WouKHMgJU@>!i@k}|hK|+90nE*o^aVTqZC?e`6IgO1V z5i3!oC zS*UhdSv^$0U9&bBc#6k?__~?g9(UTCU(yH8-9r_g- z)qAvO;+O}ZxuCkE1=Lw0zjtr-NpMlG#*HmPHJl1by_^`noM8N*q?f+8RVat8HYw=HQEP=O)v(!DpFq^pY&to z`Ww?MD!R&pK=66h3B2XDEB1W$*LetWpYsTUH7q><%G53;`Un&KPcU3sHrh3jAr_8r z^eZtHUG}(OVYAK77gBdpmo$L zl2AObS2}LlxQY)QdlYJInhLFIEbL^PO=M?u115B0hn_WblJ+mx#)$8HaIdcKv$e|r z#7nrHYiH)Snpy>`OIZcGAIhMyct~22Hc^GHuOj~2tBzH#K|-rz)FW;Z;|B3Pyx*Y?-58~`(;^%xf%*R2Ca|>g)WV*z^)vnYm2*Gr zyq~bqaj>A_Fv|sG82xON+b8z4R~J4>x_SE~0&<}AD&W&t&aPXskhl&6vqM~VR(HUz zY{H|Ts+T~5TqLVUCjELsf8vZTTgG_y|}fKz4I#3w^{mX zwn9zj#Ih+1Qe*kii9YfSQV~;w96k0@=3<8~YvFVKF8)R_kbyQ88lCb{jW+^2AOch5 zEf$8iX+or4dG18%(x06k=X!TkZ*6||qa`?HJ9BUdSvipiOLbZVjC z94fOEKy4|c{k{>kbAE)fAtk?uNoSwAohn01!O}j+=n}6MW~zI@+oo`HD6Irj6+PX{ zKyu;wy6;jNLUv$v{*KFM=Pu$LWmd8$m4LV6OFl3su(5M@vH+Zg8C@ENCvesDPDxd` zu9V{-aFZpL&?Jg*8HOY={8=fJJI8_~qVTN@zhWjjqacS2*I7w~aDJeSR)dRk1HL+3 zMYK>rP|)h_F>Mx&j1p={<7m0C?FxRkj?n~9apM(zk{KsfxiFrPjHD9jc4-o6?v>+2 z2+wP%l~J$2At&1(wYyPEV~x;t19RCTv_lO+xr;F*840LlN@bl93CQkY$SBu$L}Vuy z-85;lKVC8^D91jl@%IcoiN4sHzg39iX&82nINuku`P*jF7kl~*i}>QU-*ck3-I7-D zxai&HPzdQBFZAi^OhQYA1nTwpJ7(@aMVKv_{}?EM0mc&i^j-iPXzB_EqZ_HgyqU*K z_#gk!XqM`p4hyEw9XdVx`ZP90X-1V~A5FP*2Lln+7;+V167CZKeI?C5Ccl?O7OO%$ zQprOg%+gf!qVxeVN{%u80O#~tE)COu#cqtxR;&PprCq-^LK~Ud1X&G(SS8pEn*FS5 z4opv*bmN}`+^+=L!{3WIp&N72#;Vt0K;toY_@K}WTgj{WkceB4w0+8af_;A39kVvQ ze3`#5Jtug$htr1juEE{q_)^oS3CbnrWbU-*adAn^TvA8W2=nFY(KcU}NIYR2@ukqY z)2Kw;4XWKY15elZhQ}O!PZ@!n<#=Ig%y!4k*z}N7?sH|0SvvSeF_k1209mbKRZ&!d>u&AQ5L4ZsWGq2=Z-8c#6 z%zDO<;oxod#!%r6z_Dk*Xf&hjmoxU4Zy7GHSL+@QQuh1!^Z|W^`GWlx@144Z3;z5% zs9VGoVRzarpw1sUiph{`kT4FDr%)eBAJmJ@2KMPcuWDe=cF9~coNS~+@62<{cgXV< zsNu?Fr-k#lG3Z^rc~1MpSjjzna%0NC0@aV95_wcz%di$~iPgK4N+CKrqW^N3q?n|K zvIO`R^|K>3yB^smYenF^Y}(x(>yq!ThDo@|Ny!X3d57t^|Qn%=Cz*aWRvqmr8WjQ3RmxZAO60vZb=i&l@9S=?a8 zgqjTgJv#A4yeVsa5W7p@8MO^~80X-1Esreo?XAX3C=_)nvpVYrcs=P`5T+fa!rmdD zYnV+M-gCAl=fb&h@?xSQ1HOzRhu;ctrI1J53|9kmYdXy`27~+Lo8m z2cU5_d*f~6^SM_9>rW%t*s_nk_OGu~MRZ)A1RUFr)1h$B8+LcZn$=GEz$zIQ+;;g& zIdfa*PK1N0cj)Q{@2no>;sZ#ZSdI2BmBRTI3p%?tl#$rpTD;6^_nKFgwyKHy;b--9 zViUSwd5W<{8#{cxl76HqjK(4~>BOw*uW%c@;km@(^Qv-Aj!cI*wuN1x=3l*{oY4z%dYAN>IH&xrJCL)ew|tUYT{5W(l`WxuUAP1mY_1K z5!%&oUwJIQJv{hh{9p*Knvm{-Xvy@My6?Blneqh{QM8LBfuAzq)%FlPp-O4I>bN0r zv}z8>&aX^gDi2uv1SCkgh!%3OK=-1?^y$K+{%@PCJ}&fD%h_a=$9eR9@`)2<#iA3M)BVI-AYQ?d!=H@G#M?)yJ~X4p8p@?SNN>1+EYbaj4asV zOTHxviqN1_5p^BprE2vG7m>ShAE6YIV1La-})W$!zQXHVd`+fn^_h~rTYzeG5D2A$X)_v${xs@{)SnDA77ls8ayts}LP;x*D2^fCeB;bg@qbIj`Ks^rue< zMgN#sl&4ZsYo>39@p8bFOOsm@aW|2kG61rN+E)jk6>v)g<)=nsw3@>yF)mp*<4jLk zR+heKaqzU^YIeOX$6Rejnf-Wb?fKl8iu>E&zZB@nVzrgn>N}b83C3c}(`Qw^v1-4Y zX}i;Eq3b*yC0yBSe=x+V!aDomcw4i5sdBqUUn9l!VYk~y7|XZ&RQgC@hGrRuqwT4N zGF6WhiDBEf7~ZW>+)(*u2S>UXeSii>esZW{gA50Su~gyFf@MQoi;2w39pUS51_p}a z0AO(hSP*+CSCsDVW{!b5YlNe)84WT5b?x_X-1yzTP{Fbmc@(SR06j#rqi&XaJO5Wb z1}-WMy*ZYuQ*ZG!RI**J8}dx=jSI5q+rMg$>f9HLe6HLnD+XUO3g)~QhSRy2NQC}T zyeF=U?{vjo`jTDMA0m0*PwVn047l?TYo}eDW|iY2FVg*7%ve8rM#oh_ z>i1`c-2efzN2&`z0!UewCl6XqB z-c>a+JRYDb9?qRJ>mZbc1``d@S$4V{zPke_vm~!Q;GncmUNH$LeX^@W_;b>JTT) zTRg)O;DWgD9#6Pf!;fR=EduRTZ~zpeb_NRoj3eA2BB{bFXemlZHHrvD_sU|vLTXK)@iEwtI*)-YJX@gC1kxL&6UzCu zcxLYeC#NfbsuW_wFFN1dryeOWkMBgt;h&Q4I0o)b{fhAf8P=E{(jxF8(FE*d1LlQ` z!9O~;3I71dLVw+cTrIL2RM&Xs&LpDKcBth9Dl2O5g^>f%lGoY)P4kB2z8YAv5BTA1 zLdqGaO}seac$5v9d4PS~+orm#db)JnRqHOXSwXo(%i;#@8Etza^5b>b+wwtZ+dFOb z-u)>Zx_GbhwGBd=_1uPdbM3|YZ4CF&b@5qxD>hH@UFFAfr*$_iXM7ej_dYyr=LPS> z^PzGscV6C5n{t}us$xM=8-c8HUg1&6Y2jfRQu9VkUEh9&(Kb6b`zl~F8O}`jx5*2H zz~vDkW1LHr2UTitH5?bVgxI>6CuL*-!SYv<$-MI!O?Zm`lqVv!C<_zKJfxCeHDa|8 zZ$Co#i5$q6P=RupAB03x%KA8y%#wBV6&zx;d<>Ur!SAA^PNEFq;s=6%+6Lh%gwb8v z1t?;qxAA7g7YVjWX0Yo=wh~Q{MqecJ8eF>kOoqRq%*~VjkW-TqmssUD1w43?RTq8m zOE@NdK^7|)K_@?pGbQCSP|wSSWC<7KL$l`!3I2A_O)w)9v26Zi<~w~Z&x1Rc;D>fqk=>rRBC74)S$&v~!G$y5h_ zMXJL2mje8s{z43~d_-6r5PnIxi-0-cAksOQJZdKp?#bFb|6vXnWI3>CD#@5)z zyu8i$W4;c-s7}%DE{>MsS*h#FQtV9AiaVDaz_-_Sa?$sZ;u-nfNm#z1Xb2@ z^v#3yzCQ;ZSGPA~sH3csmXO;xrOeG$b{E43FDIz+hTt?cnTwi6Ts7$gZw;ut=-^UZ zANrB6VmmzAyE}a5N(g^3+dw6sixiTDKJ5Zm>VEqkhFja7ppj)~(|7!Le>EOI?V5}S zd0%lv^BnRjzu^btwGpGf!KgG1?M8kB=X&5(;}yM~=sLa2_bKb~tnEbZZW3=V`S*7h zx$Ffo?ZVJ1qw(6gxI>Y%>2t2ZWv+%P?U>cX-$|_o3H3y)nkv}6C~@*f{~bzm^C z)WY`_T9Ki5fp=Td7k?-0OG=8}0hw+BC%q$f(KRx7W^izIREVPhGsNe^YUmn*_pqpk z2P-PnyxBdmo&r4)w;=2onjrT~!UqXh`_S12wq*vN8)OUd?MC~vfkoL2`+ft>pxcf3 zAsLDynB*G7LZfxyp9>QH9T*FJf^`8d?x(-vzEU{?*j|-%OAcxUq5<`44Qj2|3@x~WJlYXv<8r8 zhZ6iVNh&4Cs>s2A4qR-Q6eG^$f2=+you9+!Lf;8YqsrPDmMv<}$O(o5pabp`fL5oQ z=f=aCdSK~q3$z97K+^YTt+M#U~i7VMkihX%ntU>n56B#?uV z9|vyA+19=HCB|O)%LddH5KEGneiK|22)iqe0Hd|ktjOEZw74`MH)-`OuQFb6SA74Vh9A6a5eK?%Hr zYbtvTIxV#>b3H=KPkU-bKUK3#l|EFCu?^PlbFYOiG~}4XyZiT1m=$|l@SckQCbSJ) z8)?5i8A%8@yHr$oJyd#d6|{!yPjC|!0sCcT0vfvrwZpKpORzO~WJU06fKh=I!MJ1i zEQ^s-&{4ich&;nR8NYWzHibO^vZ!CkBEMwle%%8pN$?-@0H=bE3J^m2eT_3XU~IzF za7jY#gZd)@>%t!Z^Rs|&@d6GpfkN1VwqkG5lTS%vHm0)3(4IZ~Jc|KF$cn+vOu7f1 zM4wmmxfFGIVJ5us*}#oj_|0ts9S9wbi?s5U1;wNi)?jbM?Hte>aEt}*fcL-$_Jaq3 zz~I1^@Gb*3Ix%Ce?Y2NhS=erZ3@MjFUdQk1=^86YSnqaXS(ob{=Hza zcF%M{u0?$q*w_tjb`5Qw9NefJ*vP1Bp{RlhZhjKk3Q)zP_J|o+M;0M_R5%-=3~3g7 zf?n?!;g1~k7tGPzHsvm4;JWJBW}E=%kbZ2AAd^TF-9pJ>zCnyXM3c`yRsU_qH+kn% zG^C#|^vZZlQJj~RPo!gk-77 zsm0n%qs?3?U5AT_dyA>a!baO2{;*S{k9BB^K4N|$xsXj-O?MoZff@etvF(U*^MQD4 zLExZ@i)?VSr5W!sB#qi6!#K;{`4BlzFhHGF-D=%dOH3w9aMwbk!QqVFbW$z|-rQ6Y zDQOF_$NE^_gu`(;You_BDL_L7N7+`tWL913<$^kz8toV!?qj5IOb|XwYv~{>DQu0Q*=0y`OFng_`xlu-UoSrY z2mIx&8VuZvALn7=3u}+tpdo9bSA3J$unM6Pvhj*!OLLhAJ5mN)+70dR49?L^f;|B>1Uv@%ewpXR98nL3N} zrH2TmVlXi5M0-k&TbAc|BU?_&MqMPo(xCT6`b8{@5IWC=eK#lv5szKs~tJ9yl^6ZGC>OF;YQQ{%*wq!8ZG zo-jOo9R+Qr-cG9bZi>8_GNoFIhKfVR`GuN_!Q0Bb0x*E5+1SrfOdl3-y%i&cz%CL3 z(|W)`1T0v@q5bnf_#-Pi8C$F1vHj%!)h}XhW&Bg+e~>X0Ab%(q#x_R(t_hN! z{l8!}{wE3pkC}m;_D8|V_~#<5^qq`_j16s#jQ=YdLpw=2Hj@rM@gZY-}qYaufWC_n)Kx$MWd9R-50en0r)vxu82Y zy6~eQ>04_5rg~<3eoOBt=^LxA{v(*5>zc!UQeLet^acbL_by_+d2;Dyel(kAd}F44 zsd%5!cBnjv;^z16wL^VUn-D*?p3y#{O$nXRy2T{y;8szZfBm%CFBSBKdh1v8YB(d{ zTgiP+yH-1=OUIhut4Ax~TWj;9bhz*i6!R@Q(lS*&=lqMDh6v8jBC7^4|^{Ry;bqe_f1=i&n|q z&iFsNA+4gbfzv-h$-&w9KS~Ak9gY8W(Era-V(#GRBxt7Z@UQnv>Hn`h>yMynZscU< zsL4V{hxh-mL;gSL{%iOD*ZSK3bMXJCQTnj}|E!h*B(0LIvW@w_Vf|C|&jS2kMuF`= zZ{Ghh3iRy%Sa1sejbZpd1RVx821bVeY9_9|-Mth?o4=>JRev7D)G5@o3&}>~kyer{ zE&U^TmQnO+!?2Qd;#kmYen1wxj_wqF^PGvBmgymoB!=j^upCA-6L^8>W=SlyI~z8 z1g9lkO|&i8r9Q$?lTUMr!;M2c>J=0?(sDrS$;CG&q|N`}d!RK!b<;-VZA1LH1q=_67U|69(N7PVZ-laf8~2HZh$QA90&h&t=I0LXh&jBjf+0IJc7``cb2S;`oy=_ zW=Q!!u{uV5z>_Z20f0P{NSqYsNf#+N@_J}gx-2J1jv_|tmA(P}hBpT;wZwaJc`$T{ z`v{&Xsg>l%7N093eQiAI9O5%6xWQH$QZ3=(2<4$LimVO)#^Xp3)R24kT990cNB@ln zxBm`g=ANXoh{2(mM(qT_3grq;8@3x)lTb4qrHRmgU3LI zrJ%37I6^uAtR#I;Y&^FSt;IJiqV}9OWORYIE&?jJ=CDP*HkWLd!WKRcUJpKXDvqQQ z=~q@4zPs+j`cdZiE>WysZt{y5dwOh+js(XT3VQqIxNHBD=n?gU>P2benaCT;oAjIZ z+wCL$8CGar%5%mq;+uw&kY~Xbu(LipCNQXuTe+NG&4shXkq1IUwNjuz@ z=r0N3`1IV%$x&`zWpLY=(gLJ3EuME>lutvl7G!p_~-Q4^XgBGQM|mbGv%YBJuIt;w)30_>U z0uR|ox%7@G3ZR`@LF~@N>j0I%`^7$?QQhDTKSdt^ti-@P8y5sHilEM4A2l8yqt7mF z7jO69!KqH|IRb7QUv5fy^TFodZRZ6aV;_lv?;-i|p`&NfRv=Zu;h<(AXu%=qrKgZS z&t^gE(Y!s9-4b8$Hts0zX%BXbq{eALiq92lADz$T&qWtF7sRzAZSrpryOn)2eL7nl zu+q9P=1uZ3K0r3YUvpz{d==cp$ws5t?!Natp*}I)(`N{Liwx@?H1E1oyu#d@w9E=_ z2NQZEOBZSv-+|@NK(2inBDYAt{Cdc>L~RDIYVmLhiv~P2_-#=i>M?d`uJC>RjIsK> zUL)rKKYodDgkr(N&G|gIzNx+`J0M%1qc)6jyiqbl??{;@!U#azC-QszLJf(PNiMtL zs%pJl+o0UW{}68r#3800kg54&?S!%}EU9M;cIJL$udh^*oD-Nl(@!-G@?^j-OB-nI zqZv0QI3QWKy`5$nNk@4zaJ3!mz5rewIgV#ua0)N+Eh0T6Ve8+l>zD2h_2{0&B{4b# zY&0!?b9k2z@1MQP-vjdWH$mXx^JsMp?%ZPPkhCn_EAu0U3O-g+eT_aw9Hir-@p zZs&04u;y@Dn_N@`O5Hf1JR%R$*N;csN9bKkovxcuTgXWMv26k^>8(r)sVPw@$#uBU zfzUzlkYTtdk}FY93cAE2X~(Dz*VCqAZSIEm1;E?++NN5skC3i()(}n|aXUdf-YfY@ zCgE%JQPhy86Xymxo?)6U?`X#xOw{D(*(Pk2T?Jk01QEYX9N}3$t9(x?7c^#PkW-@L zlcYH22xnNjd$vh*x0)rcfW(m6dL59^{2>$_$te;*r4xe72HH4W!gKg!pCM~56&ysZ zJn=oZLXffv;os8NBRD6LSEL@`OB`esoyY*mzO2d)#W(~%D*90 zlTw2vBqJgN`~`YvE8Vt0UftVkvaC!~25lNAQl_FFnHtA`oKcD7^NOn0c}q3J!8#8W z=6GFg*d^ap@eP-YqHfv(s@P2zhLUY$O0vYu101eT$(E6eIrznmi~X6BQpu-%h<)^0 zbbsel&Yb`HT3-K>A@&J6gkq-waVG$Rs6}H=I>~x!OlG@Q$vAXuso7oG;>P@NDNWD( zknTQtU})IP0%d|xKi8Wu3|q@F?L=5vTltYGjet%>mJ>0 zy!eP|YpX~cuM5F*b>Vt{7-TO^)U&a?Xft;(&uOb>FO}6Fx8Af-IOv=?Pt&?E);A?} z%d3G;TkN)m71|x~peKbEPJ62&FBf@w%T$+n4wl$D%01TYy&kZE7Q1O-Q};@DWn)#{ z&g2qu$V|4DsG)?zBtAUX9BOfLN-@47#kvG$bZY!U_H$gx4$SZPq`DQe-@dU(a}2oM3uW7 zdkNT}yY3R8xArN@!_H`$oKgrkmNuSShvP3vZ=*+sJ9?Ne*VUA?9-I#W1c7b(;3Vz7 zzJjpt*Q=`npZ_lA30C2}5;}jXLyNhrDXP?f5&kmAj$Pg)pmGX>#Ef+j1+`se^zA0T z!l1&yq{ATJy$ScnJ0RDl;b3HMC|B5JBXf0iwERyl@^i_=Dee6%uc>h8LRYos<>ss8 ztXT0xsdSRL+Sbkz{s{yI$x(P0a*Ep*FukNx(P;6nug9$fPZ4O5G=kauiLACQZdMeR zjP4!Z`USBE^vdDF#bqYu!>4@4#l=~%+TJBz&l{uByXmF5A+D{Grqo17q_sc2u4fc-v8@*isO^ zTB;>`9aJ0>yApMOHy6Cn37Arh3zHy6SZqV1lV3=-`}y?nsX+FWquKuYsmbPg#|_p$ zRgR%C=|PUcd^f|Qex+iEbpo;r+0f+>WYBndhb=0dwz@i~!n2 zL|J?$j|-1|&1@Jk9K^m4DtJ*7;$#* zoS87`j_c43huBgiNGWIP&z(NdLQ|9BL)8)XhWbnwkUt%qLv~DGAzj>gg{CcA2(~&Y zHd45HEhkBYK+QNFcHxk5manjkS-e-5{=V8E!Rum3@PisqI7<{WU0IY=y+<`b?Q*9K zK`FBRH@UK?45IJ`oGbS$Pka$&k!E=IRu@{$a-7CRgOKGFGL&@t`Vk#c=em)p8-|^Q zOk=5hB2ju-e@+TLft4cl&_{{^-6XJSHokb0NYH8y$*RT_<#d3%(93X6RlA%t&yKTE z6%<9g!zc#q>hk)h6_R9UaqN^`7~!rfcIj#a&S|bY^v8-I(T>mGR~kk4$;y`K96n;gss= z2D@0h+B<8kg%jfEL?=IpJzyh%r6V6^Zq3}xRNcM5WIDL(sRX-+Ii)4BM9;kXD%lpO z@XU;o(<`|ywK6xXq;3A$8ds zx^m#FRTUxxunN*4Vp(TnVRW{!zN1Z9aZrJ16Wk5ul)!lG@@q+6FLlc*q5xJzwiV#p zPRo(3`oIXsCqZ}X)ut}f9^cdZOzjy#9Q1YC+V~5>{ZH=!%*)M+PF8v_9=uPAy7T8) z-Mh3^#l-t7W=+W(nuYOKDMRkO;#s_`oB>f(FOAg*7>GJmm1Sjxixedk59F0@N`Ive z$0ImcY(w+_{y3{lWqLgf=%fvOahv~U1X-D&!n-D^FeytPIDa2Fokib~{W%0WXd5WtK{e-Anq61t9>>L?S#*U62yS>X=U0z(Ko9(+`oiao9% zrj)c7!rF$N-=|Qs=gsZVwpzg9CBW_PiWXrFj&aJzRj-Yg3p07OEMsQ=39hB|EM0H$ zNj4O#_^P;~ZAeY^@#ybv(WGqZeVRRlVi@g=a_kI!rmG&NYzFyuqIn=#6k<~D?$NF2 ztLo2Yf$;=hA`Urqg0`rMSZZN%z;f1b8J(y)Z&H#UW-i4!aK#)4G5@=AgNI zMsPu6rbP%XU?(IAGI^x%Sn2L zwbF#bg<$k@Kn1Z|X9R0w<(zn(}Th1L9@4WB1`}|AZ zV5GP2rfi3g?LX5bSeVC1d8$mF~E z9xSJY7l$2;z=v(M4AH)#Y33cm~@;^+N-yr0BrfDUFNPEWm$|Q~c38V_~ z^3{LJXUa&yr0NO^ka1p9_KhChu|>PG2r1sUft=dx8)aKU(O`2yC!8u7A2>{77A-K$ zk87sEy4Yay>muX|cz-R@bapSSg7L%}E=QO6vu1mEZ&yz^5nb?SRy$LV)&VG`<%)yl zRnC_dA;Gqc}&oG86QP^7^cVCauV$39S6&&fZy$le!Nc z2v({g4#218;(R}8H7gkNm*Mwg^rF3$=$hL(1_s{#9-E7 zjWG#=ErROe4pVY{8dK>K8Q1%xX?(+T3V#K`d!4R{{sV2-@i5(wEuux#lABhS5k>U_4kR4l ztRSECFA_us)-h}3uwtpqmYaZ#tS{TN`a8f^Pnbr`5l)7@ZmraWh&hfJ2y2i^-+vV_ zPA@>KWZc@B*Js6Y9_a6fC)enhdq-0bd0U~A+?42ZgNsEl8pclo$q+IIYjg%dPV+Xb z;{oMA`fC)gtNij#|AhL^I5qS^KWEpkeE2lAxke^Z#uI)DO^O{+!ymyGcz+o%n&m(GsX}zGcz;C%osz=%*>XVnJwGnbocyy z&Yb>d=9!DRSr<#IB$c%5QK`ON@8(t{hWci)Gsg{5YHpxxaT8jU5=^H--|uiLHjX~l zC}FELJ~#cRWI%Hj)@05oCq#~e{-7aWXIDKZTUuMEjLk^S-nIDXTnndtwK1(YbnZmM z(N#l#H+ zU#AM=eNH4e%WNu-qOU)nq>oO!D6Ns-<(ER$o|e%C$75#bcmg-4^*g^!+iRmWpxdi9 zuS`wdRJmOK{6u-+XxSr+M%u}GU@PY)HkZaikXLn_4$IT^O!sk|NUh0?I`X`-;2C;bkO(p<`~Z? z#7DjgekGm-iKA%p#v*4cm-dNqj94Z$sg-p*43$PDCQtR0nPwI(HMJduv5Ip1PL7pR zj-<5Ea?2)l=`p{=E+EXMG~s>SD22Axe5!V;x3Y<-M#(DtcsoDj05WqJSgbL|jJf3_ z8{GJ8u(xDZG&D~4UN#ACY=H)})$oXzG7P1M=v3LhIH=2iLN zw>xBX3p-6e1gB~VyF~R-X1oXS{X$124uROh99lMeUcw96G(ww{tC>NZ@pUp_-H&t) zwF#OS3%M?j+Cm&{*-QpyC!kn5M>qX8u~<4ZY?S~Kf>s27P_~3{zI6@xZHivWD0#8v z=P0%TpE9zI-BL?48qT0$(Xt&E6KYL??#WXj%M_=ABuz=o^>bO&AsoS$h#ymB;!eNM z$N3CE$3cp)eSX$+a~xg_5Rz?{4s_dfn+p)Seq1$)^!@Jgd3AMJOq{B}uA-qXwiM)- z3`|ZQX}^hRSANt%jn!{EepmyXc1VQ9L?9(OeJmV5v5Z

    @Oi9T3~NdE?roV&)vMrs=XaoB=ns%8nGGA(iyZztpvdpGHb-xcv8lReU-7Gr{+4)fO-v0 zunn1CAnKc8EyXZ4xI~~t;7Hy){uvS?ge!P4Q3a}rOTaeU_fc8} zm)37NlZQ&RW{1vJ>0Q<@M|UD~v9e2@gDCNsDOFUla_Z)y6{Qu?4dtas>`fq3osH*G0&!hm}+FcU9-G;=(3&WzMP$mRl6IB|IR(A+p=i z7)5+vCoHe9&OumM206Eh!K}&H7^z@-EU^ZTTNylJyRQoGQl!SU$-`{!MeTNiI$ytS7j_3L{{-R&^7VCf|~3j+)am zHp!A!q0+$e3Q5bbP!Br!WB76i#Ip)EM4K8KSznzft*oskIJ4pfvgZ-kS3kR#PqH#@ zVrDZUPa(A!6wD`Wyn7BG(ZQ{_P98BQ%Tx+i(|KL`t83tsl9PkY8hyzX`{ZVOWdXvO z72I_c$-khJIE%05o1g`4`sA_yy!+5TJR)6{@rVh$G|YO9ab`Z1+8C?rqkU9TUHN4cG)~7< zM-&?!Z`P5$9JPXya@ke}w`5RjVbrt0VeA&BcY#+Qtm(z1pc0ZpOFukX#&vkIM(Fz# zobN<(_#*SUWGCb0aTh{q0T>$VVyM2AIMm(ZVI9I4i}d=SP2J3(Wz5p#@f>uwwtL#< zoG+|>pBzZPg<5tgjh9zhqKYy0DD9GDywUzOPpO8tTQGa!oBs5Izs`{1WJ`UZLWwV0S zflr!4j@xswG8v1C6n}E#tg7olG3uHPr)<{`9!H?0)oKGuPg3oTo@pW=DzfOTL5ImS zR{wU!{?`(_?$h=_q$3Hd_6Hjy&JTJz_*}u?HBP{oG8S``Ed-avexQNDMW;6El10}O zTkQAa0}gVPxp=IMBuc%44H&)1It{;UDI3rgKL1y@bjYNbq*?=KopydQWVE02osu8=GqwS~+djZkXw62YtVgG) zoam`vl0G<{SDb5~i9Fb@aHf0|0h6yzAGS z3>jXUn_(@BOQ)SPj}e!sTe(2r$Ta+Sd>?V7L z=+CZ00w0^h)`+AX4;Zn;dMK44bhOn--SDUbasQMMwpT!F>2>YG&Lo_ri zVW@56nahc@wgRzC?jR@)q}X&puBm#Ed$0Tl4}LUuhTbku>?s_7=&dSmcXC~ZT=zXo zwNi;^PAW1wzQjB^`x#Sl#u;Mh#>LgE7hSOGyrL%dwL7VmPu^-zU|V&@o%D&Ljf+im z*iWTnC$~5Bc}v3|;RDVk#%;V_ZbU6{i%Eu}Hul?@2;)t_j41bdDW0yffi%+g*XTTN$cX0?E^glz zZb4sflHuY;Ap}(gW$#`@lJ0jcPdVsQxzcr z(K<@~Ia;;{0Y3l~tsyTA!VUwimM9lOz>FZ7W@tN2t^Usta7G%VRa4`T`V&wbOIh=B(GNrEe^BW|OX#ys9Q`q~JhP6ueq5KBYnWN#UiN=O`W0gN~ zZlk#O<+N$Ig#0CmF#u>FR0N5bxWpAppC6}H+b%9)f{I}=~d&|`wm#hH8IN}IJ4;|O|#m_5BiEeY8K?~d$@P_bVyg~ILqY@X|}y*D;nzfkE+64q~1Sx3A)SDX*wQow&E@g z0M_!U+>s~Jna+U3t@P!DREb&hl+++wQKHmBsII=8;Y8#rg@#Lb0c2#dSTr>0sKgG0 zEC|Gtssy?H!@dv4n&rdv2H_o0nXUn&?qztUOWbzby6z`yZ% z&ZDb0KdG)q;W2-lTh*0GeRvmBUlM-6dX&7o!tx{ve#bZVX{(Ci<>o4DbCOwy$N?)B znor%sK%e!){Psi7k{AI44Z}*Eu6T*%knfa^K=I_-y=0p)LrHbaZU%UVMVQcUV|E?U zOPJT~eI+Qv*RtiZAM5M3+pXJhkJ^5EAAWs(O?l+|v>EXbLO+M3(`<7AxqPBKqF4r6 znuh_9u!Pu752$0Ecd$vB}h}`P_9JIJ$eB8&YLvHI4l}WOYZ< zwzb$qZdqeEsx{Yz>(qs{PNC43CS8*y^r)|2Uqc5q2i2~v?528jN;2Vn!@l`=w%=Kq zi$5XzK$3Czy48`i`2}~@E*eO4&IuTQJoQok(9}?=w=81P&QglEqfi=FIC3fZO(Gop zwX8g*mIyy)J}08wssW=xogw7Zdb}(wb#bv+8l_>D|6HbgLTh|XdrfIS%>5ig%~PL- zr>KN|L6k^`hGgPC10JKIWWC_z1(JEg#tca}gA_eRvPk$&j@>Pz#!_IFPAg+A145qF z)+{zIV_`MpfYW^3V%ogIeScvkb#&9`lDI~)L$PMtn{`QN&!wm=h@Ze#2h9jd$a-{d zr?y3;UT-qn+)Z3Vyd+LIUt87*lkVobVu@%;nDUeG$-TP#j|tngYJLJ;w=CJyd4%U{ zQsBi*7d?pL+GAV}5BhzI@L!GBlYoA-p_Abbbv)V0bFK7GWz{GI0B-R^*R%hgp8dIPvZd1tx_ zBzRkH&dpM~y$(FIwNFQI)h`!pZd62|0N^|vJvqC9wl{uswA`B-)C#5JXsT#$WRv(+Q^6`Ch6fLAY2qnjS6oV?gI;A-p`}?|p;UEXp=y`7bH5`w5HV-- zTE%8mthQ7+;CW1y-b#wKy99E%=^vcLi{;08u#$A5Sb(r))H*1v-bZu{xw< zzG~FT(>}=35adETh#idQu=4I}PJQR!&&uaNx7kLdM}AL|oC3p#3(#_p$H0u8%YHz+ z)nG~#%xEckB9^=UVq|bpkORSUAz`&IrlphRk!W6Dk^VS^H!gS5lmnPqFw<6!y{;c( zMC#n-HKI8jheNYTGmV|gRLYmXKGUy1Ob345C)?sWE)8!{$|UP1 zy*@5UwyH@X-^?>zwKgxFb1vPSt5zC)<~6_WSGs0C&u(zd^%xUIz!+te9NOZO`NCqA zVMSbK-6hQ0>U{Yo3eoOy?8pSdsZXs2V^Jk_n8Qr)SYQqp2t|7AaC2qF#de2ui& z+~gefAOYU+*}T*hh-74fbIscbb942RG0u&ufpV z^4e_)<)?$mWxiqecBEidLbdFfD%)9E!EvdTOhmOF1&{Cnod(7*g^x$Z$M2!0Bz57Al zRnjB*DhSlm;Ne@pqu&f-ujWry>$CK%S#Pl z#k(;Bk3i_U2G$M6UXFvVmcj6hx;q>*U%Hr$_JahPs-?PU!Ei(Yo>Eb(aPsBn?DJML(#KL^TkH9LLb(Hz`c!3QSmwkn{7)uK zFy~%$Qq!@}jr*7F&Nv=$?|Gx<({KyG1@IcUDq`Kx?fcg*&O_|W9^0iQs5ZR!pWsS> zC|4xU-gv)OWJ=&-@c`BOROmNQ_xV$uIF|CAIO!}%k#u48amP!Vq7}KrYW_;^Y~i@b z7@sH3JSNRK+M0jty{qW95B}u5$9C(C5O5S#|L!zzyRvP4h#Aa*o5#-IRMXxuoX0Qht1fgH;QT_aTeOJ>FS+D*_nB3_Ab#2_3}7DXzpXedbg*O z_ThIyJQrxWHRZN!bcDKi;yHn7s%LTf;{<=xS)^KBI&r1lz>G1Sb`Lpzn6(!MOS-xC z%n}K*Vv7prk)#x4^9coV9rv(C)b+ZQ&C*>UlG@7n8-{C`tRm))VZYYW48w^*4F<-| zBUy=xl$?mmOp*Dnx=t$A>*uH}$K8n$ZTrHVdnhe6@54p!sm=Ox&6mB!Ej`zB&s5zX zdk5NrT~-qb?*RG@B>tf1E7`j#fn&^~D4@Zz>0QjU;R33|S9>O8s_9I*qmIQ!A551v zynS-=M!`3Q_P9x1*Q{TTv|zzyYvTs~ypdeXYvboJb`ssB7t5Didt3)hW5XucB8b_2 zs$EDIDhSy6k2p%STO`j@`3!h*E7pd!!@7?MfM@;&wCYic%q7Ps7KvB-x!4fWAUy+Nu3C!@) zj;$rFA=5BGx9~eqd;6nbn3{SvmBI!c-N*>MK3#WLHL_(fWkdmoGPkrbtnoe;oK7ab z>tZ!hoNtvH5b(_iviyA9Bb4yH$hFIx1+8az_PI$lfi7*=92rV1eX_qZr`RI>LYAr{ zlQGh;hvLZH23P9uc0;vT>omgYzCq*2_|~jILq=YxQl($zJ5oFJy%1GcMJlA_P}TG>&~;^aet; zkF{1UM{q`+eYO-wPAY?;2mluE+=RoBK%?6kA|twJJMDlkwWM1H@Gx51s02XPi@Vl* zZXUys51>dWbG34V)cwT>ZcY;(mu$IQ1o#SrQw?ZEd^YR*g2RB43-}Rp8ehW+RO%z& z;piCn@#U1W4+cR(AA69?PSmHpPNj?|Vg6n1L$r=Gl zUOD^Il+DJ8dFQm9b-3$3WC{DW)=ZY51?bJiiMY##}a(e#ZJ_wy~2YIEsL zJ`Ecpq}E1X{b0L!&;66%z25s{li}G2!TedBiH~2*H#elg%P#TQOJ`5tnk(^DyOIWW zG48V!*N>k3RElvYm`t1l406P@DEE-<+IOHRH?$&dYlxagVK!{R;>*s!uy*K6pb4w| zJ;?>cy;=}|o(Wzv(Z-MydOh?txe10NXCCnO>*{BYf@r^QxHiR2oQ^48sVmFur=zLj9`H(jUS-Uq^8H)4^|1BB%I^iR9TN6vVH72yU{u|0&=?7TZ<+P zy)tgrX?xFkc>S(lQm03rX*-%yi8p)cNs3*{)fXKJ0(E3kE)MS*4s5aQC#>{bJBlYP zPQjLS$}j#DCHM%e^2gPOIDNmLCf8ZS3DRfaOg3w@&b!AD;Nj2W2&oNCeJmtf2vE^4 zF}b(ITeRr#a33}cx(QBUjX=Po74Q7oKmK{A`tS=&Cx?&!WA~;^Y1^ZN+-V%=9|z5({1A3_@)3`C*7WhHIg+&58+{%4syGQ-aSrz#jo-J99Hwv?RQyq)jdWdK${O#~} ze~qaW_Q3D?GK-H@nu28vJ6+M%95%<_7JAZV=UUo)zkmzL%%6Pe!BM!k!>d$C`T?km z#$`~Bc$H4tj8t#XDs{KTD}7f==5m+c8gv;Ix-@I_K(O8-yYBeSrZ?9HIp7_~vh2xx z8s#q_c^c{DKU=8ba_hkQv`ec8<*2W`a)e&_$MA#DPCahIT!+zy`m*h&Xr`fHhbyVt9k6kV3 zX>aMTSCpH{uGL&APCefSjt`@c;-a~vL&r0lyRzA~Uk#x3i|@*-tYxd;20ZNH=$K8d zQ?r{6QfK5Y-WpmC;b}%lCz48JWR=vx!&V zD5RdIK4+&63I0y?#4(pl3Iav(8?K|uL#X{ajo^mB1j_}>)Qh3ixCYAG^cDX)OzJD7 zCTJ@CGo)|x8ISddbY_dMb;+(WZP)&1lQmGF#B=0 z=>uPLoKjTc2O+OnnO@+ve8g8l-~5P$@EoB1e1xzthNgF>{I=V8{skt-)lfgcA5Eg$r>|K$%V zcf5ocR5jIr#@#pvyfFa!2)myydlP(~~%T z*SAPct7{K>0rkjKi|g<7Ir#$!S~|r8$y#{RM!6WYT4#W3{gBk}D>E7-?-2M)^gcoH zK6wS*4H1|+;>;rt|`(4GC=Fk=8^_dkzVhnbWFyIz9esYpHh zB9o7Otd2iHeuD1!z4-<1g!WNn&i;y6>hq|kq_cyP{N;jVwGpWGn77D(7J-%zuKR%6y){$pwiLun93Fyr3}UZ)$$-*=&z)7hwa^oI(0S&_$s7hg9y=3Q1|smM5M zZifFDITJBbGE}UnFJ{(I;`nIXd0Fu#=X<6sK8hYTLstD~YYYoFO-CgIDjMTP8E-l2 zPiYBq!%{aDw4bmN5CHeLTgaZm*c0^=>*_KO16IZHH7wG$mnZ99EwWo5%SbZ zqe&RqXDOZa@(c-Mdl+w{g8~2&_#J70AT;{BNEkLu20IxL(mmn|u@_nxohZ&O=nW57 zxHZuDH}W8NbFOj&j|d;V{Je1z6lf?2$<(1suxk*4P6kN_0=0eMi!BMPJsi@lj;0$N zxeY-r6PN<%If#^-Ol#;IA6IIrzQsm`47CUT)-<$ zSIzb-3(tHnKy|ZRJ@1v44CYTx2+&SQ&;ZInP$+cM9o=?~9GGP*53MT1={M6Y-TWm7 z${-cO1xq98Rx3O%!8(X`WWU!=vR%9t%>jGc@eg{M&=Pc@AH83dpNyNFpaH4@NNxI| z4Tui>GIZE=*1ey=pJ2bpIMl!>JqA)pZ06yxZ{ks7p90Tf6o<0=R|2E3O!`~@3en%h ze~DS3FZT_rUD-o?e9AshyQdrduO#38)A_U~|LJ_%+8Ev^A2 z;(>OB6?KYZh>!<04W}_SrViE)S^-kwKI!gv3##h}=Qimm1eJ$c8*oSmN(VvrC2K6y z2fq#3(W5O3Ypp&@OC7@sj#3={D-6bHEaJp} zrR`tNFxLLR9Ae|7e+MZjixmS>N0hbY8L*mx(bEv8Y>zV74uk6!782lW9N$(y=%;6>^l?2~b8 z^$Vrvt5k1TW6iOiT3*aSv7XXc$(dQnVJ_t_?Bd_W&LC0`-1*Y9eGOL$^{w&e zvUa$|Vr>hI;x6iD*dgI2N_aWO)Mdw)9DR3f{vY<)hXN^_XDcI-vwUAR<4TvbPmZNb z8Rf-f={!bR`(A&Q@+B*$jB1*3uE_F=AL+S77!JuJ@U|oHl7!$B_r)mn#pD@EK`n80zu}F12HSz z{s_amO)}w(k2`W&%VgSvR2Ld73W&t4YGD;p{f{>XE zbEq`d^HJy`VShV8t^yo$lmp`E-?BG;>9eaEs!R;==Ry4AI}u5?@dJj`i!nL#iQ`)#VTesR>!XxJRX9Lb_-i`!M?_J!8YxSoSa z6F04rqbYjK>cY68rqj43e*DCSM-crIWp=#w<$}-tkgjxHFj3zBcr&Zw_5pD8!6XIe zGC23vu!9Yvrn0HAIUK=st*J=ydWq6lkX2FYdHXOE+C+lAqV_=-Kz$V~e|z@vy7ldS z1<2_2jxNojCy+L`&gpqWm^+`}Gi?S8ZvM_B3>!6KMsMuk6js~fGxs~PeXL!(-_Er~ zeYvA8(NrK*8J+jO_7+`?03Xjcn2=CqUSK;x7}|KqM+@N~(~`!BCSXB8xWA&ODAA5! z9V~*L4BdCcyn_h60U2a)cQ^%^_yY<744>vdVjlid0sdkhm^oNk|4m0>`J^)ZPyEBb zK?MJxLHvV`BJn@zDD=YiHYWc&L<;vOB88KgiHMVlnTVN%h2{SnB87#8`G1E<`HMdI zZ!`p!&sX|i`|>Xu0xK)~e;^_JSN4JKtc+bC6EfsBf9VyAuBbsTX*{x$a6o?nQt};0 zKY^0WfJO*it2aNvst>WVD*olbOU!@3=>Caj>PA2Iv{2Yw;HX*0TPN zYM?fJHjMDn8p9mCNa1T8RlwE8xom^(@&VNlE*}J-BH19ht6@T7q21+`=>QILnl&<@ z-sGzX=V2Qw;|56nKJjYs$=DxnL`$m-j zv3)R0f$lp})JtZ2z@t=R4fJ@CMSEbQT`h`5*b`3ftHV)8pAlElmKH+M!6A8rw4UC@FPq z#3^VDs9|DUQMLi-!d$3lc;H{_zi~9j6NC5QfJxzH1tA-xZkynPfs)HD0!%SwZUt0X zjn6h*ySLt7%NBGc)Ya8h1s`wcby$C%4@kxiqD0O|tJ`C>SuO;C3m+Xq8;%X0Rx9G! zb9xf{!V_Pg%ZM{{zToWRR-nuRp^5XaXDga@Zsafd+Tn=jV1l~1y;cTrIlPbC&sl^$ zU-M&A#`1Q0+UQA=E`uER>MLs60Ai1+8Tr63M@}oMnVfb{ll*{W=<`?b3qhaz5L|JB z!_OWQ4=bdkufI9M`(oURcZDTcw zsin0Raem7)nXa#dNq2U3a-rGiHZr?u>V3#?DR}91_t7BuZpW3HPn0F_pa;HH5A@`U z?R$P-pS_`emqyF^UEmefB)sceo&zm5<88qik%!uf(iv7Vqjaa+2y$QOIYV-XbT+x3OxNVncvSLHiBWsuW89-yE~U*#43P_d6Y(F7=-v>T z)96Q~`^rqS(CyItA@-l$EXX+Gyls3fgSk8D=atl-*F&R3Ve!4hs+*9^sDYZ3dz%wPI2+QwW1rho-mE z(Qa92d%2V>V6~?Aq|3l&%j;9 zByvI7ZX?*>dm>BF7hRGH!PF1$#Bf z&_r#I_!=>6#h3C=XIi!^y220(y8-gp2#sFRp5fi`f{I;GiyR>sz00X0r|8p{)A=yN&J5U4N85@gBrXcym2k zxh9mVM3(TVIrs<=`=^J6BvMt{0}+A`Qf9rsxr9<@oMuZ3GqJ|ps^^kjU6eTB1& z@(l3I)bF+5&PC=N_qMx8-QT>i4O4&t9YmL~Op>Sq5ljVP(}mf^^*4s!2(tUeJG}wI z_?X9k1e5l7d!%}Ydx!V}-38K1jCH$z^-uwNVmIX4cTMyT>I2#xq(9I-M3@*@7D(FD z$V6U}cx=dEY8SezcR+Ouc`I;g%vrC!@`H=O5UJk&N7!yF%NF>Mkq_lZ+7&A^F#`r; zf{%}tS+mbihX+?Di%HfNyNj+i%!79oJ>5;P-Pi)iB>#B3Kt%y)rY9=AEhtI8Fg?iC zH*KE9Zez$>kZT4!NDIk6by|R!8w{TJ6CamaPaR}Uu<@5JXnn{bHNKecAp3z90OM}W z%r0o!TH%xD6A_;LuKD&8f)8eQ=ttDYBnUDqosd&fIICs}^e4jD3)^X0cczA2%J?{L>}*W^3Ykb@<|n2SS2n@pQ_+fu8(qrGG9 z&9|=1?kL|NU&Sj}cf(|d;48nYqN^?kX|#TyYm#eh!2hrqc<%p1P7=;S@kCPVo#Te zwkGugE`&{i;J2O!WVe))G3qRY+u)DrK(>bTa}#iBb>E4VHtpv}6eFL=)%473w%f0s zw4T<_-1C!y0bBcohCbnF7g)w(mtbo4#uuTXK0P|0)YmUMIUi`ch^O!T?~odo&*Ryx zIo{cx;BWB=tRFG*+pm6Bcs(+0&`!k4{qi6K*#n?#ff6&&?>I`=4Nv%d!Aw3`-tdgZ zcUMgm*k9}*7C$dH0hToBambAOnFnKD(^1E19wd4doBfy-!u9iUnIoM*c$>Pp@3{Z? zd8TtsH`?VsLvle}rWYuFH@;$+U{lYWZrxBW zf1VZ#>>Vw!n!u9W(Q=+%){TmKe?Yxpn@vwJ9X$ITzO5VObv!25_+jfI@>yYD^{db9 z@4qTidi}l~Ye{h}xZk%=VH8R&K;Iwof7esr4Aq+E@QKr$C~f7NURJ+0+?MzVa0T1Y zi`sLA`mY(sM zk$yp1=H6L`ju!t{A#s&+Xz~cDuAwPx4Zl6ejs68J5=|m2+m^&FDx1BZ7Zo@X)kgJL zdy*7irL+8lj_XRy1%Tnmu10PR`=Uc*El%9CiD&qPi1Qde_H1DCIn4~q-1SutQVJ4l zzKC*tf&UoMH6G=A{mcbICdKO%?$O0fh;SgnvDA7Sv)2q6EMpd1u?6b9ls+r9vs}a- z`{=$2hec^UCx7Q|H8r*_Th^>Sto^D(&mXeihH)KzSpknfWqIuRE?z33v$c7knzP-P z#x9P2>Q=|?#Ne+bjqo2LWVv&SRtE-750RmsV{guUVeEoSsdWtoyEL*R#7paM>4QUO zL1HN#J!3$0P0Rq$CP~S<89vqHtHbw&UZ16j9@@j4-@46<>>CfyRh?;YBO}VDbO3q~ zbh|n^8h3&8@@=XE-VfO!cX}&2nv0#{%It-lS?O(0cl;TJ6H?WWz`4_ik+5DF5Il6mESY4~?YWu}C7pV)f?jTM_99$nJlSwXP! zJf!D=j?RZlRN!)w8eJDWc;xyPlExH5cW)zf-p0xy0h_|L#M9zhr>g?&G)%(Z3&t-(erMop$(gl9sLesgfyIPrJ_xW9?6{5P` zp8$DBMU66YLy*qfCB!(`r^7;iz{OZ7=9lswEXu0vFO{j-m3vr>GQYu|I=7vtikR{! zy?-eB_kNU6D}sTPuxlKrCxv3FR|l>t9Qi&^t2~3dM^NPQt7Xrbc%GcybTo{be=ZLN z27@{=m$>e);`26|Z=8Y}H5p6DYaFjIcNq7nxC0AnNkU!mjG&hN@kuxOnx+I*eauQ- z>kq5HrS+S(;tOSdgL@3Q&OSU>z0RgxM8pI0R&R6K|04j&n%xHn^(el*`G*+dh_%X8 zlD>=#A{fo@5LE5Vm~3^^Q&P+#YjfWMg5Ex>H*8U=i6@Ffjyaj9O>IaBU%gD(691Vr zso?Xe(wT%%tZV%pJRx6&Jg%>y*?ds$&rPoy#$&$`V5A>D66a(Z`E=xpX~ml$8tkQj zzq_|i5n)iqL+Xb!idH89e+=0SwcU)wbgdw&1ax@8GDNqtzwzr6~v<`Hk(kV?5z|^l>TUD zK2*KO;10r1sD9#yLX=G|eQbjzb1!d;E4Ad2QNC9r&-!p3EA2rge!xB?UTVl0nf46> zKCTO10mB5T@V-7@20Fa)U%x&K;Q{-0f-HM);*3f9_3gIyIzrY8@R6>q@vXDo@*{)C z#C)>3sH*1lEbtm{t$qaj$#R*Tj1P*UrNZ zHoL6UyAwBtlDce(j>PVeV{;#GJj-c)g(A6c#oI^-#aCE)&E+%gbYh8C)Q-ygk4U3p zQuP=EWjC2nRbsZi(pFpR)-*dR`}PjVNQS>b0{D-p>)hxt7=z9N3|hFEA=4qPb|BtE zBMXD~boh9N2239lp1;2Z+hQvOFW7i;)*PmaA>bqj!#8N@zWFu$^md^Q*xEA+3@NHJh*g-Z~epW5+`HSs7$%GW`aK{h7Mn`IpgK{T# z3nvTfFoXqL$Vx1X+PnlcFrlltqA&S!wP3Iv@zMzR*a=(M89kcy(h@Z4&sSq*Nm)Gb zW4SkK{u6l`CYl=P1?sa?@0*)eW<3GY;w~-CuoWtxXj}YXB6_fFv?`QZBXCztz+KE40h;hQ}%R_u?>xY`sRUMpT?i>iQsIXAH@L3~7i5>CVk<4pAMe z@*@(LoRkM(5ZvSMt6J-RcEIW`4`4_4wfD74_6P_60lp_BF)jv0<(z6Fs%L)BcRZ{2 zzzj{56K>LXz04b z5lkgF^n3zF-1COd4VB4z!JwoQ)XiMzTO+C9d2gnSfm2za571A@g{v; zX?vp4h@b$Uz%(?~G|2I)J{>x>y1;FTLyUzT#|AJ=mj*e)0|?B^YXDjbuTq(|9cYKY zFLn8U&Z)&Y3s?`YAE?d6Y3Z1r>aOc-EvB;52w~{4=_$LX_5^RUS-!)7M{$x! z4nT4&!-L9IWvN$|F030y@5eIbkJbol*j*H=5Eu{X;(PE82#NV3lu!{;;t*&8C}@r# zpz{9GqqeOdneu)v0hz^)y@}#aie#QF92)%UxNB07>dnp+Ce5{}CC)v?R#c%smKuGm z0;ke;5W`rs1E80F@*PRtF@zGk`EQ%4Nrj`#cM^JY5tx95YSpfLfZ{VB|{q2?B_EO?iP-wA&>}OIaslkfODy@e|UOW*2~JI8(Vz=s#Qf2b+#? zEfi=Gajxh@q=OJTC;|^VLNs(2I^01a>?xd>Zy9G16D$`5pDzQv%&0x4jQJ%VblllC zrAGF->_Xj3=8~Tab??^ZlbSPQZki-QgCkCA0VZ2u=?rfv2@%-!;XYMGP_h;1>RD?J zd#XAH(;H**4aG5l=1SOH_mKF2mPj|ifSY5Lla-BwhnL6M%h?8USeQmNBONs>wc)~)8RJgW5UyQ9%XJlH?lTBn03SFSwrxK+YC=_ z>I7E>?bWWtZ~h+uNkF#01ki?JKa53_>z7qX8!;kVP`8*=&X!(s`q1 z5s9+Mh~zeTTBNWD?Lc@335Db!^V6pZEOL!H9bLx5AXk)TD0Kp{jY8!+Kv<7V6Tqyj ztKUA3_Q1zu75XjN${;Fll*mie2%&wlsECsyC=tWrxVT5;#Cy1Jar-!yyh?Z!*HuHq*fc16eEz{fbN#@^Kt7TXmmPTN9+chtUub57V zKo>;AZ49e<25?9yA5)jMXnL4Mn&rWj%u3Bg%owxXy50St^*fsHXs4tDnn~?Xm_ISc z^e448>8Oa2cvNIYNlMmk0v2r&q~4o38_GHCWT;k4(X!sY)xO*QE=cQk@2@!wU+^fP z8E7%=fJ+rc04qgA}j)Qi_tN{?S@cW8v;S8b6~%KFvE-v zQn`?-3q)5qghY6oE?m^B4MhW-62Z>|Q5Q%;WY>M(O37QfS1oBPC2ggop(WL*DcWUs z(XE(^1~?JHZIn9W542;e;B*is?cl z%d^|?Q^nm7yK&W9y&c0ob+}p=B+eqr81Ots{9ERovcI>BghY#6Ds96R-KeKIc(e%_U89{UYFg1_ zeJ1Ol^fP{hF~{){TfY^LTQRcA)=BHM^{DljRj>~Gp4eVh0tgV3W9a12j64JI4C2o0 zagcbR1h8_kK( zd~-ZJ-jx}s|4QrU8=Oh*g}Gn%%>Hn2ell^<#^&`Knae_s%NNHkCSJ-ALLBkjH{klc ziR=^(sIAdWEgDXvoy@Wiq@GE=oO+A>b?O7|1MR2Wr&`SjKO$@uw@TyuxUfsyB}t-I zQ_qMYy?zSERD@y=S3_|;R9+083h8~Mk+K_ zxmsPoPvP26EB#ar@jsH;u(3j7QmKx2%DhC|#5&(l--?*AbG? z>>y2=YpPf?VW4)hR?W0+b5W$J*pMN#PSL<4nPwE=lIdJtuWF!Ez3TUqtC`RmkVn-3 zX{6V4TV2>iYhAR3OO6J_jx-12X=#Q?1Qn!B9m&oTE#>vp5g;-Zi$iL98gTir{Cb7b zsGD`Z9nFkoMGLkl!Qy!7am_(3Ye`vdMmIO#fp+S476iY=(Jq(9OPt2Hj9(^nE8TU= z+SSr_zoa#a3aVRzd$mh+OA5WMo$X7`S);pDce~~`?QJ^KO2@4ZCRkcuVuqz=l<&wS z8}iR$FQ~x!^7KJXQJ>Hi^;Cy%FUb0SJ)^>9SkEdnxlYgNJ6wc8CUwQ-uJx|XE;j4h z>SA1949b|yIM<FI5BHY_U;PjYUQ52jPL0 zMSMcU{eT;t^E*FBp&dYcEY&x2awY`;Ol%Gvv#AQfA&?KW=H%@0A$evLI4rQ-l(|?j zr(`RHejlp`iFa_Of(=EgaV;v$^M!e9YgWAK{iT2;iG=Aqrc4ef=$5k6nC z0H4Q06%M|?5oQVyc?_g#k{mP^TNNV_u;1UOC&@8Yrz^S?9SCc>qK`nkM_1Isr)*7- zR12vVQVpb<;#u69Fl?A-R1honl~y35g?vROch+n!%}vgV0_mto>2^{dH^Va(!JS$3 z%S04{_LYQD@xHAlZ{2Qle=#`cb?Cpb(&_nZFYa@ ziq6Ka8&*fQUs6|>ZqGF38&*#wg9}r)&%JuN?sfb!p|%@8tw-cPFZHRGY42=IgmK<0M` z$TNEWIra}of>T6R_7AGgYEw0&b2}my^LJ8Bz?rJHLTYw6EFYF%2Ar)42XQO4585cx zx58rxiI}oUB8wjr9SnEUj4r{gTUMyd49k&1=y=?Ukw^MsRi62ZIF zlP9U#UYwW5&V7bWE&WccI{Tf&&JpK?a}P8W^~r#^v<@c&LOg7X8x~jtwk{ZwC}%XY^I7?t_m? zzYk?*dP?P~& z;cWCf+4M5~9y0wH9OWA%%W(m{zM@2dAfA%R=aG-3=u3#!)NL8JsNRPSVBMek()d29FT155S>xh3bX z9e9pVb;Q9LEKh^xKG-?%BjjP55Ql7R(+Bbg-ZP2-+=JB#0kLbreoNFCW1_y8HY&u- zCYyr%*rQ;F268SDa#n+^U@r?7yUwW~HxHMJvVZ!IQZ;~{QB3p`)qE{;t#G6EM&pf^ z8ywfVuJuVngF`^?h(p6Cn~PpJK}$QaS4XMTATj+qiK&3v3V;%|IO_<_u@Gw&h#3qz z`o$}+du{7$H(q-4&sP+#T)6v78@_n?BKFBgwmtcUQ{#{B{HHJe$Mp+JkKFXi-0$}M z^U1r02^|?P|DNOdD^VM>2DII;rLhT{7#)jDTmmKe)N;I>(U{w(aF2SlrLD!odb#zk z_3rha^5^8RHyPF`?emhk_A$$tb$jFi@qz4v zxyQ59i5Kc$PEEI;Xt$j!wRzEI#``3G4KsKv#`qb~MtfC5;h{#K*S|UF4FsO?lbXEy zAzM9QxZY^f>y0UWy^)J)Xd;C1EJ8w}kqswo8s-OB4blQCHzwS=3IlogsWz#I_| zDwhfr6JshG1CFd}HtH@hTq;| zrY@)v!{+7yL!lRBU5Pl&J`uGH{osPj?o7@9;Kw_A|N5hO`QSf$+gKPsw#Vnb^VMa?{^_bMZB9=}%U?N{+P3Gyt>?{e?!EbvtM5JUp?5WWDUij#yyq*! zx2j+Qu!nmW?%&sj36bE9cpp| z!QIbYI;s&xUDRT0Z0~JZcpdFPa>Bg9?_ka{Q|SFMB@C?49-WATx8mYU)Zng66LA2KJYo6r(@ z2DrQvc)ZaS6?BTxCHT`uok%Pk2Z#$r(xU7;sUrY;ApDg;`>A>eQ#1i_{qPxVe~nl% z{jic&r|4Y9NYn|vR=U(-iE@5zh4WKcK=DvHz{62}1Z7I{5t+I}O7B$cdIcOOt`H?Y93P2K#<{$% zHQ25!4lY)Bk7QjQaK*!+<$-8CEXDBxF(7p*x~N~8!riJ>i=t7to6g6m)oFFQkV0f| zBbvlOJ4f(tpfDUJzK*EH?TJ_{{niO9GY*$YD@)XWrD^~G^*{FF)|zzQ(I;0t9Uxi& z!VzD`OvT?JQ@7M5g&Hdb611XFzGG^&u-Ri)i4_9z$L&)DM*?rD3US$Cw0CB ziL^{0j%1!D_kzLtyMm2qj6V-aWz8(jY09keB zWWaexI38CP!~*dyq}A1%ZHkOJ*En$}%X+LIWLYG-oZ9sQRs}3%g7vr_nIpkqP{HHM zgu)<2Rwk8c<*33d!^!V{?rbO;J3d-*B+D~nGec&|DvCvPhC_J_s5JnJy`|=Rpmgdp z{}<6~&J3B=GEqUSG zl4WskBocARVRJv;xaS-nad-i|Y$_jTegp8*$mOf>5^tvP(yS7hmBBO;0@FwcZ1Q@f zxSrIaq>$*Tx=}?~U+U5!Bb@aPvs>c(Y(? zp2CW{4anHf3_mj-jvS8uHvC59t@L}`d*SyYAEb4bQhF%;xrUq5yYMb%7dvhr_l$eT zecKy$Weh~h)3O@9;M1mGse3gn`B;a|;&b@jNpJc=&4b#9m9K`s8qrx&hD3U4dU^Bu z<{OeXr0+039)7ahn~w!VUKFAcLm(fc>|uH2g@D> zR*u|sKj$Df>nxT?*r4M~ahmV}{1cSP);A(V7S;1bAg#nkVsm84!ZJU%U~G9U^ls>{ zAvQF{+Em?$2@jh_OcN&7G=*DKciiL71c520cgHcI=HqcziRa=>{28pECamo3tsw`I zr6`G=B{qd}2oDVwvmmqVD`Ut~7JiaYT^fyX{0t!rHEs@MD+W4)T z9}H+cc+p*VpI7X@aH74qX7mB4$>;(awj3e$M*c38Vx*eirGa`}8fa8i1GT++fgu|O zEE$tkVa;MBu`(9I*o+oxoURzE1tNKAfSYNhNHpL?$doh@_lrf4fThe#%hRPJr_C%N zXiXCv3pvh;LOFy?^ch5)6aJ(Sfu5vfRlcGzCsm6J8OECx9o|Umq5L-GXhvg0y2{a} zDbJ)Ft)|e=->E!ke=x>&v0eJb?%Ubh_54E|&NgffO$Za>ZfUpX5&04GWP?T)WRY24 zzdptIB;)>obWa`L9}uTlNezYry93Vyxio`7h*SMCmUHzCFt?b3#DSZA@yBcuA z6!Y;uT%Vf4vT8^qvBhMT?=hKhgg6PG8XnHmw7tEOmP(a0(nwR);S1#_jF`9<*BeKS z)5fDl!RSsu!wMiERLDTZ8Q2Fnev(R89gw^?biBeZJ33~^I!d$V;!w60R}zie9I>c9 z7Ih?iC~k}RsFjCkLSv(#3yo5^w4Jv26N68<&`gx`=B6r)R^0lasoR_F_`AO7{1vnB zBohnW`}PeS7`=R;Js)s3FAWA`8P)d@yKMHm<8|psB++#dvvzUE_8(u{)zB6wgs!$) z8!vrh;bOu^4dvtfe$Wll?2!r#9*l(oW+Rg(VNXV+iMb>$5e*6^0STy7DrK|IA_;G; z1-RCrCRTo5?V@pYKFY4>)kTfWQswH3X*iUioo`9wG`cp5qq=JoIH9Zbo=!J}LYaoD zC5XJMG*lu%g!E+ob>YMlv5nKS+EM{Uo6$4fa_J zwB0M-U_O+|BsaDA-R___EN3`v%pXg~im_GB$DEJ39*ar3XltZ3z8o#Xec}>nQKTo{ zm*`90DUQqI<}XL@Ox&3q&pagGOBzR?l@CP^C7#c`5`87{R`jjJ(M%BWoG92irzR@K zH3=bEaCXX_=6?QsakcCG7M8-Rwk`i%H%WhUxS2UtXqNEWc$%HML zOaNd+<2gxVlO&DH?sD5B+Ne#djfNwUoXc%XYmy%iX{A9)s#?SumD#&<&^1SE%58T`=`;x z$?z1GRJ-cU_Pg;OH-66jn)_Wh>%J$FbpbVbS}{ej3?H3fnNFXNK8s}(Lv~Pj^r|+y zK8DrUc#Mfn;g9xfZjNWAXRsGu=T)@|Xa(bm6U37TS|mK0*dr1>$=jdAw9k0!Zt3aXT~j)Di2h^ZP#(njC_)uGu41F5Al(f zI*8k}=F+I-XDU4Wbd<3%<4Q@jaAWDrn$-Usczotnl%)=-gDChUrmPZDmI9p%`0Rn0 zY}1zr(|Zb1D{)Wwi_S9=8%a*=a~7i{vD4CC`{|%$8Yxf>L$+GuGzd9Ff|O~QT1aRa zhglM}9E%(9I4E?7U&_1Ujt;zkalj@WeZdwl;?SDp+%J-Uocl*~?rnct2YYXn3;2TR z*}vg`+ScJTve78(l*2ar?BDUHEs8b3M5BhwPkqQNnSGjNmNXNk4t14}b3A`H3Ze$4 zx60>YS<2^DDVi7CZR_!9~YV8BuWA%Ho;&k>ziUI6ZLL|;dS3ah8cPPu13zbW>S1C6t zyU}jt`{E(-<$7IAvc~lbECFkm-5+->@CE!`L3m8ZrR{XpfIn@5@J-r^?X`E6={~5BicxO9I(kz!Ym1hM}>+p%mU1@a5}#7 z410x{m

    Br=-_FSQXxbxyUjB(*$^qt8eLn{b+b0|N^1^XnfHP~Hq1rd!MUd>u% zW=cNoU?b<)Fn3_)$<=Kq&j-YvfQ~Y%|9qCx(pZM>2MgL_A++URp@3EJ0?a&-voCQB z43`7BMKiu{(@B93dXsz%T%d>{Wz1_Z9lA6SU2$G%@*y?4ZRvmfh!}svN_k>sxUx3b z+!}5V`o@Jah}E1V|?wJ+XKp#JHgrXEZDR^gu=xFVU5eubp%oK8_sZ%=1Ed>7U(PE4n?sjpGh%3w&OvYS{>i( z=vPxZAaQMJm(!x6O~|{2Y!TFAk@t7Lajj|#gGwyhl%IeugVkbhg^x`+r&VzZX6&I; z8X8w@8BRQm(PpG&Vtkfdt3Wm0+g~%9WXIB>Q6r{OJJ)R{w zShuL8952z5!Oo1&Q0*#gRp+wf!2lBLhgpYihV2F}B&Ikz!z+oB@(o8f7{*_No>j#2 zL=40ud^jrHf48s|PY$bNZ`q-jx`5rBoU1C0YGm8z!p>T!fm?Fnf0%U3iM%0mMURL! zT3dywQW9J@z!W`QK*d|d#D_!&#Rg0VP?>q-LIgYqNe3Fv9k_V>aS4JAoW9#gnA)+q zAxGk#eG^>c9t;T%fWAVtd4YqrOuZ(H!l??y*1g4?Xz##Kj4J}4Xo<#cxS~^$-RkY7 zLUzAm-z#cJazS(#3<< zb1&|4*5Kg>69y2S9ei@A6LR;3v@w*?&2`tphHLLtzE;z)PHkqajj;rQJM6~`!W+g7 zV!{VzJ>Wf9_CfZRF)wlG@ex{`m9z`=3$Fi*p9L$;BE=hG8-O}v0{6JpZk9{$ImU+J zzN^R+ST8mEIe}9iop>FN0m3nUn-*NzD#D|1C+-Sf9W3e97M`QD>@%5rjHWcC?gLi;%z%p4gFcL5M5M5yLq2L*mig+^BeLc(zfmLL&HE&wz*+)oC1k);Pjx(Pe74 z7!F7O4uWy5BiZOHfO7Qj{ai|}gu-WBBu&~m$gVk*X7(qU3b=!8E`asvw zgy)25fa%a(jkm7rJ|Q?I*nDPRmMTZpQqtX13N=(thm{It@#-+ts?(N%d&e>62O=US zYSt>km7GOZ!i<`7gmt4CuPf8k=Fet=8%q^UO$A$~QiMRi>h^hp)OUfK4NC_Kkri&U7#Fp$HPx#pq+5$& z15XDVO+VE91>yiG1rd@)J!pd#Xa6IxMN@L}hfS$2lsMzIR(I2nq93RP8_?1CRf2&N z-R=O0gn`dXGk@}YCVc3-`MZ&~p}R#QYcThB@O0+^q5uW~NHMvYc&ekMqXaqi*TE`PTvY$=?tYxv1C~3 z4e62U7wMlbhs2ReW$6r=NiC*V>dvUB61VTaPdSWGz|0u6CXO!EAg}dZ8rF^qlw+9l zZ=Pd2MY3aj6_iMk-mx(k)i1eDvtOp$;X11gR>&{zEy^!4DoGSM);TV77;Ttyn>3U* zMpvw$HjEmpDgBztEe88D1b~PEv`NVpH7yB%hj!7C+kxStQlHnI;d=+TWDC13T&~@1 z1?@uXv-VB4ZjNjM&`RIP`eT9aLdzGM88TT?dME4|J5=;Syqjn2ao>CuFOB*1`z605 zG5Egz_+ak)lba;ncV`;&X4($|cAeWK4bfJl^Aun04(XKMmTtj#UA>+Auf0jR{d%IY zZ9J@8&J%E`Wm1oz$XpF zS!}tvXl<{%2$&Vx!)Eo0qo<_%$TOuL@9Xi8y#yDu$eENAUgeY|7<0TLt{Ocm%PB|Ut`4h#@UB%8LMci8oYWCTnV7OgI zz%H`u#^SM}_sJrUts<^tdE9Gq(c`;($k5T_bBY62#ZlU__@T0R(bD+0(s=XIct^$2 zQwrSCDC%s&1bfLU6c?#_75UFX@~+uI>lTCdQ(EQD%wK!f{AC!LVE4)`QkW6uzkf6+$?B@vAGuk>s)(WH@2;mVlOgRqneP9Ah&VfV85!PDAXXSs#QzhR@ve z5Qfduxx~MO5D|N)zh;oM`CMMzcaL;Wti>Dd6{p3y7opFFBF^uz@}(zr{( zrrBSsaj*Tj^1(dtwONM!-1tCRw58{LJ@}>9!ERl-ZvcnAHh72kE{MDkj_UH&rt5Jj z{oI0Z_YeXfRKSS~hA{amm(si-W9QSfwky?7fCokkArRrYFBXshKI`%h`B)ITWV8h_ zh4$f&>9t}Nxx~T*)+YYc&m?$EiK?IY2O};3IntTW0xjTEhL19zhTsp&ZA4+t{cQb$ zDqh!P?D62?m6pS6?E!Lq=vLOvKMi@I_~Kj?;uOPp5-NfmFMr7wcc74lv!RT_ou0Rr zBNY!0X3tN(^p^C5vi(|R5@$UO}!8yZAXpBIEc*g0FUnOg|@ zh(io`>ghLosQZ)snsO?>86@5N44Vn`#|r9P$-1Od6=y(hxc^da?vI`0vUf%6#1s-r z*+{pW;}4(}$SfDVvQ?~R>5X-KHxJA0qSHW7`oKtk^ck=;|J~4jH%An=AHs~v*K2-fN*dOOD~GETmMDz^?wuR@ z&MWp8>`rYPW=R*Z8OtFG!`?lF!(A%!CJ#1x3G9g=S24lQSZFsH1R<+G*G(eOs$om? z1<#&ib%ydU)?aT5dLnJzj}|tN;=OW%oWi9aVj`(mpU<$`%2|2NXK=SmM#{IV@P>U7ZlrIk6-$9kCuzt1mcV)UWhk zd<&Vfa$@xMd_|tmlSr72y{P!7@E~^k%Jw3Ck5ABx)XTov?D~q1i)sgMQtpkQ6CbAK z&djkBK6SY9L@35tVof+k#64i6S?2tZG=8rMVIv2?-NukYl0FmajBOD%lSRn9GU&Vn zG_0DolV7^^u^ZIkojSzO*?3~;2f#*&{D|-nK#BaiO1%>#rDX`@vJ<~adKc)Di(#Q2 zTA|Fkaz0aYJ~5T0<=ptUWGwF&rAvha%|_tTw!ii$H(%k{X3j%11UD{ht>28S-Lb1( z-!C(Oe(ve3Gg<1HkSd$(mAl;~b^=Jb@9lAR+%(=0Sf{bn#%bE$&Eh2Ox0-V-eQ%o~og3j=kWV!j4H?dJHoL)0HTWH^_Sjd1 zR3fx+EH=$XPF_NAnjS=zAu>_2+uPJz(3G8e{qW=2DJCbRi*@3m|$ z+JbZ|GE*~CYXk_UmTADZ{Z<_LJw`c}nGKgl0oU)ksT50YD&4r_jaemWgiJfnmce4C zsYk}pvLa7qF|?75fsCxy8b=i22}X-?3l{Mdx;RX!Yv#*7RO+85k^s6EA7$Ln>@1moZZl%QLGvwpotp0 zV@`ZuyS_=!U|?8OI4SDcII48cxl>N62+LJG>&ux)$NN+-Q=($me$CPhR(D)iLtf~N zu<$b|G_8T83~ zS$U#;)qP)OgC_U|YDMzWw=&(u-zolirSq!ZUMK#EWpgXqnbVapzE7KS-(*MgfmKq_ zHQ)*J{yS&K;@Fzle#&*{{I83Z+M=Xv+Uc3*v%c;WkDy;EF-p`8eS_2O z>FF)C2t)Dq4FtD#l-*&!%6o2c}|Gj|z zCj5hH@tw)`-N84p0`hPCZ~8wP{|^6BEdDmc{!PL7oAx*UH~f1tf2kIK%m2;)N4@V8 z_(#aX{!eKae{T%`jdt<(+WK#o+h2&wf2UpiMb`X(X%`G^Y)tl;-} zFRL$hx!4~a4IgpagHKO!0yfaU4b~Nm7d|MI^Dutyu%?6b1z^nZ^f}4ib=S)IbZIch z4qCPi91I`)vq1rdz-KAxM?MEjLP6izR5QX?aBEME0+*}h+4$5C0VE!(%w*iQo4HVJ z01e|AFS~Tpyv|D_UY|DDgP=j#oB0dT4l+@hO|Hyw+!Y`&ZZbUn7AoCArtbx^R_X0+ zaWhdl6g?c&ZJq!I=6d2bmzOv8_^qKLQbVI!W;%K}na?&u=>pZ##?Pj^AC%@YR^359 znmkUx{rY2H+O$1T*VySnxGrG1)8KHvfYdhNxBwU&wl6=Qk!kvtt})u7rn)JwiQ7S~ z0fL_Je+=yqyW+3}*IjO0!fo3+qkskUt@34U52l=>dU%C#5KS) zcu66@;%F;g9r@0p$@$qS+}g{|n3Kanmmh5EjvKax-(C@A>Gq|I)3ifQ(l#{N;$UF>`!GiR?E1 z>y|w*M`L}KNdB_5Dyc6!)z~#cZCf{sSlH859TMdyrLJpD>o4sBe*|TUd#tt$MM%s2 z%~#!oS*sR_v}_6-9P+?w7ZfEU$=`SNoLdDWuBk~E4z(!hV9_BTs*t!ki9S4K7m}RD z=08V7jqjSFkLUdspxGP5Su7(6=Tu2sBo{iXrFV%-4{LI8*=grIq_q~Qf^x%{Ut9|w z7!~w46qSHbi#zlFv@?&a_U9C2s8pJxSP*`dH&}FhFZ7R{WUn5+0aG)Nio)and32g} z&Oy}Ei9&T1YZ#2Ch|+nmYl9;tY}y+I6F7yIISy`^g8yZ+%QVPZ-5Q{Ah8(G;frkCQ zs7|@v2GLvW4lYeX%Y+Qh)HvayX>Qes%yoHY$(ZW{51Cuwq<)G~b@Zuj$=E;Jq$IAW zo=;ICpKYHuveIJvG_8&2)LN;h!QdUHVVly+AFdu@?Mj4Jg?MR3)hy1<#2hy3G;%Qd z?wP9LI>W;~sHU!;^vU(!pW4W)qo2}|Q~BKG;;L&uvDG^_)lYH5)t4HE_Ua0^&eLfC zx9JKC(Mvmh@=JYFh6^xc<4yn?zz|?j-ORSf8OgN{`N)NwHklr_*-ow>M7fVO4iz7YhI2J|g3a7u|KXMs-R%em7*uu4`k9V~ zZf)H%1}q|mP4$2@yNXZSaJ`>!G2uXJMd4CR6OLqdxW~Cpd60%(HMseRGK(K2_usz1aq>ebC^KidV75I!Ve$>Oa=E|1hsoptHO>J z|4Hc8s)%Zf7ag)?gmWRiH1DNcQJ3O8BJ^{OdknE-c@BxW4mCnuBQUvfaA{J}aiQP= zdkE@|Vih4U6^^_#P)jQ_QxjE%D{*2I0}jK%IW;qls2Q3S_>mf+Ua1jOF?}}@mdLTXFAc7op+VA= zOv6FnFkQx{eFjmC)$nwLDHGHBr?iv&z(m9}9LT=`T&h3I0-Y~3Ku)8KEWE0)a%VZ zWYFw%Hk)u9tFt-+poKRhfPTc2$Y1?1^-SA?-~L|U0^oR|q6a@Fb%j2H3E9B-g{_tc zCGME^e&?Xw5z~Ry)jjxu5(U?xJ5EO~|B)_2s)2d0HebQkMHeU>XjmRQz^Ja1TuGTF zyBd&$X#YLV{DYj+8=X^9&kN~+V7~uim1&=LNJYxIJ2JC}s&GS^@zA=5*-SOjGAm-B zjuV!7%#%(N|4!Q84)fJzC0h-DU$TDM&=cbN*ZXh@1OXdD2iR9l&;nUi3D`53MZ4P1 zf2t4u9j3;@!0;a$nBjlRQWNVAXeIAzsb_8REq5(+jqUN7{$x-nW>RcQCY6!e{%= z_$#YLOf3u<@EPd;Z*UsJ{|!!KU|{&4RB!ZzSsxv||CL)PcVQ6zS_`J2IK=Zb-tHrS zf$0VKX@bfaSeNHw=$Zf=Vz?2HsnY9uXZ?QM=e7=~B%O<0xV_3U;XRw1oFT~MT76mj z+K5m=z{Mi_&ipL=soF`Ii~TMN>=e!YbN)Rir-3vPcIRZqu~o1)p>v_x&Z zfnt17`i3;C8P-`5%yNKg>i&v-z=f^!^BXsPf5hk+tXHU|>pvU)ZyogSTIGME4*%I` z|5HUs|L=m3k%jqNk1_uM4eQLJwp3AL(KAEOe|a|5;EixVw5F z4>WpHcSuT4&2Gf4)J_C60KIxEsp7kAiofCau7!(`*a~aB@8<3MUeh*}ZP}MMPV@E+@B4Pj0#q)DN ze!l*mkB*3uk~4m;;0$G>_33zpvdKBC)Ai%_4Go$Mn~jXiMNHP`xopMp#xfI1jnA*d~jqg z`YL6(D6Ph?_Gjm5@*H$+*ORZCfJ?q8(qE#}uAO+Re=tUS-1altAncyu2ImJ-iKpcavw9L@W|A9hxa91)~9J18TR^GYmPg@$4aA?9v zK|x_=suU|t(uH&}$0;~r|1@=WrdW4tU&{a@JMe*tH%*B;=w?9cK{E6Cr)qN*9a#JM-7zw!9O0wRI?kxqAMXo=OLvd$m}9&qp=iJ^091T zY8n1<(KsB1)sZg~kqN{cgKmYQ6D(m4$BC4i)Dsoa<>NG7=ObrpyxJq9$j;20_7+z+ zi-l;38#P7`+}hk7GsSybmK^3DFJ(8*dk4v0f4H#UPH9~hf~G}8t>*|K@8<%8SHwZ~ zQL}qU>f$UliT5JNN_JU?ym(=ku3GP5ltDk_a3Ri;GLI(0wiHk%`hBvpg z^s+3p6YGB=G9}@xC?shfAbn66ewdjqVkuO>w}KEs1iWhC)uX~dsPgAO3Qlt`WaJp$ zQ+H#OTLUUTe%gQAHx9Aw-7yY5D8OmmE~mLc)_lly)wt8c>ZL_W;&vIRo$@A{J!I&oJaqY%eBZ~Y&SF=AdZf?n|QGvb;|8oelLmn&Z)i1$ZhC+l3 zh3YR902C32vR~sUHdRjzO@vGX>3808UCzY}hs-HG;rpaQ1cA*{p0!i*32fB8;EXx@qFJQ2)rT?9Gz@yzc>257il2=a@3bpD z9`eARQXHQsWV=m7sVYA!XI`IENBar&`reWu<5`yb#c?Lww^!Emf@WH#^OryW;QGoz z)B0=oFn5js&Tg|-d!JWld{70ROIa}6lmkS^qa6WC{Vh)D*<#W?iYU&Ef zR6@nqv-h(CX0NiXq|+KPUn+Nh1)6OeQ`itQfL5t)OXc}_0>E477*CADVbnCY8F=Mz z0jGM?pr-1MLg_1C5BZ(91HEH1Mr&3^D`&YMMf%Q&_qJQg(zLDyTSG^@)^5kcIYUPq zwKq?WtQe$^4=%2+^|fbxKlh^gGlw@AOwJRO4>UL2C(?to{Wi}7SEhi?WoE9$iWA!s zh<^FE@prR`szjIjaE3NO+ft|;fgKA@lVmxi1W-Io@Jt7SMmNH^C-e9ufPSz^FdGYO zY&2hDPg-N6MQ{^1uxjWeJ)L;n&W^SWJ^Yd6RO$^r^n-?`4iu@p-c!)7k@Q4gu_z@G z2haAC3-UG87N6ZGj?MSYfE2o>B+aFsIi!;p1%($L4FW@dJm?dP_7K*8PAZbK4@aH` zRI|UT;U*DdVyWz!@DC}hvU#mq{xj4sws19MTD80a7p~4@zyQ_P z@6G0`lG%i*Id{E^_D|NGuH>s86Uy*$wrXkC`cyqI4JUv^mw4=gEW5Pck&Ycy5crij zdQj?#nLw95l5pS^D#LnpG$44Zbtuk=y1_R#G|o)#oeD?VuBWAsr}I%rpoSj}{oJqI zigvFudq;gLZ)G_jHSI|EMqEVu!iX>awaEfZ0T}fx z$)(T(PD1)0L7B$Qy2xBw^2s$d54=}QUW1c;&*2Eyhsaf0oyeqD&ne4#N0S5h`ZKfp znI-rmaoK&c(>s#&Ja&%SFTY+yrraX;fHbKBMg~@g_wCMrElD!txL6HMZ*Sup7CB>= zir6Y*2L?%tT;}Fnt0L@;WT*utXVaTIa8cF*$+XT7g7V8Y%VDQ%paW&AtgjmYly<&+ z=mK?Rq5DRC*!b9-5t9-yMvo+A#zqD@sv*qpue?1MpA82uC=+$6ZVsZ>!nbOw_dYY~ zaJwpQa;_q;wmaRdW+{m%?c6nN_ga#o;CL0ZIV;LNWUgZ1s#b4E=GrLGy$89I4@3yL zNrRCLVy&@?6P8Eo$x^x@Qc1*EU}VLGrOqA7ATNFo%~8ka@dT1hhq0s$SlrLE^j+wM zVk|+(JrFJDJ@!S7<9fHdk0rM4T>H8G80cvk6dkl4bluBaF6oOXl48c?89?8(g_i;& z5tqd$6unltp3sj8Qcqa#IZ){vA-R*ev7N4{Rm1uN!xT-yR8)PWIqwbS%LdW8Ki}BS%P8(D$w9I{Ul+2ZUMSy)0 z1h{2CaI=<*TtZI8&+_Lcwwfk7cv)Fb^+$gMCXN*g<}|^Bvh5$-y@hDSJffK<0ZJ#y z0MIr=ncxaq*5W;hbKY7%ThdR? zGVRq3bfHL6cnGv7@*>@TDJAo>V#13{=Xk~Zzz+TamMs4(egrZ>78{!D z3l!Ss9?r&RT|8KIyCO)N?o+RPD9kqM0t}A#tI-z1I8(E$>dZzXqRkJL3gh`yA@3!u zZmI*}@r43b5hk8nJsPcNaw>!k+P-3}!m$xX zsceAa`?)uIV3Q$Hv{;NvQ4+L&GdCpXKJYiJf(qGf=w!f3y)QG0ZaV27AlvQowxOkQ z?CBx&Y5$_V6xx$4VJ)xO;;@KjQkL1U86`Lu6>Xd`Q1`Y2^vwhK(?ccX}-OIH3 zBuQjUFwJ3RBbXKh3U}TNITPwB+pYVFjm= zAUmSC-1?gF2T4y(0kNS4giDq;0-e)8NxTBLmDf$9R1=bzhopF)inVU8KK{I(rO()14 zv+JfdRZ(Eh&L^`lCJXR%hw4u1YV3mMZhZ%7nNaG3y8h0CB*6Qz)#@eYP>WKm=v`i`iXJuQ>*5v^3@&o%Jrt>E^P7l zD@&4)v&WbGfO*xH!_;|(KCgSjB`xJphQ&GnrjqB0ycNeVEf5wu@<}2sGL(OlDoPWz zD#S`Fxw)k?87iNl*31tTg&?C|hCgC|`0Y3ewPA<(6-Rvh6g>6ob(UDSbdqLnVFZ8g z8Gu{&G4dT{F%)7(p?k9w_KR_=mT#3*8Rxa2saQkj$H;UAJ2Oo668%Xb0<#Q*1em{9 z;l$5jFF&U01_kr%&QEsb6Mue>PTkoIW39eMvktbUq)hyVBr=5+Px5QaKjU>L779Vm z8J0owC-8=usd~g7CP~bV&29buQs+pbQK?V@NB}JYM>)mTZK;;jtP15<6jM}$gRFrt z0}$yKNT0mXqj5h22eoU}H@XE$lK!?`8E?sScivnjh3l{r+^LXwi&Uq#zGGC+;(2em zfx^}Cc-r{bot!ONbJV+lnYKs|;h%%KSph`^1*Yoj0P;CBZS8l#RPH}6s!=f~8)Oq@ zmpVwClVqHVL#(;M1cM1t*o_uuM+o$eFJmQ5rwPnUhthnHhuWD}fu;onlY}4p;)42Q z1<~7YBg~7#@4jhfB0ai(otw2x-$+WwjAl27JamB~B@iv~Y;KwlDleG5j-MUMuFK4( zOh|N#4UtEcXz;;@CtfKqz}sE*edyHyPw3rw=Cr~)Ev_nfa4%>nXkeaP;E2?4;eXOp z1vwwKIhf&c3Rbl-n5I%jb?gbaVAd}Bpxf2MEQG>M|GIb&LQQ9uhVlFqw84CH+h2Ot zfd*Q>U=FM!GV;J=4BYk`gqMpHB#ZguB2EU%%gr(yHsHjcBck&2T3krp6yezd+|Xcv zd9y)4gVv5FU%;UEpw=O#h{z){Fl}5?#V>^>l+na~lOtf%G zxdL5)j8U1>Nc1Ju*W4gIC+i3@xAdVT74Ig!Fh!d8m3Q+yoqg8!}!17I5U-%SPY4p!J6x5N8wW2Kg?iGl8&#gL@+2km6E<_^E@Y>B3*HbbpK?Ie zW&}5V?VM55zU|~xmt~9ajd!sdcKjM%Obc})yAs;04-NAsAS&^+E$M1-sB`ZG1P6}! zac?g_R=kaaz7279x>#(g4H^1Jj+sz(@APwv!4S$iN+iXQTu^rj9~QRKPqYV-J%Aam z%1HF7@W@qe1fpR&x$Qr~I;onsz8{JpMy|}A3FWIc4+Z+pl0zfBnHHYWMX!E?NwiAZ zc$_dZF&9{g2?n^&m~)+6J}xt2JNPwZmho&fmbG*va^&OnDndn0);bbCz<|p;f$aCD zu=Pv_uoscYqa~^Nh4@8i4sr(J>lG;4PV zvO~syJH?SX6>8UYpBjvkwSl6%zDNIK$uydE1d8k40#+H%NJf?!6?C zxN^+wFjJ%UU@ATJ_7sbz=Qf$cDq8Np_)e$g{@XGQog6V(WTh6V>YCq+t1GlCX{)zu z*n2qZumTeAPid;q0YGuQ%Qh3gLc}nt3p_xRJD|T>G38J$RLTGgu@7Q>26$lD8lb2n zmD3H6ku0pq6MQ{oc1ujGB65%kjHWQT3u&z2YaDs8MzU054u=|LxoDF-R?&4kA96UI zp{fb2q>2x%UmFS=?j0&sV@Y$>?*XjPZK9lYw$NPY=Diyz{=hxKce>5jaDLa-(V0ak z4z33-F6WGrF;7CPYC9+q4eVs_I-B@ytBdCOb%-PmsyD6<=_}E~vnaYUnuwFEzPKts z(xu+G3=<=RV*@qOkPJ0)e{F`|n|wCu@_E5`CgP(piQ^2Guno{2`UBxG`SCiox6QD| zA7#(*?UkY}<5u@V{fjd5@Wu|Cy=*yuylVGdQ3b6YT2a#_tKZ)D`ky`0Y*YEFrevo; zGO*^{qMce?fDN#Fn<<6j08{ZWWF<*wFb?Cd{hH+%64XNz?XadXb&+Z`cu@TNP+3rz zjaJfO3MrVmEYpw~3&$f^zQ)8)xJU&fw<+46LGox>^B;6M?soZwc2{7cLrqP-y}p1W z22j1Iz=~NDO#zN-h!JsaZoo-*s|~8b5--FIxRUh?aUWOQDyiQ9qz^OwO~yhOg@~gc zICs*;{JE1lqx?a1F5>miDvh)WY6I>0bJGKkewYYB-78ef%y8f;R4tPqh6)L7+w^1& zXXxeuaAca?IM7CRQ1VvSw$o9JYVB8ECzo^a`?moQ{d7re^ClqJ#Vve5f zSb%-pTgcL%^cB~Fjio&j*dDtjG{uJI%n%EG4hg2*A+b+b$Bf)tUJq8$LB+U~yaga- zAU;o|Yf48D<__`@VA@?ZvS8Bl_Fy>VyCBUabm34Q{JnE%bG9?WHApB8EW(@ib#0L3 zfA`bEPUbcw6DZ1h#(O#NXy--p)E-`3&{&2B6XPUy1!_%Tg@BrKEvP-!nrT`sjT-As zt|GF`RoT^SDZH%Cz3>tAB+cs&8TmVuI<6e725MQg%36j>@ecv~UVt5<{trJDM=38} z##W}ja4lXTm!hrjZOaZb@q-8w0Xs(mbcBTCQElxoYalQ&siQ~UiNWDkiCNKMM z;VX*JzooL#Kfc+x3}fzn{b01b(QS8gQ|UYA3plm`|By4N ze(7m*aTg#FVbALgME4*uXI|wg1hVmFzi_mDz-&SbsitOK`Kl-^vbbDr9=(vt5L^Ms z*%8bJ+B<#35tdGw@x;bXTI&MJ(ZROVHY?E{3<@ex<;TZQ$R}VnO@R!i1Od{cgqAAV zb%E0jN!184U&SqOel`(C`_(WIawE;JkjJUCXmw69*=c{+z!_UtmDWYnBGI&ZN#(@l z?ljl9SXEp`c$>!){;tA!8kyn-aj%DSp9{?-Ca@#|h-)ze51=-x%9Y#y>j!H~Gb?Ts zxzuxp9AmMWi4hyzm&`+Hv2HFzXytJ+;2?56IrIW;m{C_;i&_Z@IeWKY1hlM@S;^XwWsiXqwb zU_qo?KtV-7#Q+OLAi*SnAVpLx*v~HZ?%4%J0YyES8X6DVCd9kxuu(Ofm{HoE?vB&)Fc?))!NX|_=`f?m@UE0X3 zVE5iTRxb{f92z=e{gV#v$M0Ho_Y;kg_H@?x9=SPmNzCzvyJe}Rxz7AGDsULItr3y=LCHXW<-TPs7$s37R9)GE#viReTo4H1gd1t0(P3+KR!Jxc? z-wq7*?{#^U^Ypa*)s~3^=Qw=QwDd4ul{KcqPR`qS#l=n?)O+Jqm$-3GJ5!tGW>@CD zI*;Gz5jJ?TW#Qx8`LA}~EOPPu*&W|!)vMK@O*{Z`monAJDg%Cu{X=SG?Djt?bgX+?_F9x(wQKp>>OehN%}`wCrhF z5WRZNc8Sjb$ap+E^<4v;8h9AB#41m~<%9;mw^+hrdLZ?)N!uex&!7E!Wnht7ip9 z-O?mXd$!NJbC=Kc)cY4N799%ywChOmL-QAx4sTdzR=TM7AF`1`2j0bwlZ}=H`{jFI zztMcQneBQ}# zes1J@k{A3jB4_#B6U%HqbzN_1Bae)XOFLwiA2-oDb8PI8D+^Zpyc<+-bApK`TD`z|rj z2A=m~mXBRn&{?s2eSA_%;4PjkZs_av{Ut*0(&+>D*fhM?u94)1+m84J*N2?4eBbO) z1Cd|r*(~?SD_71h@%1&GalU=0D?L+6Hy@rkqtm143BF+;u2kNiZ#ykNuQUaiPA+x_uXL;r@Y z564Wc9B%&JpwYNe?~uFwgX+zyoa+$wykVaNFO%T+UmA=w^E6$Z?J#2Jh7V3RPw#R! zzx^rn$+;Qk9_M5OOlJh2ojYp8ng?aRQDq5UiyEfyR(7nEeBAlI$>g7UC3Y-48Fcm4 zk&BNWhCM1SJhHKUdQ?{8$SLd>&(~k7m(*7(%zv@kZRV~O!>@11xv(QMVZb<>ZS&&x zuAfxm*Q1YxwRP0%(7h%H$|jg6I_=|Rg}FDry0;7ebE{|R+*@4_tqq*&=o#F7L7qdr z;Q7t#5rgiX$jK5v?fb+k;*a$4gR_piTJ9?>j(A%C%J_!U%+g z0}@JGoX0{0LvDD*MIMYi8994nf_m1d72NZO=RethbYj2UEBkPVUB8}v`8eR0u+Cl7 z@7BDFxfPV4ni&}9r9LBS-r3IEY@NwZhtpf8Z_W{~`2E!4#pQt?TRuFzSfMy&=99C$ z-kozNcC0(e%k8;2ZvTYddFGOFGaVPcK5Paj^F3cmii+5kCejR$~V!A6?ZCfXMetYvE|&iz08k)T`}Hckll>Lu3nM% zTSPVz`xg}0m>#;B+m%za};|FW*vE3f;tvTWI;*NCh+%a-*W`OB{O4t^sBh38wx zcMyc1RW9C6M1-sAHF_A8XVz9yA6-SKk97uQWMJK1eKJ#O=` zx3}8G?C_eo$|WZ1bM_p&uTxv?K2b5*ssE}G=eOQ@Gi349A!BX`GMcXVsfS6(6srlX za^An~bbsG{_xl?LN>BT5-MZJgz~uCd`s-5IcgG*-9&mZ|z1)P8ap#ju*QHJ~fAXO4 zwC4t{Xl>KG_mc`9m##_o6%E36nRh$8Fv#Wjo@?o)liKID&hqR~y0ll%_GW9QoN7EQ z6ZgrIA9<(39=GY#_*#rv<0-q&3y+r$ZRPi}DY8gpC&!#E1t(*y3x5+LZjKERy4HVmg1h(`lU$7y=rDM z;m&~}VXl6GMdizGu>E#^IC!bc7Ws}G_g@E;jo&fq`S=$HhR3(tpO8Bv_jYc0;6`PS zNq@fH((>~9bMpj~Gc~S#Zl@$)YP4pOeR2P|rYVCSaF)ic^tc`U`NQgvL%#%ujwrBM z)1uq?yY^+k6o5V6yJIq+B`CMW4fd5qqI3LsqZgci8YEG_0Vmy z>&cS9^=$^ed%b*jw~EKh!#(zc9 z{NC@q%(BiA|JI;*&hY60ZRRz(&mEH4C)Ts;&%GC#ZSCayIQmRRcdjghXLJJZdSTM% z4TTGLmkfS1FJbx2sJUInK!XvLl?6VU)pSV z`W25Ut#3cBVS^$Q)w#th`+Sgg&8p0^&bTl#C@x^d(1@l3_W12GNr+5TosRmFeri-w z)QMu>0jXsPGC{|7uR=^u6vylwQ5b#x(`&myzxbvtc%zJ5pkCy@XzjGk*es9t<>`G} z6s8uQENj`P{*zvJ@Fj~lp0?X#-2DyDuWU3e?&O2r^DkTr>)+)3gp4ouTCBzniC&&) zX?4iqaK<^ikhrwlL+al@(PxCi!ceYbOmmZ*x14E?A#IYbs7_tpDC%|mP4tm(^kEm=JX#nM-w+R(zi6>{fT{hdf#bQXytBF{*$GX$=e3Dz*?0KS#+u{=S8;6VuAq5srrtB{;&|vcjaR)W-!@UZzj&>{ zW^T1@={DVH^qmHq21WEt`uSV~!-*SPH#*X5*qy{~>|JeUetqjIc6N-^0J9dtL;tJoK2NUWT73V4szVvI{RJpf>-Zme{MGNb-v3NYu@Cf zmz7geHjbWk>gXQcPr(*peil1Y?)!WDxaKb3yMgzcN%5k@K0Ui$J+U(XPp2HtmeLmo zV~gIbzj|oFm-hBQ8D^(D2dm3Y9ZYCwW4vvrL4x|k%qd5t5^UM}eyUs35AT`&$2+6! z)17*0@Z&w_WQ8d^d=;18-*T;8r>@C?pFiBY74Ycvt{thVJG_g$kCqv=?QZe;i-B20 zbikkSN=4k8&8L0drV9NFu1)$gx%2NI3JWJ*@Y#_ex%@!kW@B?c*0yK-nvkst#?~%l zHcgCe);cOx}Nj6i5Wz!KI77$ z$Cx!XJ#S_2TV}0hy*w(BbZ~v@bg0v+@Y|xJKE>A-?NKd#F`W1DWxki0B+>ZxxRqON zJN27wx8jBO%r6DoSWDkrj9tbzaUSp}wIumMC;!H|ZnuuDc^9|%Q1gu~!=zs`Rh<@o zv@QvcInc+)Hs@!D?)DcgOpG%ganFSe<^7g*{LMy-apR3=PfojVJ9cBmHs=E6fipfA zIUCB(mu9}>*d1S+CJ7%uY4xr5pL;%WduyC{CTc}&n{n^@EfS}PmM?E@vnGuj`b*D@ zDEq{wOH#J55z>z93hx%K?m!oY0lK}*ZLgHM7SrDpRcpI4swVzkxR&EOc8 zr@r{tpKk-1ELLcO!NX>Z$;2X3~VHh6ijUWxj<6+#++) z)zp5MpZU0QUquV9ZgY*!9^gKq!&M*GS$%IlS!vZ_``8GNZ6lX8EVNtMum6S5t??E+ zTW>HfzGQm?zN`0&3M*Hug_k4=kO{jiDdT9y-QI=qc)V-nI@gf3Pt(@nA3F6h_;g`F z%9j20mnEoLr_4*fb~JwQy!G2N!ZON-_}saEdW1pz(_az>T^X=@UD?>RPiMLw6kHv= zb3HV+yu>hJ!tT;*X%(xBu2>%%ceClNPp87mmU!4tw7WlkT&E3< z12cdBebU5}dsH?-@3+bM{hv9y3&ax!7ke~am_F;Q>9FE#fxu|Ec-zj~OZsPbPOSH( z`I066ZL&Q-PMqm;=5r6h>>(pPwpH4f{c)sSMroL!+1mpaBmQ!0yFS+Ej|Ubd13E_z zb!{56qfz#%f>py4Hpb@-GFZ}T^dGahs`dN{3;@8Cf2}}QM{bd8bi6h#4xxC|?q8=yA z8fF&6J7wbLPa4=&rY#D1cIT~eLc;ISVQ(_e-yEKju>Nk!jQ7_-3Vv#mT*QZyd49LA50NxWvGJ zMi7^Gsl%-(Rf~?(?@Gt0e>&(?5#xz3R4@O$r`YCc<7cH;1PCoR)zSxV6?zStu zux43o_MnO$r&BzyP1=8=|AB4Ce+{kN%qii#Ozv6o=+_fjh1KRXYs)$@jK|~aq_o$x ztkaH6X&)KvCHIzDDivyF01nyaL{>YLT~<|dav?z-3dlr!s7{k|znjr6(aUJZ^fKDT z=w`JU?L^iyEfJbo$0@@2|0|V($~==xt@upJXi z?zGx@^4NvZgroL1pm&E9S`x%t;m<*`FrgkK)iHQX|#Jk`nIIonHkeM!jEua(N>eX6Ys2we4`Ts zyGM`i`pUe#U%t`$c@_1)v?{XwIK9X=r!?Q__siYRya*d0;1(yq_ zg*bj49=X4A$_F@wzHfAnTmI6QUd7u(id@`s%a`6B`gNw;*#d*mj+Z#UUoOP>F%@m! znk`Ek_H|0}Y<}Y>29~Temw}&pmUq5XnEl7lufzRaD_31BKNJ$*?Pj{sT)U{VU95{{ zWZ3tLj*YT;W&YA8KKiD}d{5gqpKnfxkA7zx-|d=L#dBHWr?V5SpV%7p{M2rzY3F+b zCJes4IC{L>!-z#sQZqF%FT4KH_*HpH=*1RE^~aQdZXWvD60dd=*0SvMJ9Dnu+5#!B zs<=SJg@VqyzYYJ!v#8(yFQ1=8sz6kvqml<{lqwdF$1IHHF$-gPI)$+|7!>Uw8G)i$ zsNdmIANedyN_`YS15}*?4TR7G^-%P^rWDBUtffxB%1ZGSNhk6}&k#I4XwyIHx zg)DfHHzZsr!P0&fvLsr#l%+*L%4NZeY%LYC;02$7QylB*8e3NyL`9{Z)glCK(~Cj z4R7tk0=NzDs!O;l_;wfALXHrg1eQ}=M;9QQRd;~lB+pR?z;2S`M0dbqipy{dI7~4a zE`ST#UWFJ7UK9$bw7m)e5eBvD+B;Cd4lk;i7-PW;E{JZ#P}^R0OMt6@5l0x}Bsmdl1Ir@JHGi(saUrMyWT3xz_dQM(&r z0%B&v^Mq)i;xSv5GB^lC#u=nurcei=4!puRmd;Qv7$Wz=oot73>{%LBFg|1m2fQ{7 z^bg&kQR?UiG|+z>S#nB&h!8YoVI-m9FEt1Wp@HC<7<%0YLwudiV2G*kkB^3&%XP<+ z)Y`ge{$C+FRFnd1fvBW}V5F$SHNkTDOB{p(e+k|9H-SLtgs5=)(ORpnTgew2XheH}W7zR0$ z>r8zE%>WA%Q9>i4xBdX-W zvA;W9nBg$1C6nrCfJvh0B88~%ZzDx;KnE#80{>;CNa@!_>OVj!Qlsb~MJV`xnNp;2 z(M9S%Kq=B->mWrqBL6a_NGC`ass8|_NOxEVDPocEFH=fNC~sY){sWYf()ARa82ImN zPmD{a6-LBw{$&or(K5)c>aY+t0R98iB2Q%0L+d{vS{Qj`jxJh^!(i&NlK-3T0!Hq8 z=%MvrpcWZK)J2PM@&9d+hjR;QBG3OXP>au)iG(ZuZ&M3SeAK5Fj|kUMXjv+QiHH?! zsHxNtHztFHoRKngL9TCg!(QZly=S?cv2slSKFG$}#)iukin&~ogv*7!K`!40VrvjF z*SHftK4*ff8$|a^;YdrV0RD%~Ln>fKV&ueefY2MDKwrR+kQ#IfCtGsGf**7&;0jEE zON@?3tMHJju~S$u$%Y_$SfFi4l4``_hoTx8Uxf&`E?EF8L>5R01=jEx$R$E> zfC>2|<_IQ+1|s2iNQa3;rhv!yMnI5GOGh**bS(pq2%77SK%aR6gc0i9g2W0oc?o)o zfSF>EE>^_i?i)ize9RPhtb-LnJuM>ID0tO=1ra0u0V0ST1Sx1QS_m$wos^AQe}OAjNo+eH7z~M|G4QPdAJ~&KDZlC}xTQ8xedK1Gb3C1V-o{ zFd1D72@+Bm13=9G9=1F#jmacADQubK)KP!cBB+%rL?$#r(O-lZK{84rDUy>C`5nYy z&&w3n?>c-YH!�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 diff --git a/docs/sample_chart.doc b/docs/sample_chart.doc deleted file mode 100644 index 631c0554b2..0000000000 --- a/docs/sample_chart.doc +++ /dev/null @@ -1,24 +0,0 @@ -/*! - \page somestatechart Example state diagram - - \startuml SomeState "my state diagram" - scale 600 width - - [*] -> State1 - State1 --> State2 : Succeeded - State1 --> [*] : Aborted - State2 --> State3 : Succeeded - State2 --> [*] : Aborted - state State3 { - state "Accumulate Enough Data\nLong State Name" as long1 - long1 : Just a test - [*] --> long1 - long1 --> long1 : New Data - long1 --> ProcessData : Enough Data - } - State3 --> State3 : Failed - State3 --> [*] : Succeeded / Save Result - State3 --> [*] : Aborted - - \enduml -*/ From 6580b200db70bc175526868b1b632fa19220afc1 Mon Sep 17 00:00:00 2001 From: Mayukha Vadari Date: Mon, 10 Aug 2026 13:10:18 -0400 Subject: [PATCH 052/102] refactor: Replace boost::lexical_cast with existing alternatives (#7991) --- include/xrpl/beast/unit_test/reporter.h | 3 +-- include/xrpl/beast/unit_test/suite.h | 3 +-- src/test/unit_test/multi_runner.cpp | 3 +-- .../rpc/handlers/account/AccountChannels.cpp | 16 +++++----------- src/xrpld/rpc/handlers/account/AccountLines.cpp | 16 +++++----------- src/xrpld/rpc/handlers/account/AccountOffers.cpp | 16 +++++----------- 6 files changed, 18 insertions(+), 39 deletions(-) diff --git a/include/xrpl/beast/unit_test/reporter.h b/include/xrpl/beast/unit_test/reporter.h index 0fe77a7862..cbd1c7e70d 100644 --- a/include/xrpl/beast/unit_test/reporter.h +++ b/include/xrpl/beast/unit_test/reporter.h @@ -8,7 +8,6 @@ #include #include -#include #include #include @@ -188,7 +187,7 @@ Reporter::fmtdur(clock_type::duration const& d) using namespace std::chrono; auto const ms = duration_cast(d); if (ms < seconds{1}) - return boost::lexical_cast(ms.count()) + "ms"; + return std::to_string(ms.count()) + "ms"; std::stringstream ss; ss << std::fixed << std::setprecision(1) << (ms.count() / 1000.) << "s"; return ss.str(); diff --git a/include/xrpl/beast/unit_test/suite.h b/include/xrpl/beast/unit_test/suite.h index e24904a87b..a727e3fc77 100644 --- a/include/xrpl/beast/unit_test/suite.h +++ b/include/xrpl/beast/unit_test/suite.h @@ -7,7 +7,6 @@ #include #include -#include #include #include @@ -30,7 +29,7 @@ makeReason(String const& reason, char const* file, int line) namespace fs = boost::filesystem; s.append(fs::path{file}.filename().string()); s.append("("); - s.append(boost::lexical_cast(line)); + s.append(std::to_string(line)); s.append(")"); return s; } diff --git a/src/test/unit_test/multi_runner.cpp b/src/test/unit_test/multi_runner.cpp index 71208313a4..918fc7c89f 100644 --- a/src/test/unit_test/multi_runner.cpp +++ b/src/test/unit_test/multi_runner.cpp @@ -7,7 +7,6 @@ #include #include #include -#include #include #include @@ -36,7 +35,7 @@ fmtdur(typename clock_type::duration const& d) using namespace std::chrono; auto const ms = duration_cast(d); if (ms < seconds{1}) - return boost::lexical_cast(ms.count()) + "ms"; + return std::to_string(ms.count()) + "ms"; std::stringstream ss; ss << std::fixed << std::setprecision(1) << (ms.count() / 1000.) << "s"; return ss.str(); diff --git a/src/xrpld/rpc/handlers/account/AccountChannels.cpp b/src/xrpld/rpc/handlers/account/AccountChannels.cpp index d50bf1cf07..f2da1e31ee 100644 --- a/src/xrpld/rpc/handlers/account/AccountChannels.cpp +++ b/src/xrpld/rpc/handlers/account/AccountChannels.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -22,9 +23,6 @@ #include #include -#include -#include - #include #include #include @@ -129,7 +127,7 @@ doAccountChannels(rpc::JsonContext& context) return rpc::expectedFieldError(jss::marker, "string"); // Marker is composed of a comma separated index and start hint. The - // former will be read as hex, and the latter using boost lexical cast. + // former will be read as hex, and the latter as a decimal integer. std::stringstream marker(params[jss::marker].asString()); std::string value; if (!std::getline(marker, value, ',')) @@ -141,14 +139,10 @@ doAccountChannels(rpc::JsonContext& context) if (!std::getline(marker, value, ',')) return rpcError(RpcInvalidParams); - try - { - startHint = boost::lexical_cast(value); - } - catch (boost::bad_lexical_cast&) - { + auto const hint = toUInt64(value); + if (!hint.has_value()) return rpcError(RpcInvalidParams); - } + startHint = *hint; // We then must check if the object pointed to by the marker is actually // owned by the account in the request. diff --git a/src/xrpld/rpc/handlers/account/AccountLines.cpp b/src/xrpld/rpc/handlers/account/AccountLines.cpp index f134c8af92..4a6d22d5d8 100644 --- a/src/xrpld/rpc/handlers/account/AccountLines.cpp +++ b/src/xrpld/rpc/handlers/account/AccountLines.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -22,9 +23,6 @@ #include #include -#include -#include - #include #include #include @@ -153,7 +151,7 @@ doAccountLines(rpc::JsonContext& context) return rpc::expectedFieldError(jss::marker, "string"); // Marker is composed of a comma separated index and start hint. The - // former will be read as hex, and the latter using boost lexical cast. + // former will be read as hex, and the latter as a decimal integer. std::stringstream marker(params[jss::marker].asString()); std::string value; if (!std::getline(marker, value, ',')) @@ -165,14 +163,10 @@ doAccountLines(rpc::JsonContext& context) if (!std::getline(marker, value, ',')) return rpcError(RpcInvalidParams); - try - { - startHint = boost::lexical_cast(value); - } - catch (boost::bad_lexical_cast&) - { + auto const hint = toUInt64(value); + if (!hint.has_value()) return rpcError(RpcInvalidParams); - } + startHint = *hint; // We then must check if the object pointed to by the marker is actually // owned by the account in the request. diff --git a/src/xrpld/rpc/handlers/account/AccountOffers.cpp b/src/xrpld/rpc/handlers/account/AccountOffers.cpp index 1467b14b48..a7933f65a7 100644 --- a/src/xrpld/rpc/handlers/account/AccountOffers.cpp +++ b/src/xrpld/rpc/handlers/account/AccountOffers.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -20,9 +21,6 @@ #include #include -#include -#include - #include #include #include @@ -97,7 +95,7 @@ doAccountOffers(rpc::JsonContext& context) return rpc::expectedFieldError(jss::marker, "string"); // Marker is composed of a comma separated index and start hint. The - // former will be read as hex, and the latter using boost lexical cast. + // former will be read as hex, and the latter as a decimal integer. std::stringstream marker(params[jss::marker].asString()); std::string value; if (!std::getline(marker, value, ',')) @@ -109,14 +107,10 @@ doAccountOffers(rpc::JsonContext& context) if (!std::getline(marker, value, ',')) return rpc::invalidFieldError(jss::marker); - try - { - startHint = boost::lexical_cast(value); - } - catch (boost::bad_lexical_cast&) - { + auto const hint = toUInt64(value); + if (!hint.has_value()) return rpc::invalidFieldError(jss::marker); - } + startHint = *hint; // We then must check if the object pointed to by the marker is actually // owned by the account in the request. From 4f8819565a8b42e536c5b6d92d299e1c21cd70af Mon Sep 17 00:00:00 2001 From: Mayukha Vadari Date: Mon, 10 Aug 2026 13:18:22 -0400 Subject: [PATCH 053/102] fix: Assorted cleanup fixes (#7988) --- include/xrpl/basics/Number.h | 2 +- include/xrpl/protocol/AMMCore.h | 2 +- include/xrpl/protocol/TxFlags.h | 3 +- include/xrpl/protocol/detail/sfields.macro | 50 ++--- include/xrpl/tx/invariants/InvariantCheck.h | 4 +- src/libxrpl/protocol/InnerObjectFormats.cpp | 6 +- src/test/app/lending/LoanBroker_test.cpp | 2 +- src/test/protocol/Hooks_test.cpp | 189 ------------------ .../rpc/handlers/account/AccountInfo.cpp | 24 +-- 9 files changed, 33 insertions(+), 249 deletions(-) delete mode 100644 src/test/protocol/Hooks_test.cpp diff --git a/include/xrpl/basics/Number.h b/include/xrpl/basics/Number.h index f90800c715..ec75724b5d 100644 --- a/include/xrpl/basics/Number.h +++ b/include/xrpl/basics/Number.h @@ -304,7 +304,7 @@ concept Integral64 = std::is_same_v || std::is_same_v // IWYU pragma: keep - -#include -#include -#include -#include -#include -#include - -#include -#include -#include - -namespace xrpl { - -class Hooks_test : public beast::unit_test::Suite -{ - /** - * This unit test was requested here: - * https://github.com/XRPLF/rippled/pull/4089#issuecomment-1050274539 - * These are tests that exercise facilities that are reserved for when Hooks - * is merged in the future. - **/ - - void - testHookFields() - { - testcase("Test Hooks fields"); - - using namespace test::jtx; - - std::vector> const fieldsToTest = { - sfHookResult, - sfHookStateChangeCount, - sfHookEmitCount, - sfHookExecutionIndex, - sfHookApiVersion, - sfHookStateCount, - sfEmitGeneration, - sfHookOn, - sfHookInstructionCount, - sfEmitBurden, - sfHookReturnCode, - sfReferenceCount, - sfEmitParentTxnID, - sfEmitNonce, - sfEmitHookHash, - sfHookStateKey, - sfHookHash, - sfHookNamespace, - sfHookSetTxnID, - sfHookStateData, - sfHookReturnString, - sfHookParameterName, - sfHookParameterValue, - sfEmitCallback, - sfHookAccount, - sfEmittedTxn, - sfHook, - sfHookDefinition, - sfHookParameter, - sfHookGrant, - sfEmitDetails, - sfHookExecutions, - sfHookExecution, - sfHookParameters, - sfHooks, - sfHookGrants}; - - for (auto const& rf : fieldsToTest) - { - SField const& f = rf.get(); - - STObject dummy{sfGeneric}; - - BEAST_EXPECT(!dummy.isFieldPresent(f)); - - switch (f.fieldType) - { - case STI_UINT8: { - dummy.setFieldU8(f, 0); - BEAST_EXPECT(dummy.getFieldU8(f) == 0); - - dummy.setFieldU8(f, 255); - BEAST_EXPECT(dummy.getFieldU8(f) == 255); - - BEAST_EXPECT(dummy.isFieldPresent(f)); - break; - } - - case STI_UINT16: { - dummy.setFieldU16(f, 0); - BEAST_EXPECT(dummy.getFieldU16(f) == 0); - - dummy.setFieldU16(f, 0xFFFFU); - BEAST_EXPECT(dummy.getFieldU16(f) == 0xFFFFU); - - BEAST_EXPECT(dummy.isFieldPresent(f)); - break; - } - - case STI_UINT32: { - dummy.setFieldU32(f, 0); - BEAST_EXPECT(dummy.getFieldU32(f) == 0); - - dummy.setFieldU32(f, 0xFFFFFFFFU); - BEAST_EXPECT(dummy.getFieldU32(f) == 0xFFFFFFFFU); - - BEAST_EXPECT(dummy.isFieldPresent(f)); - break; - } - - case STI_UINT64: { - dummy.setFieldU64(f, 0); - BEAST_EXPECT(dummy.getFieldU64(f) == 0); - - dummy.setFieldU64(f, 0xFFFFFFFFFFFFFFFFU); - BEAST_EXPECT(dummy.getFieldU64(f) == 0xFFFFFFFFFFFFFFFFU); - - BEAST_EXPECT(dummy.isFieldPresent(f)); - break; - } - - case STI_UINT256: { - uint256 const u = uint256::fromVoid( - "DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBE" - "EFDEADBEEF"); - dummy.setFieldH256(f, u); - BEAST_EXPECT(dummy.getFieldH256(f) == u); - BEAST_EXPECT(dummy.isFieldPresent(f)); - break; - } - - case STI_VL: { - std::vector const v{1, 2, 3}; - dummy.setFieldVL(f, v); - BEAST_EXPECT(dummy.getFieldVL(f) == v); - BEAST_EXPECT(dummy.isFieldPresent(f)); - break; - } - - case STI_ACCOUNT: { - // NOLINTBEGIN(bugprone-unchecked-optional-access) - AccountID const id = - *parseBase58("rwfSjJNK2YQuN64bSWn7T2eY9FJAyAPYJT"); - // NOLINTEND(bugprone-unchecked-optional-access) - dummy.setAccountID(f, id); - BEAST_EXPECT(dummy.getAccountID(f) == id); - BEAST_EXPECT(dummy.isFieldPresent(f)); - break; - } - - case STI_OBJECT: { - dummy.emplaceBack(STObject{f}); - BEAST_EXPECT(dummy.getField(f).getFName() == f); - BEAST_EXPECT(dummy.isFieldPresent(f)); - break; - } - - case STI_ARRAY: { - STArray dummy2{f, 2}; - dummy2.pushBack(STObject{sfGeneric}); - dummy2.pushBack(STObject{sfGeneric}); - dummy.setFieldArray(f, dummy2); - BEAST_EXPECT(dummy.getFieldArray(f) == dummy2); - BEAST_EXPECT(dummy.isFieldPresent(f)); - break; - } - - default: - BEAST_EXPECT(false); - } - } - } - -public: - void - run() override - { - using namespace test::jtx; - testHookFields(); - } -}; - -BEAST_DEFINE_TESTSUITE(Hooks, protocol, xrpl); - -} // namespace xrpl diff --git a/src/xrpld/rpc/handlers/account/AccountInfo.cpp b/src/xrpld/rpc/handlers/account/AccountInfo.cpp index d4232cf451..f131af01e5 100644 --- a/src/xrpld/rpc/handlers/account/AccountInfo.cpp +++ b/src/xrpld/rpc/handlers/account/AccountInfo.cpp @@ -51,22 +51,16 @@ void injectSLE(json::Value& jv, SLE const& sle) { jv = sle.getJson(JsonOptions::Values::None); - if (sle.getType() == ltACCOUNT_ROOT) + XRPL_ASSERT(sle.getType() == ltACCOUNT_ROOT, "xrpl::injectSLE : sle is account root"); + if (sle.isFieldPresent(sfEmailHash)) { - if (sle.isFieldPresent(sfEmailHash)) - { - auto const& hash = sle.getFieldH128(sfEmailHash); - Blob const b(hash.begin(), hash.end()); - std::string md5 = strHex(makeSlice(b)); - boost::to_lower(md5); - // VFALCO TODO Give a name to this constant and move it - // to a more visible location. - jv[jss::urlgravatar] = str(boost::format("https://www.gravatar.com/avatar/%s") % md5); - } - } - else - { - jv[jss::Invalid] = true; + auto const& hash = sle.getFieldH128(sfEmailHash); + Blob const b(hash.begin(), hash.end()); + std::string md5 = strHex(makeSlice(b)); + boost::to_lower(md5); + // VFALCO TODO Give a name to this constant and move it + // to a more visible location. + jv[jss::urlgravatar] = str(boost::format("https://www.gravatar.com/avatar/%s") % md5); } } From 07aa97fda4aae2f999708a8a39f3a7d62c07221e Mon Sep 17 00:00:00 2001 From: Mayukha Vadari Date: Mon, 10 Aug 2026 13:22:40 -0400 Subject: [PATCH 054/102] test: Use std::string::starts_with/ends_with instead of Boost (#7992) --- src/test/core/SociDB_test.cpp | 3 +-- src/test/jtx/TrustedPublisherServer.h | 33 ++++++++++++--------------- src/test/rpc/NoRippleCheck_test.cpp | 10 ++++---- src/test/server/ServerStatus_test.cpp | 13 +++++------ 4 files changed, 26 insertions(+), 33 deletions(-) diff --git a/src/test/core/SociDB_test.cpp b/src/test/core/SociDB_test.cpp index 373ec66cd1..7a57641b64 100644 --- a/src/test/core/SociDB_test.cpp +++ b/src/test/core/SociDB_test.cpp @@ -6,7 +6,6 @@ #include #include -#include #include #include #include // IWYU pragma: keep @@ -108,7 +107,7 @@ public: for (auto const& i : d) { DBConfig const sc(c, i.first); - BEAST_EXPECT(boost::ends_with(sc.connectionString(), i.first + i.second)); + BEAST_EXPECT(sc.connectionString().ends_with(i.first + i.second)); } } void diff --git a/src/test/jtx/TrustedPublisherServer.h b/src/test/jtx/TrustedPublisherServer.h index f5ee8aac3a..941af374ef 100644 --- a/src/test/jtx/TrustedPublisherServer.h +++ b/src/test/jtx/TrustedPublisherServer.h @@ -16,7 +16,6 @@ #include #include -#include #include #include #include @@ -549,7 +548,7 @@ private: res.keep_alive(req.keep_alive()); bool prepare = true; - if (boost::starts_with(path, "/validators2")) + if (path.starts_with("/validators2")) { res.result(http::status::ok); res.insert("Content-Type", "application/json"); @@ -565,7 +564,7 @@ private: { int refresh = 5; static constexpr char const* kRefreshPrefix = "/validators2/refresh/"; - if (boost::starts_with(path, kRefreshPrefix)) + if (path.starts_with(kRefreshPrefix)) { refresh = boost::lexical_cast( path.substr(strlen(kRefreshPrefix))); @@ -573,7 +572,7 @@ private: res.body() = getList2_(refresh); } } - else if (boost::starts_with(path, "/validators")) + else if (path.starts_with("/validators")) { res.result(http::status::ok); res.insert("Content-Type", "application/json"); @@ -589,7 +588,7 @@ private: { int refresh = 5; static constexpr char const* kRefreshPrefix = "/validators/refresh/"; - if (boost::starts_with(path, kRefreshPrefix)) + if (path.starts_with(kRefreshPrefix)) { refresh = boost::lexical_cast( path.substr(strlen(kRefreshPrefix))); @@ -597,13 +596,13 @@ private: res.body() = getList_(refresh); } } - else if (boost::starts_with(path, "/textfile")) + else if (path.starts_with("/textfile")) { prepare = false; res.result(http::status::ok); res.insert("Content-Type", "text/example"); // if huge was requested, lie about content length - std::uint64_t const cl = boost::starts_with(path, "/textfile/huge") + std::uint64_t const cl = path.starts_with("/textfile/huge") ? std::numeric_limits::max() : 1024; res.content_length(cl); @@ -617,41 +616,39 @@ private: } } } - else if (boost::starts_with(path, "/sleep/")) + else if (path.starts_with("/sleep/")) { auto const sleepSec = boost::lexical_cast(path.substr(7)); std::this_thread::sleep_for(std::chrono::seconds(sleepSec)); } - else if (boost::starts_with(path, "/redirect")) + else if (path.starts_with("/redirect")) { - if (boost::ends_with(path, "/301")) + if (path.ends_with("/301")) { res.result(http::status::moved_permanently); } - else if (boost::ends_with(path, "/302")) + else if (path.ends_with("/302")) { res.result(http::status::found); } - else if (boost::ends_with(path, "/307")) + else if (path.ends_with("/307")) { res.result(http::status::temporary_redirect); } - else if (boost::ends_with(path, "/308")) + else if (path.ends_with("/308")) { res.result(http::status::permanent_redirect); } std::stringstream location; - if (boost::starts_with(path, "/redirect_to/")) + if (path.starts_with("/redirect_to/")) { location << path.substr(13); } - else if (!boost::starts_with(path, "/redirect_nolo")) + else if (!path.starts_with("/redirect_nolo")) { location << (ssl ? "https://" : "http://") << localEndpoint() - << (boost::starts_with(path, "/redirect_forever/") - ? path - : "/validators"); + << (path.starts_with("/redirect_forever/") ? path : "/validators"); } if (!location.str().empty()) res.insert("Location", location.str()); diff --git a/src/test/rpc/NoRippleCheck_test.cpp b/src/test/rpc/NoRippleCheck_test.cpp index 6e30f944c7..8e719e6407 100644 --- a/src/test/rpc/NoRippleCheck_test.cpp +++ b/src/test/rpc/NoRippleCheck_test.cpp @@ -27,8 +27,6 @@ #include #include -#include - #include #include @@ -203,13 +201,13 @@ class NoRippleCheck_test : public beast::unit_test::Suite if (user) { - BEAST_EXPECT(boost::starts_with(pa[0u].asString(), "You appear to have set")); - BEAST_EXPECT(boost::starts_with(pa[1u].asString(), "You should probably set")); + BEAST_EXPECT(pa[0u].asString().starts_with("You appear to have set")); + BEAST_EXPECT(pa[1u].asString().starts_with("You should probably set")); } else { - BEAST_EXPECT(boost::starts_with(pa[0u].asString(), "You should immediately set")); - BEAST_EXPECT(boost::starts_with(pa[1u].asString(), "You should clear")); + BEAST_EXPECT(pa[0u].asString().starts_with("You should immediately set")); + BEAST_EXPECT(pa[1u].asString().starts_with("You should clear")); } } else diff --git a/src/test/server/ServerStatus_test.cpp b/src/test/server/ServerStatus_test.cpp index 5adf6a08f5..f1989ed171 100644 --- a/src/test/server/ServerStatus_test.cpp +++ b/src/test/server/ServerStatus_test.cpp @@ -56,8 +56,7 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En static auto makeConfig(std::string const& proto, bool admin = true, bool credentials = false) { - auto const sectionName = - boost::starts_with(proto, "h") ? Sections::kPortRpc : Sections::kPortWs; + auto const sectionName = proto.starts_with("h") ? Sections::kPortRpc : Sections::kPortWs; auto p = jtx::envconfig(); p->overwrite(sectionName, Keys::kProtocol, proto); @@ -71,9 +70,9 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En } p->overwrite( - boost::starts_with(proto, "h") ? Sections::kPortWs : Sections::kPortRpc, + proto.starts_with("h") ? Sections::kPortWs : Sections::kPortRpc, Keys::kProtocol, - boost::starts_with(proto, "h") ? "ws" : "http"); + proto.starts_with("h") ? "ws" : "http"); if (proto == "https") { @@ -261,7 +260,7 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En } } - if (boost::starts_with(proto, "h")) + if (proto.starts_with("h")) { auto jrc = makeJSONRPCClient(env.app().config()); jrr = jrc->invoke("ledger_accept", jp); @@ -289,7 +288,7 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En Env env{*this, makeConfig(proto, admin, credentials)}; json::Value jrr; - auto const protoWs = boost::starts_with(proto, "w"); + auto const protoWs = proto.starts_with("w"); // the set of checks we do are different depending // on how the admin config options are set @@ -485,7 +484,7 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En boost::beast::http::response resp; boost::system::error_code ec; - if (boost::starts_with(clientProtocol, "h")) + if (clientProtocol.starts_with("h")) { doHTTPRequest(env, yield, clientProtocol == "https", resp, ec); BEAST_EXPECT(ec); From a0e1e578a0a3977f4466d0ff274613088022cc29 Mon Sep 17 00:00:00 2001 From: Mayukha Vadari Date: Mon, 10 Aug 2026 13:23:02 -0400 Subject: [PATCH 055/102] refactor: Remove operator!= overloads that C++20 synthesizes (#7994) --- include/xrpl/basics/Buffer.h | 6 --- include/xrpl/basics/IntrusivePointer.h | 3 -- include/xrpl/basics/IntrusivePointer.ipp | 7 --- include/xrpl/basics/Number.h | 6 --- include/xrpl/basics/SHAMapHash.h | 6 --- include/xrpl/basics/Slice.h | 6 --- .../xrpl/basics/partitioned_unordered_map.h | 12 ----- .../container/detail/aged_ordered_container.h | 19 -------- .../detail/aged_unordered_container.h | 22 ---------- include/xrpl/beast/core/List.h | 7 --- include/xrpl/beast/net/IPEndpoint.h | 6 --- include/xrpl/beast/rfc2616.h | 6 --- include/xrpl/conditions/Condition.h | 6 --- include/xrpl/conditions/Fulfillment.h | 6 --- include/xrpl/json/json_value.h | 30 ------------- include/xrpl/ledger/BookDirs.h | 6 --- include/xrpl/ledger/CanonicalTXSet.h | 6 --- include/xrpl/ledger/Dir.h | 6 --- include/xrpl/ledger/detail/ReadViewFwdRange.h | 3 -- .../xrpl/ledger/detail/ReadViewFwdRange.ipp | 7 --- include/xrpl/protocol/Quality.h | 13 ------ include/xrpl/protocol/Rules.h | 3 -- include/xrpl/protocol/STAmount.h | 6 --- include/xrpl/protocol/STArray.h | 9 ---- include/xrpl/protocol/STBase.h | 2 - include/xrpl/protocol/STCurrency.h | 6 --- include/xrpl/protocol/STObject.h | 38 ---------------- include/xrpl/protocol/STPathSet.h | 9 ---- include/xrpl/protocol/SeqProxy.h | 6 --- include/xrpl/protocol/Serializer.h | 10 ----- include/xrpl/protocol/Units.h | 7 --- include/xrpl/protocol/detail/STVar.h | 6 --- include/xrpl/server/Manifest.h | 6 --- include/xrpl/shamap/SHAMap.h | 6 --- include/xrpl/shamap/SHAMapNodeID.h | 44 +++++++------------ include/xrpl/tx/paths/detail/Steps.h | 13 ------ src/libxrpl/protocol/Rules.cpp | 6 --- src/libxrpl/protocol/STBase.cpp | 6 --- src/test/jtx/amount.h | 6 --- 39 files changed, 16 insertions(+), 362 deletions(-) diff --git a/include/xrpl/basics/Buffer.h b/include/xrpl/basics/Buffer.h index 05af6c409a..705a5ef51a 100644 --- a/include/xrpl/basics/Buffer.h +++ b/include/xrpl/basics/Buffer.h @@ -226,10 +226,4 @@ operator==(Buffer const& lhs, Buffer const& rhs) noexcept return std::memcmp(lhs.data(), rhs.data(), lhs.size()) == 0; } -inline bool -operator!=(Buffer const& lhs, Buffer const& rhs) noexcept -{ - return !(lhs == rhs); -} - } // namespace xrpl diff --git a/include/xrpl/basics/IntrusivePointer.h b/include/xrpl/basics/IntrusivePointer.h index 59853ad4d0..b978016860 100644 --- a/include/xrpl/basics/IntrusivePointer.h +++ b/include/xrpl/basics/IntrusivePointer.h @@ -96,9 +96,6 @@ public: SharedIntrusive& operator=(SharedIntrusive const& rhs); - bool - operator!=(std::nullptr_t) const; - bool operator==(std::nullptr_t) const; diff --git a/include/xrpl/basics/IntrusivePointer.ipp b/include/xrpl/basics/IntrusivePointer.ipp index 67d43b05d6..6c2a71f7eb 100644 --- a/include/xrpl/basics/IntrusivePointer.ipp +++ b/include/xrpl/basics/IntrusivePointer.ipp @@ -111,13 +111,6 @@ SharedIntrusive::operator=(SharedIntrusive&& rhs) return *this; } -template -bool -SharedIntrusive::operator!=(std::nullptr_t) const -{ - return this->get() != nullptr; -} - template bool SharedIntrusive::operator==(std::nullptr_t) const diff --git a/include/xrpl/basics/Number.h b/include/xrpl/basics/Number.h index ec75724b5d..f6ce0b300d 100644 --- a/include/xrpl/basics/Number.h +++ b/include/xrpl/basics/Number.h @@ -449,12 +449,6 @@ public: x.exponent_ == y.exponent_; } - friend constexpr bool - operator!=(Number const& x, Number const& y) noexcept - { - return !(x == y); - } - friend constexpr bool operator<(Number const& l, Number const& r) noexcept { diff --git a/include/xrpl/basics/SHAMapHash.h b/include/xrpl/basics/SHAMapHash.h index 3c3d525022..1902a3b2ec 100644 --- a/include/xrpl/basics/SHAMapHash.h +++ b/include/xrpl/basics/SHAMapHash.h @@ -85,12 +85,6 @@ public: } }; -inline bool -operator!=(SHAMapHash const& x, SHAMapHash const& y) -{ - return !(x == y); -} - template <> inline std::size_t extract(SHAMapHash const& key) diff --git a/include/xrpl/basics/Slice.h b/include/xrpl/basics/Slice.h index 75c9b8c7bd..92b777ab98 100644 --- a/include/xrpl/basics/Slice.h +++ b/include/xrpl/basics/Slice.h @@ -208,12 +208,6 @@ operator==(Slice const& lhs, Slice const& rhs) noexcept return std::memcmp(lhs.data(), rhs.data(), lhs.size()) == 0; } -inline bool -operator!=(Slice const& lhs, Slice const& rhs) noexcept -{ - return !(lhs == rhs); -} - inline bool operator<(Slice const& lhs, Slice const& rhs) noexcept { diff --git a/include/xrpl/basics/partitioned_unordered_map.h b/include/xrpl/basics/partitioned_unordered_map.h index e78043e252..c6b0107b93 100644 --- a/include/xrpl/basics/partitioned_unordered_map.h +++ b/include/xrpl/basics/partitioned_unordered_map.h @@ -116,12 +116,6 @@ public: { return lhs.map == rhs.map && lhs.ait == rhs.ait && lhs.mit == rhs.mit; } - - friend bool - operator!=(Iterator const& lhs, Iterator const& rhs) - { - return !(lhs == rhs); - } }; struct ConstIterator @@ -189,12 +183,6 @@ public: { return lhs.map == rhs.map && lhs.ait == rhs.ait && lhs.mit == rhs.mit; } - - friend bool - operator!=(ConstIterator const& lhs, ConstIterator const& rhs) - { - return !(lhs == rhs); - } }; private: diff --git a/include/xrpl/beast/container/detail/aged_ordered_container.h b/include/xrpl/beast/container/detail/aged_ordered_container.h index 5b60ef7e6d..9dd83d466b 100644 --- a/include/xrpl/beast/container/detail/aged_ordered_container.h +++ b/include/xrpl/beast/container/detail/aged_ordered_container.h @@ -1038,25 +1038,6 @@ public: Compare, OtherAllocator> const& other) const; - template < - bool OtherIsMulti, - bool OtherIsMap, - class OtherT, - class OtherDuration, - class OtherAllocator> - bool - operator!=(AgedOrderedContainer< - OtherIsMulti, - OtherIsMap, - Key, - OtherT, - OtherDuration, - Compare, - OtherAllocator> const& other) const - { - return !(this->operator==(other)); - } - template < bool OtherIsMulti, bool OtherIsMap, diff --git a/include/xrpl/beast/container/detail/aged_unordered_container.h b/include/xrpl/beast/container/detail/aged_unordered_container.h index c4287b1ca1..ea271feed0 100644 --- a/include/xrpl/beast/container/detail/aged_unordered_container.h +++ b/include/xrpl/beast/container/detail/aged_unordered_container.h @@ -1340,28 +1340,6 @@ public: OtherAllocator> const& other) const requires MaybeMulti; - template < - bool OtherIsMulti, - bool OtherIsMap, - class OtherKey, - class OtherT, - class OtherDuration, - class OtherHash, - class OtherAllocator> - bool - operator!=(AgedUnorderedContainer< - OtherIsMulti, - OtherIsMap, - OtherKey, - OtherT, - OtherDuration, - OtherHash, - KeyEqual, - OtherAllocator> const& other) const - { - return !(this->operator==(other)); - } - private: bool wouldExceed(size_type additional) const diff --git a/include/xrpl/beast/core/List.h b/include/xrpl/beast/core/List.h index b9b6829d31..076ac3028b 100644 --- a/include/xrpl/beast/core/List.h +++ b/include/xrpl/beast/core/List.h @@ -82,13 +82,6 @@ public: return node_ == other.node_; } - template - bool - operator!=(ListIterator const& other) const noexcept - { - return !((*this) == other); - } - reference operator*() const noexcept { diff --git a/include/xrpl/beast/net/IPEndpoint.h b/include/xrpl/beast/net/IPEndpoint.h index d4d3b2ab12..a5fb5b4318 100644 --- a/include/xrpl/beast/net/IPEndpoint.h +++ b/include/xrpl/beast/net/IPEndpoint.h @@ -110,12 +110,6 @@ public: operator==(Endpoint const& lhs, Endpoint const& rhs); friend bool operator<(Endpoint const& lhs, Endpoint const& rhs); - - friend bool - operator!=(Endpoint const& lhs, Endpoint const& rhs) - { - return !(lhs == rhs); - } friend bool operator>(Endpoint const& lhs, Endpoint const& rhs) { diff --git a/include/xrpl/beast/rfc2616.h b/include/xrpl/beast/rfc2616.h index 1986568553..0e061845fb 100644 --- a/include/xrpl/beast/rfc2616.h +++ b/include/xrpl/beast/rfc2616.h @@ -229,12 +229,6 @@ public: return other.it_ == it_ && other.end_ == end_ && other.value_.size() == value_.size(); } - bool - operator!=(ListIterator const& other) const - { - return !(*this == other); - } - reference operator*() const { diff --git a/include/xrpl/conditions/Condition.h b/include/xrpl/conditions/Condition.h index 365a41a087..04e571a028 100644 --- a/include/xrpl/conditions/Condition.h +++ b/include/xrpl/conditions/Condition.h @@ -92,10 +92,4 @@ operator==(Condition const& lhs, Condition const& rhs) lhs.fingerprint == rhs.fingerprint; } -inline bool -operator!=(Condition const& lhs, Condition const& rhs) -{ - return !(lhs == rhs); -} - } // namespace xrpl::cryptoconditions diff --git a/include/xrpl/conditions/Fulfillment.h b/include/xrpl/conditions/Fulfillment.h index 11f3165a58..6fd75aa5a3 100644 --- a/include/xrpl/conditions/Fulfillment.h +++ b/include/xrpl/conditions/Fulfillment.h @@ -93,12 +93,6 @@ operator==(Fulfillment const& lhs, Fulfillment const& rhs) lhs.fingerprint() == rhs.fingerprint(); } -inline bool -operator!=(Fulfillment const& lhs, Fulfillment const& rhs) -{ - return !(lhs == rhs); -} - /** * Determine whether the given fulfillment and condition match */ diff --git a/include/xrpl/json/json_value.h b/include/xrpl/json/json_value.h index 260917face..be126d8b8e 100644 --- a/include/xrpl/json/json_value.h +++ b/include/xrpl/json/json_value.h @@ -72,36 +72,18 @@ operator==(StaticString x, StaticString y) return strcmp(x.cStr(), y.cStr()) == 0; } -inline bool -operator!=(StaticString x, StaticString y) -{ - return !(x == y); -} - inline bool operator==(std::string const& x, StaticString y) { return strcmp(x.c_str(), y.cStr()) == 0; } -inline bool -operator!=(std::string const& x, StaticString y) -{ - return !(x == y); -} - inline bool operator==(StaticString x, std::string const& y) { return y == x; } -inline bool -operator!=(StaticString x, std::string const& y) -{ - return !(y == x); -} - /** * @brief Represents a JSON value. * @@ -489,12 +471,6 @@ toJson(xrpl::Number const& number) bool operator==(Value const&, Value const&); -inline bool -operator!=(Value const& x, Value const& y) -{ - return !(x == y); -} - bool operator<(Value const&, Value const&); @@ -562,12 +538,6 @@ public: return isEqual(other); } - bool - operator!=(SelfType const& other) const - { - return !isEqual(other); - } - /** * Return either the index or the member name of the referenced value as a * Value. diff --git a/include/xrpl/ledger/BookDirs.h b/include/xrpl/ledger/BookDirs.h index dc4361136d..b9aa87ae52 100644 --- a/include/xrpl/ledger/BookDirs.h +++ b/include/xrpl/ledger/BookDirs.h @@ -49,12 +49,6 @@ public: bool operator==(const_iterator const& other) const; - bool - operator!=(const_iterator const& other) const - { - return !(*this == other); - } - reference operator*() const; diff --git a/include/xrpl/ledger/CanonicalTXSet.h b/include/xrpl/ledger/CanonicalTXSet.h index 11aadf4e92..3fe17d6eef 100644 --- a/include/xrpl/ledger/CanonicalTXSet.h +++ b/include/xrpl/ledger/CanonicalTXSet.h @@ -59,12 +59,6 @@ private: return lhs.txId_ == rhs.txId_; } - friend bool - operator!=(Key const& lhs, Key const& rhs) - { - return !(lhs == rhs); - } - [[nodiscard]] uint256 const& getAccount() const { diff --git a/include/xrpl/ledger/Dir.h b/include/xrpl/ledger/Dir.h index 233719cdeb..eb70b3b6a3 100644 --- a/include/xrpl/ledger/Dir.h +++ b/include/xrpl/ledger/Dir.h @@ -59,12 +59,6 @@ public: bool operator==(ConstIterator const& other) const; - bool - operator!=(ConstIterator const& other) const - { - return !(*this == other); - } - reference operator*() const; diff --git a/include/xrpl/ledger/detail/ReadViewFwdRange.h b/include/xrpl/ledger/detail/ReadViewFwdRange.h index 19ac0698c2..bfa2527bbd 100644 --- a/include/xrpl/ledger/detail/ReadViewFwdRange.h +++ b/include/xrpl/ledger/detail/ReadViewFwdRange.h @@ -85,9 +85,6 @@ public: bool operator==(Iterator const& other) const; - bool - operator!=(Iterator const& other) const; - // Can throw reference operator*() const; diff --git a/include/xrpl/ledger/detail/ReadViewFwdRange.ipp b/include/xrpl/ledger/detail/ReadViewFwdRange.ipp index c7cbc5ee61..2003280ea6 100644 --- a/include/xrpl/ledger/detail/ReadViewFwdRange.ipp +++ b/include/xrpl/ledger/detail/ReadViewFwdRange.ipp @@ -64,13 +64,6 @@ ReadViewFwdRange::Iterator::operator==(Iterator const& other) const return impl_ == other.impl_; } -template -bool -ReadViewFwdRange::Iterator::operator!=(Iterator const& other) const -{ - return !(*this == other); -} - template auto ReadViewFwdRange::Iterator::operator*() const -> reference diff --git a/include/xrpl/protocol/Quality.h b/include/xrpl/protocol/Quality.h index 3475efa977..d0d0f10cd2 100644 --- a/include/xrpl/protocol/Quality.h +++ b/include/xrpl/protocol/Quality.h @@ -75,13 +75,6 @@ operator==(TAmounts const& lhs, TAmounts const& rhs) noexcept return lhs.in == rhs.in && lhs.out == rhs.out; } -template -bool -operator!=(TAmounts const& lhs, TAmounts const& rhs) noexcept -{ - return !(lhs == rhs); -} - //------------------------------------------------------------------------------ // XRPL specific constant used for parsing qualities and other things @@ -271,12 +264,6 @@ public: return lhs.value_ == rhs.value_; } - friend bool - operator!=(Quality const& lhs, Quality const& rhs) noexcept - { - return !(lhs == rhs); - } - friend std::ostream& operator<<(std::ostream& os, Quality const& quality) { diff --git a/include/xrpl/protocol/Rules.h b/include/xrpl/protocol/Rules.h index 2c2136b6e8..d67e0d8654 100644 --- a/include/xrpl/protocol/Rules.h +++ b/include/xrpl/protocol/Rules.h @@ -98,9 +98,6 @@ public: */ bool operator==(Rules const&) const; - - bool - operator!=(Rules const& other) const; }; std::optional const& diff --git a/include/xrpl/protocol/STAmount.h b/include/xrpl/protocol/STAmount.h index cc80481582..4b2f1cc9fb 100644 --- a/include/xrpl/protocol/STAmount.h +++ b/include/xrpl/protocol/STAmount.h @@ -642,12 +642,6 @@ operator==(STAmount const& lhs, STAmount const& rhs); bool operator<(STAmount const& lhs, STAmount const& rhs); -inline bool -operator!=(STAmount const& lhs, STAmount const& rhs) -{ - return !(lhs == rhs); -} - inline bool operator>(STAmount const& lhs, STAmount const& rhs) { diff --git a/include/xrpl/protocol/STArray.h b/include/xrpl/protocol/STArray.h index 573bb6dad8..e88563fb1a 100644 --- a/include/xrpl/protocol/STArray.h +++ b/include/xrpl/protocol/STArray.h @@ -133,9 +133,6 @@ public: bool operator==(STArray const& s) const; - bool - operator!=(STArray const& s) const; - iterator erase(iterator pos); @@ -283,12 +280,6 @@ STArray::operator==(STArray const& s) const return v_ == s.v_; } -inline bool -STArray::operator!=(STArray const& s) const -{ - return v_ != s.v_; -} - inline STArray::iterator STArray::erase(iterator pos) { diff --git a/include/xrpl/protocol/STBase.h b/include/xrpl/protocol/STBase.h index acc5500a57..a8bda8f614 100644 --- a/include/xrpl/protocol/STBase.h +++ b/include/xrpl/protocol/STBase.h @@ -140,8 +140,6 @@ public: bool operator==(STBase const& t) const; - bool - operator!=(STBase const& t) const; template D& diff --git a/include/xrpl/protocol/STCurrency.h b/include/xrpl/protocol/STCurrency.h index 18642b20cf..933abaedb8 100644 --- a/include/xrpl/protocol/STCurrency.h +++ b/include/xrpl/protocol/STCurrency.h @@ -93,12 +93,6 @@ operator==(STCurrency const& lhs, STCurrency const& rhs) return lhs.currency() == rhs.currency(); } -inline bool -operator!=(STCurrency const& lhs, STCurrency const& rhs) -{ - return !operator==(lhs, rhs); -} - inline bool operator<(STCurrency const& lhs, STCurrency const& rhs) { diff --git a/include/xrpl/protocol/STObject.h b/include/xrpl/protocol/STObject.h index c7fc4fa796..dcbd08170e 100644 --- a/include/xrpl/protocol/STObject.h +++ b/include/xrpl/protocol/STObject.h @@ -432,8 +432,6 @@ public: bool operator==(STObject const& o) const; - bool - operator!=(STObject const& o) const; class FieldErr; @@ -667,36 +665,6 @@ public: return !lhs.engaged() || *lhs == *rhs; } - friend bool - operator!=(OptionalProxy const& lhs, std::nullopt_t) noexcept - { - return !(lhs == std::nullopt); - } - - friend bool - operator!=(std::nullopt_t, OptionalProxy const& rhs) noexcept - { - return !(rhs == std::nullopt); - } - - friend bool - operator!=(OptionalProxy const& lhs, optional_type const& rhs) noexcept - { - return !(lhs == rhs); - } - - friend bool - operator!=(optional_type const& lhs, OptionalProxy const& rhs) noexcept - { - return !(lhs == rhs); - } - - friend bool - operator!=(OptionalProxy const& lhs, OptionalProxy const& rhs) noexcept - { - return !(lhs == rhs); - } - // Emulate std::optional::value_or [[nodiscard]] value_type valueOr(value_type val) const; @@ -1202,12 +1170,6 @@ STObject::setFieldH160(SField const& field, BaseUInt<160, Tag> const& v) } } -inline bool -STObject::operator!=(STObject const& o) const -{ - return !(*this == o); -} - template V STObject::getFieldByValue(SField const& field) const diff --git a/include/xrpl/protocol/STPathSet.h b/include/xrpl/protocol/STPathSet.h index d527e2479f..5768721111 100644 --- a/include/xrpl/protocol/STPathSet.h +++ b/include/xrpl/protocol/STPathSet.h @@ -115,9 +115,6 @@ public: bool operator==(STPathElement const& t) const; - bool - operator!=(STPathElement const& t) const; - private: static std::size_t getHash(STPathElement const& element); @@ -432,12 +429,6 @@ STPathElement::operator==(STPathElement const& t) const accountID_ == t.accountID_ && assetID_ == t.assetID_ && issuerID_ == t.issuerID_; } -inline bool -STPathElement::operator!=(STPathElement const& t) const -{ - return !operator==(t); -} - // ------------ STPath ------------ inline STPath::STPath(std::vector p) : path_(std::move(p)) diff --git a/include/xrpl/protocol/SeqProxy.h b/include/xrpl/protocol/SeqProxy.h index fa72914591..3686d123d6 100644 --- a/include/xrpl/protocol/SeqProxy.h +++ b/include/xrpl/protocol/SeqProxy.h @@ -123,12 +123,6 @@ public: return (lhs.value() == rhs.value()); } - friend constexpr bool - operator!=(SeqProxy lhs, SeqProxy rhs) - { - return !(lhs == rhs); - } - friend constexpr bool operator<(SeqProxy lhs, SeqProxy rhs) { diff --git a/include/xrpl/protocol/Serializer.h b/include/xrpl/protocol/Serializer.h index 73bd9c8289..c1ea5c16ba 100644 --- a/include/xrpl/protocol/Serializer.h +++ b/include/xrpl/protocol/Serializer.h @@ -265,20 +265,10 @@ public: return v == data_; } bool - operator!=(Blob const& v) const - { - return v != data_; - } - bool operator==(Serializer const& v) const { return v.data_ == data_; } - bool - operator!=(Serializer const& v) const - { - return v.data_ != data_; - } static int decodeLengthLength(int b1); diff --git a/include/xrpl/protocol/Units.h b/include/xrpl/protocol/Units.h index 169ee2c543..94afd72f53 100644 --- a/include/xrpl/protocol/Units.h +++ b/include/xrpl/protocol/Units.h @@ -258,13 +258,6 @@ public: return value_ == other; } - template Other> - constexpr bool - operator!=(ValueUnit const& other) const - { - return !operator==(other); - } - constexpr bool operator<(ValueUnit const& other) const { diff --git a/include/xrpl/protocol/detail/STVar.h b/include/xrpl/protocol/detail/STVar.h index 12026f3d09..56f868b665 100644 --- a/include/xrpl/protocol/detail/STVar.h +++ b/include/xrpl/protocol/detail/STVar.h @@ -152,10 +152,4 @@ operator==(STVar const& lhs, STVar const& rhs) return lhs.get().isEquivalent(rhs.get()); } -inline bool -operator!=(STVar const& lhs, STVar const& rhs) -{ - return !(lhs == rhs); -} - } // namespace xrpl::detail diff --git a/include/xrpl/server/Manifest.h b/include/xrpl/server/Manifest.h index 786967b057..1b726f2c0c 100644 --- a/include/xrpl/server/Manifest.h +++ b/include/xrpl/server/Manifest.h @@ -306,12 +306,6 @@ operator==(Manifest const& lhs, Manifest const& rhs) lhs.serialized == rhs.serialized; } -inline bool -operator!=(Manifest const& lhs, Manifest const& rhs) -{ - return !(lhs == rhs); -} - struct ValidatorToken { std::string manifest; diff --git a/include/xrpl/shamap/SHAMap.h b/include/xrpl/shamap/SHAMap.h index e198c472fa..97ab2e9f7a 100644 --- a/include/xrpl/shamap/SHAMap.h +++ b/include/xrpl/shamap/SHAMap.h @@ -789,12 +789,6 @@ operator==(SHAMap::ConstIterator const& x, SHAMap::ConstIterator const& y) return x.item_ == y.item_; } -inline bool -operator!=(SHAMap::ConstIterator const& x, SHAMap::ConstIterator const& y) -{ - return !(x == y); -} - inline SHAMap::ConstIterator SHAMap::begin() const { diff --git a/include/xrpl/shamap/SHAMapNodeID.h b/include/xrpl/shamap/SHAMapNodeID.h index 6094892091..1189304aa7 100644 --- a/include/xrpl/shamap/SHAMapNodeID.h +++ b/include/xrpl/shamap/SHAMapNodeID.h @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -65,45 +66,32 @@ public: static SHAMapNodeID createID(int depth, uint256 const& key); - // FIXME-C++20: use spaceship and operator synthesis /** * Comparison operators + * + * <, >, <= and >= are synthesized from the spaceship. It is written out + * rather than defaulted because the ordering is by depth first, and the + * members are not declared in that order. */ - bool - operator<(SHAMapNodeID const& n) const + std::strong_ordering + operator<=>(SHAMapNodeID const& n) const { - return std::tie(depth_, id_) < std::tie(n.depth_, n.id_); - } - - bool - operator>(SHAMapNodeID const& n) const - { - return n < *this; - } - - bool - operator<=(SHAMapNodeID const& n) const - { - return !(n < *this); - } - - bool - operator>=(SHAMapNodeID const& n) const - { - return !(*this < n); + return std::tie(depth_, id_) <=> std::tie(n.depth_, n.id_); } + /** + * Equality, which the spaceship above does not provide. + * + * Only a *defaulted* operator<=> implicitly declares a defaulted + * operator==; the one above is user-provided, so == has to be written. + * It cannot be defaulted either, because a defaulted == would also compare + * the CountedObject base, which is not equality comparable. + */ bool operator==(SHAMapNodeID const& n) const { return (depth_ == n.depth_) && (id_ == n.id_); } - - bool - operator!=(SHAMapNodeID const& n) const - { - return !(*this == n); - } }; inline std::string diff --git a/include/xrpl/tx/paths/detail/Steps.h b/include/xrpl/tx/paths/detail/Steps.h index 8ee37c026c..1d68860adc 100644 --- a/include/xrpl/tx/paths/detail/Steps.h +++ b/include/xrpl/tx/paths/detail/Steps.h @@ -274,19 +274,6 @@ public: return lhs.equal(rhs); } - /** - * Return true if lhs != rhs. - * - * @param lhs Step to compare. - * @param rhs Step to compare. - * @return true if lhs != rhs. - */ - friend bool - operator!=(Step const& lhs, Step const& rhs) - { - return !(lhs == rhs); - } - /** * Streaming operator for a Step. */ diff --git a/src/libxrpl/protocol/Rules.cpp b/src/libxrpl/protocol/Rules.cpp index 197139027a..cb71133d8f 100644 --- a/src/libxrpl/protocol/Rules.cpp +++ b/src/libxrpl/protocol/Rules.cpp @@ -193,12 +193,6 @@ Rules::operator==(Rules const& other) const return *impl_ == *other.impl_; } -bool -Rules::operator!=(Rules const& other) const -{ - return !(*this == other); -} - bool isFeatureEnabled(uint256 const& feature, bool resultIfNoRules) { diff --git a/src/libxrpl/protocol/STBase.cpp b/src/libxrpl/protocol/STBase.cpp index f029f10e75..1e56897e30 100644 --- a/src/libxrpl/protocol/STBase.cpp +++ b/src/libxrpl/protocol/STBase.cpp @@ -38,12 +38,6 @@ STBase::operator==(STBase const& t) const return (getSType() == t.getSType()) && isEquivalent(t); } -bool -STBase::operator!=(STBase const& t) const -{ - return (getSType() != t.getSType()) || !isEquivalent(t); -} - STBase* STBase::copy(std::size_t n, void* buf) const { diff --git a/src/test/jtx/amount.h b/src/test/jtx/amount.h index 57a4502db9..94dd8aef9e 100644 --- a/src/test/jtx/amount.h +++ b/src/test/jtx/amount.h @@ -162,12 +162,6 @@ operator==(PrettyAmount const& lhs, PrettyAmount const& rhs) return lhs.value() == rhs.value(); } -inline bool -operator!=(PrettyAmount const& lhs, PrettyAmount const& rhs) -{ - return !operator==(lhs, rhs); -} - std::ostream& operator<<(std::ostream& os, PrettyAmount const& amount); From b19c3c64f24ae2e7f7e40b5ca0cbf5e94f44b803 Mon Sep 17 00:00:00 2001 From: yinyiqian1 Date: Mon, 10 Aug 2026 13:47:16 -0400 Subject: [PATCH 056/102] fix: Add zero keylet check in credential (#7971) --- .../xrpl/ledger/helpers/CredentialHelpers.h | 3 +- .../ledger/helpers/CredentialHelpers.cpp | 24 ++++++++++- .../tx/transactors/account/AccountDelete.cpp | 2 +- .../tx/transactors/escrow/EscrowFinish.cpp | 2 +- .../tx/transactors/payment/Payment.cpp | 2 +- .../payment_channel/PaymentChannelClaim.cpp | 2 +- .../transactors/token/ConfidentialMPTSend.cpp | 2 +- src/test/app/DepositAuth_test.cpp | 41 +++++++++++++++++++ 8 files changed, 71 insertions(+), 7 deletions(-) diff --git a/include/xrpl/ledger/helpers/CredentialHelpers.h b/include/xrpl/ledger/helpers/CredentialHelpers.h index 8e78a00923..8b1c819bf4 100644 --- a/include/xrpl/ledger/helpers/CredentialHelpers.h +++ b/include/xrpl/ledger/helpers/CredentialHelpers.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -34,7 +35,7 @@ deleteSLE(ApplyView& view, SLE::ref sleCredential, beast::Journal j); // Amendment and parameters checks for sfCredentialIDs field NotTEC -checkFields(STTx const& tx, beast::Journal j); +checkFields(STTx const& tx, Rules const& rules, beast::Journal j); // Accessing the ledger to check if provided credentials are valid. Do not use // in doApply (only in preclaim) since it does not remove expired credentials. diff --git a/src/libxrpl/ledger/helpers/CredentialHelpers.cpp b/src/libxrpl/ledger/helpers/CredentialHelpers.cpp index 226ea100e9..5ba832957d 100644 --- a/src/libxrpl/ledger/helpers/CredentialHelpers.cpp +++ b/src/libxrpl/ledger/helpers/CredentialHelpers.cpp @@ -22,6 +22,7 @@ #include #include +#include #include #include #include @@ -52,6 +53,9 @@ removeExpired(ApplyView& view, STVector256 const& arr, beast::Journal const j) for (auto const& h : arr) { // Credentials already checked in preclaim. Look only for expired here. + if (view.rules().enabled(fixCleanup3_4_0) && h.isZero()) + return std::unexpected(tecINTERNAL); // LCOV_EXCL_LINE + auto const k = keylet::credential(h); auto const sleCred = view.peek(k); @@ -124,7 +128,7 @@ deleteSLE(ApplyView& view, SLE::ref sleCredential, beast::Journal j) } NotTEC -checkFields(STTx const& tx, beast::Journal j) +checkFields(STTx const& tx, Rules const& rules, beast::Journal j) { if (!tx.isFieldPresent(sfCredentialIDs)) return tesSUCCESS; @@ -137,6 +141,13 @@ checkFields(STTx const& tx, beast::Journal j) return temMALFORMED; } + if (rules.enabled(fixCleanup3_4_0) && + std::ranges::any_of(credentials, [](uint256 const& id) { return id.isZero(); })) + { + JLOG(j.trace()) << "Malformed transaction: zero credential ID."; + return temMALFORMED; + } + std::unordered_set duplicates; for (auto const& cred : credentials) { @@ -160,6 +171,14 @@ valid(STTx const& tx, ReadView const& view, AccountID const& src, beast::Journal auto const& credIDs(tx.getFieldV256(sfCredentialIDs)); for (auto const& h : credIDs) { + if (view.rules().enabled(fixCleanup3_4_0) && h.isZero()) + { + // LCOV_EXCL_START + JLOG(j.trace()) << "Zero credential ID."; + return tecINTERNAL; + // LCOV_EXCL_STOP + } + auto const sleCred = view.read(keylet::credential(h)); if (!sleCred) { @@ -234,6 +253,9 @@ authorizedDepositPreauth(ReadView const& view, STVector256 const& credIDs, Accou lifeExtender.reserve(credIDs.size()); for (auto const& h : credIDs) { + if (view.rules().enabled(fixCleanup3_4_0) && h.isZero()) + return tefINTERNAL; // LCOV_EXCL_LINE + auto sleCred = view.read(keylet::credential(h)); if (!sleCred) // already checked in preclaim return tefINTERNAL; // LCOV_EXCL_LINE diff --git a/src/libxrpl/tx/transactors/account/AccountDelete.cpp b/src/libxrpl/tx/transactors/account/AccountDelete.cpp index ce027f4cad..0936fe26dc 100644 --- a/src/libxrpl/tx/transactors/account/AccountDelete.cpp +++ b/src/libxrpl/tx/transactors/account/AccountDelete.cpp @@ -50,7 +50,7 @@ AccountDelete::preflight(PreflightContext const& ctx) return temDST_IS_SRC; } - if (auto const err = credentials::checkFields(ctx.tx, ctx.j); !isTesSuccess(err)) + if (auto const err = credentials::checkFields(ctx.tx, ctx.rules, ctx.j); !isTesSuccess(err)) return err; return tesSUCCESS; diff --git a/src/libxrpl/tx/transactors/escrow/EscrowFinish.cpp b/src/libxrpl/tx/transactors/escrow/EscrowFinish.cpp index 5fc0aef853..32f4d9ec48 100644 --- a/src/libxrpl/tx/transactors/escrow/EscrowFinish.cpp +++ b/src/libxrpl/tx/transactors/escrow/EscrowFinish.cpp @@ -111,7 +111,7 @@ EscrowFinish::preflightSigValidated(PreflightContext const& ctx) } } - if (auto const err = credentials::checkFields(ctx.tx, ctx.j); !isTesSuccess(err)) + if (auto const err = credentials::checkFields(ctx.tx, ctx.rules, ctx.j); !isTesSuccess(err)) return err; return tesSUCCESS; diff --git a/src/libxrpl/tx/transactors/payment/Payment.cpp b/src/libxrpl/tx/transactors/payment/Payment.cpp index 17c96a1919..c8b00f0193 100644 --- a/src/libxrpl/tx/transactors/payment/Payment.cpp +++ b/src/libxrpl/tx/transactors/payment/Payment.cpp @@ -281,7 +281,7 @@ Payment::preflight(PreflightContext const& ctx) } } - if (auto const err = credentials::checkFields(ctx.tx, ctx.j); !isTesSuccess(err)) + if (auto const err = credentials::checkFields(ctx.tx, ctx.rules, ctx.j); !isTesSuccess(err)) return err; return tesSUCCESS; diff --git a/src/libxrpl/tx/transactors/payment_channel/PaymentChannelClaim.cpp b/src/libxrpl/tx/transactors/payment_channel/PaymentChannelClaim.cpp index b8118bc49f..9143a675f6 100644 --- a/src/libxrpl/tx/transactors/payment_channel/PaymentChannelClaim.cpp +++ b/src/libxrpl/tx/transactors/payment_channel/PaymentChannelClaim.cpp @@ -87,7 +87,7 @@ PaymentChannelClaim::preflight(PreflightContext const& ctx) return temBAD_SIGNATURE; } - if (auto const err = credentials::checkFields(ctx.tx, ctx.j); !isTesSuccess(err)) + if (auto const err = credentials::checkFields(ctx.tx, ctx.rules, ctx.j); !isTesSuccess(err)) return err; return tesSUCCESS; diff --git a/src/libxrpl/tx/transactors/token/ConfidentialMPTSend.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTSend.cpp index d121ec2634..f4c7b98c41 100644 --- a/src/libxrpl/tx/transactors/token/ConfidentialMPTSend.cpp +++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTSend.cpp @@ -82,7 +82,7 @@ ConfidentialMPTSend::preflight(PreflightContext const& ctx) if (hasAuditor && !isValidCiphertext(ctx.tx[sfAuditorEncryptedAmount])) return temBAD_CIPHERTEXT; - if (auto const err = credentials::checkFields(ctx.tx, ctx.j); !isTesSuccess(err)) + if (auto const err = credentials::checkFields(ctx.tx, ctx.rules, ctx.j); !isTesSuccess(err)) return err; return tesSUCCESS; diff --git a/src/test/app/DepositAuth_test.cpp b/src/test/app/DepositAuth_test.cpp index 881441e0f9..c987e603be 100644 --- a/src/test/app/DepositAuth_test.cpp +++ b/src/test/app/DepositAuth_test.cpp @@ -934,6 +934,46 @@ struct DepositPreauth_test : public beast::unit_test::Suite } } + void + testZeroCredentialID(FeatureBitset features) + { + testcase("Zero credential ID"); + + using namespace jtx; + + char const credType[] = "abcde"; + Account const issuer{"issuer"}; + Account const alice{"alice"}; + Account const bob{"bob"}; + + Env env(*this, features); + + env.fund(XRP(5000), issuer, alice, bob); + env.close(); + + env(credentials::create(alice, issuer, credType)); + env.close(); + env(credentials::accept(alice, issuer, credType)); + env.close(); + + auto const jv = credentials::ledgerEntry(env, alice, issuer, credType); + std::string const credIdx = jv[jss::result][jss::index].asString(); + + std::string const zeroIdx(64, '0'); + + // post-fixCleanup3_4_0: a zero ID is rejected by checkFields in + // preflight; pre-fixCleanup3_4_0, it will trigger assertion, so it is not testable. + env(pay(alice, bob, XRP(100)), credentials::Ids({zeroIdx}), Ter(temMALFORMED)); + env.close(); + + env(pay(alice, bob, XRP(100)), credentials::Ids({credIdx, zeroIdx}), Ter(temMALFORMED)); + env.close(); + + // A valid credential succeeds + env(pay(alice, bob, XRP(100)), credentials::Ids({credIdx})); + env.close(); + } + void testCredentialsCreation() { @@ -1446,6 +1486,7 @@ struct DepositPreauth_test : public beast::unit_test::Suite testPayment(supported - featureCredentials); testPayment(supported); testCredentialsPayment(); + testZeroCredentialID(supported); testCredentialsCreation(); testExpiredCreds(); testSortingCredentials(); From 9c292fbe4fbd62aa8780842a95ddd9b5291093f5 Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Mon, 10 Aug 2026 18:49:29 +0100 Subject: [PATCH 057/102] build: Install conan configuration/profiles inside Nix devshell (#7997) --- .envrc | 4 ++++ BUILD.md | 42 +++++++++++++++++------------------------- conan/init.sh | 21 +++++++++++++++++++++ conan/profiles/default | 3 --- docs/build/nix.md | 28 +++++++++++++++++++++++----- nix/devshell.nix | 22 +++++++++++++++++++++- 6 files changed, 86 insertions(+), 34 deletions(-) create mode 100755 conan/init.sh diff --git a/.envrc b/.envrc index cecf4b4767..ec38b75f5c 100644 --- a/.envrc +++ b/.envrc @@ -1,3 +1,7 @@ watch_file nix/*.nix +# The dev shell derivation includes all of conan/ (see nix/devshell.nix), so any +# change in there has to invalidate direnv's cached environment. +watch_dir conan + use flake diff --git a/BUILD.md b/BUILD.md index ad4666b141..ae2e69bb97 100644 --- a/BUILD.md +++ b/BUILD.md @@ -53,33 +53,25 @@ releases](https://github.com/XRPLF/rippled/releases). ### Set Up Conan -Once your [development environment](./docs/build/environment.md) is ready, you -may need to set up your Conan profile. - -#### Profiles - -We recommend that you install our Conan profiles: +Once your [development environment](./docs/build/environment.md) is ready, set +Conan up for this repository: ```bash -conan config install conan/profiles/ -tf $(conan config home)/profiles/ +./conan/init.sh ``` -You can check your Conan profile by running: +That installs our [`global.conf`](./conan/global.conf), our Conan +[profiles](./conan/profiles), and the `xrplf` remote that hosts some of our +dependencies. It honours `CONAN_HOME` and never deletes an existing Conan home, +so it is safe to re-run — it only overwrites the files it manages. -```bash -conan profile show -``` +> [!TIP] +> In the [Nix development shell](./docs/build/nix.md#conan-configuration) this is +> already done for you: the script runs on entry. -If the default profile is not suitable for your environment, you can create a custom profile and pass it to Conan. -More information on customizing Conan can be found in the [Advanced Conan configuration](./docs/build/advanced_conan.md). - -#### Add xrplf remote - -Run the following command to add the `xrplf` remote, which hosts some of our dependencies: - -```bash -conan remote add --index 0 --force xrplf https://conan.xrplf.org/repository/conan/ -``` +You can inspect the resulting profile with `conan profile show`. If it is not +suitable for your environment, create a custom profile and pass it to Conan — see +[Advanced Conan configuration](./docs/build/advanced_conan.md). ### Set Up Ccache @@ -368,14 +360,14 @@ After any updates or changes to dependencies, you may need to do the following: 4. [Regenerate lockfile](./docs/build/advanced_conan.md#conan-lockfile). 5. Re-run [conan install](#build-and-test). -If you are using the Nix development shell, prebuilt Conan binaries may be -incompatible with it — see -[Building xrpld in the Nix shell](./docs/build/nix.md#building-xrpld-in-the-nix-shell). +If you are using the Nix development shell, whether prebuilt Conan binaries apply +depends on your platform — see +[Prebuilt packages](./docs/build/nix.md#prebuilt-packages). #### ERROR: Package not resolved If you're seeing an error like `ERROR: Package 'snappy/1.1.10' not resolved: Unable to find 'snappy/1.1.10#968fef506ff261592ec30c574d4a7809%1756234314.246' in remotes.`, -please [add `xrplf` remote](#add-xrplf-remote) or re-run `conan export` for [patched recipes](./docs/build/advanced_conan.md#patched-recipes). +please [set Conan up](#set-up-conan) so the `xrplf` remote is configured, or re-run `conan export` for [patched recipes](./docs/build/advanced_conan.md#patched-recipes). ### `protobuf/port_def.inc` file not found diff --git a/conan/init.sh b/conan/init.sh new file mode 100755 index 0000000000..287ee83001 --- /dev/null +++ b/conan/init.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Install our Conan configuration, profiles and the xrplf remote into CONAN_HOME. +# Safe to re-run; never deletes the Conan home. + +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +CONAN_DIR="$(conan config home)" + +echo "Installing Conan configuration into ${CONAN_DIR}" +conan config install "${SCRIPT_DIR}/global.conf" +conan config install "${SCRIPT_DIR}/profiles" -tf "${CONAN_DIR}/profiles" +# This script manages these files, so make them read-only - Conan does not +# preserve the source mode. Only the files: the directories must stay writable +# for `conan config install` to replace them. +chmod a-w "${CONAN_DIR}/global.conf" +find "${CONAN_DIR}/profiles" -type f -exec chmod a-w {} + + +echo "Adding the xrplf Conan remote" +# --index 0: our patched recipes must win over Conan Center. +conan remote add --index 0 --force xrplf https://conan.xrplf.org/repository/conan/ diff --git a/conan/profiles/default b/conan/profiles/default index f2d93213ac..1b7eaff980 100644 --- a/conan/profiles/default +++ b/conan/profiles/default @@ -1,10 +1,7 @@ {% set os = detect_api.detect_os() %} {% set arch = detect_api.detect_arch() %} {% set compiler, version, compiler_exe = detect_api.detect_default_compiler() %} -{% set compiler_version = version %} -{% 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 #} diff --git a/docs/build/nix.md b/docs/build/nix.md index fad8bc701d..d1e40fcc89 100644 --- a/docs/build/nix.md +++ b/docs/build/nix.md @@ -124,14 +124,32 @@ nix develop -c "$SHELL" Once inside the Nix development shell, follow the standard [build instructions](../../BUILD.md#steps). The Nix shell provides all necessary tools (CMake, Ninja, Conan, etc.). -Two things differ from a system environment: - -**Prebuilt Conan packages.** There is no guarantee that binaries from the Conan cache will work when using Nix. If you encounter any errors, add `--build '*'` to the `conan install` command in [Build and Test](../../BUILD.md#build-and-test) to force Conan to compile everything from source. Keep the rest of the command as it is there, so it rebuilds the `build_type` you are actually configuring. - -**Coverage builds.** `-Dcoverage=ON` works in the `gcc` shell (and `gcc-plain` on Linux): +Coverage builds (`-Dcoverage=ON`) work in the `gcc` shell (and `gcc-plain` on Linux): each ships a `gcov` matching its compiler, since Nix's cc-wrapper does not expose one. The `clang` shells do not include `llvm-cov`, so use a `gcc` shell for coverage. +## Conan configuration + +The shell runs [`conan/init.sh`](../../conan/init.sh) on entry, so +[Set Up Conan](../../BUILD.md#set-up-conan) is already done for you. It installs +into the shell's own Conan home: `CONAN_HOME=~/.conan2-nix`. + +### Prebuilt packages + +On **Linux**, the binaries on the `xrplf` remote are built in this same Nix +environment — CI runs in Docker images that bundle the dev shell's toolchain (see +[`nix/docker`](../../nix/docker)) — so `.#gcc` and `.#clang` can reuse them. The +`-plain` shells do not match that toolchain's glibc, so binaries from the remote +are not a reliable match there. + +On **macOS**, CI builds with Apple Clang, so the remote holds nothing for the Nix +`clang` toolchain and dependencies are compiled locally. We do not publish +Nix-built macOS binaries because a Conan package ID records the compiler version +but not the nixpkgs revision. + +To compile everything from source, add `--build '*'` to the `conan install` +command. + ## Automatic Activation with direnv [direnv](https://direnv.net/) or [nix-direnv](https://github.com/nix-community/nix-direnv) can automatically activate the Nix development shell when you enter the repository directory. diff --git a/nix/devshell.nix b/nix/devshell.nix index cb4a99c76a..ac0b84e169 100644 --- a/nix/devshell.nix +++ b/nix/devshell.nix @@ -30,10 +30,29 @@ let }; customGccGcov = if pkgs.stdenv.isLinux then customCompilers.customGcov else plainGcov; + # Whole directory: init.sh locates the profiles relative to itself. + conanDir = ../conan; + + # Own Conan home, so Nix-built packages never share a cache with a system + # Conan. The stamp holds a content-addressed store path, so init.sh re-runs + # only when something in conan/ changes. + conanHook = '' + export CONAN_HOME=~/.conan2-nix + _xrpl_conan_stamp="$CONAN_HOME/.xrpld-devshell" + if [ "$(cat "$_xrpl_conan_stamp" 2>/dev/null)" != "${conanDir}" ]; then + if ${conanDir}/init.sh; then + printf '%s' "${conanDir}" >"$_xrpl_conan_stamp" + else + echo "⚠️ Conan setup failed - run ./conan/init.sh from the repository root to retry." + fi + fi + unset _xrpl_conan_stamp + ''; + # Shown when entering a *-plain shell. These exist only on Linux (see below), # where the stock toolchain diverges from CI. plainWarningHook = '' - echo "⚠️ WARNING: this is the stock nixpkgs toolchain and does not match CI's glibc. Prefer 'nix develop .#gcc' / '.#clang' unless you need to skip the custom-glibc build." + echo "⚠️ WARNING: this is the stock nixpkgs toolchain and does not match CI's glibc. Prefer 'nix develop .#gcc' / '.#clang' unless you need to skip the custom-glibc build." ''; # Tools to expose under version-suffixed names (see mkVersionedToolLinks). @@ -87,6 +106,7 @@ let shellHook = '' echo "Welcome to xrpld development shell"; ${compilerVersionHook} + ${conanHook} ${warningHook} ''; } From 4173f7e499e3dd55900bf72b3c05f68577448060 Mon Sep 17 00:00:00 2001 From: Braedon Klock Date: Mon, 10 Aug 2026 21:30:06 +0000 Subject: [PATCH 058/102] fix: Validate account_lines peer field type (#7728) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- API-CHANGELOG.md | 1 + src/test/rpc/AccountLines_test.cpp | 47 +++++++++++++++++++ .../rpc/handlers/account/AccountLines.cpp | 5 ++ 3 files changed, 53 insertions(+) diff --git a/API-CHANGELOG.md b/API-CHANGELOG.md index 79fb8ff522..bc3672588e 100644 --- a/API-CHANGELOG.md +++ b/API-CHANGELOG.md @@ -54,6 +54,7 @@ This section contains changes targeting a future version. - `submit`: The `fail_hard` field now returns an error if the value is not a boolean. [#6529](https://github.com/XRPLF/rippled/pull/6529) - `subscribe`: The `taker` field in the `books` array now returns `actMalformed` instead of `badIssuer` if the value is not a valid account. [#6529](https://github.com/XRPLF/rippled/pull/6529) - Fixed a bug in `Forwarded` HTTP header parsing where the extracted IP address could be incorrect when no comma or semicolon delimiter follows the address. This could cause the server to misidentify a client's IP address when operating behind a reverse proxy. [#6529](https://github.com/XRPLF/rippled/pull/6529) +- `account_lines`: The `peer` field now returns an error if the value is not a string. [#7728](https://github.com/XRPLF/rippled/pull/7728) ## XRP Ledger server version 3.1.0 diff --git a/src/test/rpc/AccountLines_test.cpp b/src/test/rpc/AccountLines_test.cpp index cb20de9bf5..3de2bdefa3 100644 --- a/src/test/rpc/AccountLines_test.cpp +++ b/src/test/rpc/AccountLines_test.cpp @@ -94,6 +94,24 @@ public: LedgerHeader const ledger3Info = env.closed()->header(); BEAST_EXPECT(ledger3Info.seq == 3); + { + // test peer non-string + auto testInvalidPeerParam = [&](auto const& param) { + json::Value params; + params[jss::account] = alice.human(); + params[jss::peer] = param; + auto jrr = env.rpc("json", "account_lines", to_string(params))[jss::result]; + BEAST_EXPECT(jrr[jss::error] == "invalidParams"); + BEAST_EXPECT(jrr[jss::error_message] == "Invalid field 'peer'."); + }; + + testInvalidPeerParam(1); + testInvalidPeerParam(1.1); + testInvalidPeerParam(true); + testInvalidPeerParam(json::Value(json::ValueType::Null)); + testInvalidPeerParam(json::Value(json::ValueType::Object)); + testInvalidPeerParam(json::Value(json::ValueType::Array)); + } { // alice is funded but has no lines. An empty array is returned. json::Value params; @@ -775,6 +793,35 @@ public: LedgerHeader const ledger3Info = env.closed()->header(); BEAST_EXPECT(ledger3Info.seq == 3); + { + // test peer non-string + auto testInvalidPeerParam = [&](auto const& param) { + json::Value params; + params[jss::account] = alice.human(); + params[jss::peer] = param; + + json::Value request; + request[jss::method] = "account_lines"; + request[jss::jsonrpc] = "2.0"; + request[jss::ripplerpc] = "2.0"; + request[jss::id] = 5; + request[jss::params] = params; + + auto const lines = env.rpc("json2", to_string(request)); + BEAST_EXPECT(lines[jss::error][jss::error] == "invalidParams"); + BEAST_EXPECT(lines[jss::error][jss::message] == "Invalid field 'peer'."); + BEAST_EXPECT(lines.isMember(jss::jsonrpc) && lines[jss::jsonrpc] == "2.0"); + BEAST_EXPECT(lines.isMember(jss::ripplerpc) && lines[jss::ripplerpc] == "2.0"); + BEAST_EXPECT(lines.isMember(jss::id) && lines[jss::id] == 5); + }; + + testInvalidPeerParam(1); + testInvalidPeerParam(1.1); + testInvalidPeerParam(true); + testInvalidPeerParam(json::Value(json::ValueType::Null)); + testInvalidPeerParam(json::Value(json::ValueType::Object)); + testInvalidPeerParam(json::Value(json::ValueType::Array)); + } { // alice is funded but has no lines. An empty array is returned. json::Value params; diff --git a/src/xrpld/rpc/handlers/account/AccountLines.cpp b/src/xrpld/rpc/handlers/account/AccountLines.cpp index 4a6d22d5d8..ac98e271b6 100644 --- a/src/xrpld/rpc/handlers/account/AccountLines.cpp +++ b/src/xrpld/rpc/handlers/account/AccountLines.cpp @@ -107,7 +107,12 @@ doAccountLines(rpc::JsonContext& context) std::string strPeer; if (params.isMember(jss::peer)) + { + if (!params[jss::peer].isString()) + return rpc::invalidFieldError(jss::peer); + strPeer = params[jss::peer].asString(); + } auto const raPeerAccount = [&]() -> std::optional { return strPeer.empty() ? std::nullopt : parseBase58(strPeer); From 60291c3ed613a749f6aeced06d485b1d478d9843 Mon Sep 17 00:00:00 2001 From: Kassaking7 <96991820+Kassaking7@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:34:28 +0000 Subject: [PATCH 059/102] fix: Allow OverrideFreeze to bypass individual/deep freeze on AMM trust lines (#6959) --- include/xrpl/tx/invariants/FreezeInvariant.h | 6 +- src/libxrpl/tx/invariants/FreezeInvariant.cpp | 23 +- src/test/app/AMMClawback_test.cpp | 204 ++++++++++++++++++ 3 files changed, 222 insertions(+), 11 deletions(-) diff --git a/include/xrpl/tx/invariants/FreezeInvariant.h b/include/xrpl/tx/invariants/FreezeInvariant.h index 4b3e9beec4..c66e002872 100644 --- a/include/xrpl/tx/invariants/FreezeInvariant.h +++ b/include/xrpl/tx/invariants/FreezeInvariant.h @@ -69,7 +69,8 @@ private: IssuerChanges const& changes, STTx const& tx, beast::Journal const& j, - bool enforce); + bool enforce, + bool fixOverrideFreeze); static bool validateFrozenState( @@ -78,7 +79,8 @@ private: STTx const& tx, beast::Journal const& j, bool enforce, - bool globalFreeze); + bool globalFreeze, + bool fixOverrideFreeze); }; } // namespace xrpl diff --git a/src/libxrpl/tx/invariants/FreezeInvariant.cpp b/src/libxrpl/tx/invariants/FreezeInvariant.cpp index 0a604d4c39..c4340b9aec 100644 --- a/src/libxrpl/tx/invariants/FreezeInvariant.cpp +++ b/src/libxrpl/tx/invariants/FreezeInvariant.cpp @@ -73,6 +73,7 @@ TransfersNotFrozen::finalize( * view.rules().enabled(fixFreezeExploit); */ [[maybe_unused]] bool const enforce = view.rules().enabled(featureDeepFreeze); + bool const fixOverrideFreeze = view.rules().enabled(fixCleanup3_4_0); return std::ranges::all_of(balanceChanges_, [&](auto const& entry) { auto const& [issue, changes] = entry; @@ -90,7 +91,7 @@ TransfersNotFrozen::finalize( return !enforce; } - return validateIssuerChanges(issuerSle, changes, tx, j, enforce); + return validateIssuerChanges(issuerSle, changes, tx, j, enforce, fixOverrideFreeze); }); } @@ -199,7 +200,8 @@ TransfersNotFrozen::validateIssuerChanges( IssuerChanges const& changes, STTx const& tx, beast::Journal const& j, - bool enforce) + bool enforce, + bool fixOverrideFreeze) { if (!issuer) { @@ -225,7 +227,7 @@ TransfersNotFrozen::validateIssuerChanges( { bool const high = change.line->at(sfLowLimit).getIssuer() == issuer->at(sfAccount); - if (!validateFrozenState(change, high, tx, j, enforce, globalFreeze)) + if (!validateFrozenState(change, high, tx, j, enforce, globalFreeze, fixOverrideFreeze)) { return false; } @@ -241,26 +243,29 @@ TransfersNotFrozen::validateFrozenState( STTx const& tx, beast::Journal const& j, bool enforce, - bool globalFreeze) + bool globalFreeze, + bool fixOverrideFreeze) { bool const freeze = change.balanceChangeSign < 0 && change.line->isFlag(high ? lsfLowFreeze : lsfHighFreeze); bool const deepFreeze = change.line->isFlag(high ? lsfLowDeepFreeze : lsfHighDeepFreeze); bool const frozen = globalFreeze || deepFreeze || freeze; - bool const isAMMLine = change.line->isFlag(lsfAMMNode); - if (!frozen) { return true; } - // AMMClawbacks are allowed to override some freeze rules - if ((!isAMMLine || globalFreeze) && hasPrivilege(tx, OverrideFreeze)) + // Pre-fixCleanup3_4_0: the isAMMLine check incorrectly blocked clawback on + // individually-frozen or deep-frozen AMM trust lines. + // Post-fixCleanup3_4_0: AMMClawbacks are allowed to override all freeze types. + bool const isAMMLine = change.line->isFlag(lsfAMMNode); + if ((fixOverrideFreeze || !isAMMLine || globalFreeze) && hasPrivilege(tx, OverrideFreeze)) { JLOG(j.debug()) << "Invariant check allowing funds to be moved " << (change.balanceChangeSign > 0 ? "to" : "from") - << " a frozen trustline for AMMClawback " << tx.getTransactionID(); + << " a frozen trustline for a freeze privileged transaction " + << tx.getTransactionID(); return true; } diff --git a/src/test/app/AMMClawback_test.cpp b/src/test/app/AMMClawback_test.cpp index ba416d8192..90bface1fb 100644 --- a/src/test/app/AMMClawback_test.cpp +++ b/src/test/app/AMMClawback_test.cpp @@ -2155,6 +2155,209 @@ class AMMClawback_test : public beast::unit_test::Suite } BEAST_EXPECT(env.balance(carol, eur) == eur(7750)); } + + // gw (USD issuer) individually freezes the AMM-USD trust line. + // AMMClawback must still succeed because the freeze invariant + // short-circuits before reaching the AMM line check (no receivers in + // the USD issuer's change set). Behavior is identical with or without + // fixCleanup3_4_0. + { + Env env(*this, features); + Account const gw{"gateway"}; + Account const gw2{"gateway2"}; + Account const alice{"alice"}; + env.fund(XRP(1000000), gw, gw2, alice); + env.close(); + + env(fset(gw, asfAllowTrustLineClawback)); + env.close(); + env.require(Flags(gw, asfAllowTrustLineClawback)); + + auto const usd = gw["USD"]; + env.trust(usd(100000), alice); + env(pay(gw, alice, usd(3000))); + env.close(); + + auto const eur = gw2["EUR"]; + env.trust(eur(100000), alice); + env(pay(gw2, alice, eur(3000))); + env.close(); + + AMM const amm(env, alice, eur(1000), usd(2000), Ter(tesSUCCESS)); + env.close(); + + BEAST_EXPECT( + amm.expectBalances(usd(2000), eur(1000), IOUAmount{1414213562373095, -12})); + + // gw individually freezes the AMM-USD trust line (AMM pseudo-account + // <-> gw), not alice's trust line. + env(trust(gw, STAmount{Issue{usd.currency, amm.ammAccount()}, 0}, tfSetFreeze)); + env.close(); + + env(amm::ammClawback(gw, alice, usd, eur, usd(1000)), Ter(tesSUCCESS)); + env.close(); + + env.require(Balance(alice, usd(1000))); + env.require(Balance(alice, eur(2500))); + BEAST_EXPECT(amm.expectBalances(usd(1000), eur(500), IOUAmount{7071067811865475, -13})); + BEAST_EXPECT(amm.expectLPTokens(alice, IOUAmount{7071067811865475, -13})); + } + + // gw2 (EUR issuer) individually freezes the AMM-EUR trust line. + // The EUR flow (AMM → alice) is a genuine P2P transfer checked by the + // freeze invariant. Pre-fixCleanup3_4_0 the isAMMNode guard incorrectly + // blocked AMMClawback's overrideFreeze privilege on that trust line. + { + Env env(*this, features); + Account const gw{"gateway"}; + Account const gw2{"gateway2"}; + Account const alice{"alice"}; + env.fund(XRP(1000000), gw, gw2, alice); + env.close(); + + env(fset(gw, asfAllowTrustLineClawback)); + env.close(); + env.require(Flags(gw, asfAllowTrustLineClawback)); + + auto const usd = gw["USD"]; + env.trust(usd(100000), alice); + env(pay(gw, alice, usd(3000))); + env.close(); + + auto const eur = gw2["EUR"]; + env.trust(eur(100000), alice); + env(pay(gw2, alice, eur(3000))); + env.close(); + + AMM const amm(env, alice, eur(1000), usd(2000), Ter(tesSUCCESS)); + env.close(); + + BEAST_EXPECT( + amm.expectBalances(usd(2000), eur(1000), IOUAmount{1414213562373095, -12})); + + // gw2 individually freezes the AMM-EUR trust line. + env(trust(gw2, STAmount{Issue{eur.currency, amm.ammAccount()}, 0}, tfSetFreeze)); + env.close(); + + if (features[fixCleanup3_4_0]) + { + // Post-fixCleanup3_4_0: overrideFreeze privilege applies to + // all freeze types on AMM trust lines. + env(amm::ammClawback(gw, alice, usd, eur, usd(1000)), Ter(tesSUCCESS)); + env.close(); + + env.require(Balance(alice, usd(1000))); + env.require(Balance(alice, eur(2500))); + BEAST_EXPECT( + amm.expectBalances(usd(1000), eur(500), IOUAmount{7071067811865475, -13})); + BEAST_EXPECT(amm.expectLPTokens(alice, IOUAmount{7071067811865475, -13})); + } + else + { + // Pre-fixCleanup3_4_0: the isAMMNode guard prevents the + // overrideFreeze privilege from applying to individually-frozen + // AMM trust lines, so the invariant blocks the clawback. + env(amm::ammClawback(gw, alice, usd, eur, usd(1000)), Ter(tecINVARIANT_FAILED)); + } + } + + // gw2 (EUR issuer) globally freezes its issued assets. AMMClawback + // must still be able to return EUR from the AMM to alice. + { + Env env(*this, features); + Account const gw{"gateway"}; + Account const gw2{"gateway2"}; + Account const alice{"alice"}; + env.fund(XRP(1000000), gw, gw2, alice); + env.close(); + + env(fset(gw, asfAllowTrustLineClawback)); + env.close(); + env.require(Flags(gw, asfAllowTrustLineClawback)); + + auto const usd = gw["USD"]; + env.trust(usd(100000), alice); + env(pay(gw, alice, usd(3000))); + env.close(); + + auto const eur = gw2["EUR"]; + env.trust(eur(100000), alice); + env(pay(gw2, alice, eur(3000))); + env.close(); + + AMM const amm(env, alice, eur(1000), usd(2000), Ter(tesSUCCESS)); + env.close(); + + BEAST_EXPECT( + amm.expectBalances(usd(2000), eur(1000), IOUAmount{1414213562373095, -12})); + + env(fset(gw2, asfGlobalFreeze)); + env.close(); + + env(amm::ammClawback(gw, alice, usd, eur, usd(1000)), Ter(tesSUCCESS)); + env.close(); + + env.require(Balance(alice, usd(1000))); + env.require(Balance(alice, eur(2500))); + BEAST_EXPECT(amm.expectBalances(usd(1000), eur(500), IOUAmount{7071067811865475, -13})); + BEAST_EXPECT(amm.expectLPTokens(alice, IOUAmount{7071067811865475, -13})); + } + + // Same as above but gw2 deep-freezes the AMM-EUR trust line. + if (features[featureDeepFreeze]) + { + Env env(*this, features); + Account const gw{"gateway"}; + Account const gw2{"gateway2"}; + Account const alice{"alice"}; + env.fund(XRP(1000000), gw, gw2, alice); + env.close(); + + env(fset(gw, asfAllowTrustLineClawback)); + env.close(); + env.require(Flags(gw, asfAllowTrustLineClawback)); + + auto const usd = gw["USD"]; + env.trust(usd(100000), alice); + env(pay(gw, alice, usd(3000))); + env.close(); + + auto const eur = gw2["EUR"]; + env.trust(eur(100000), alice); + env(pay(gw2, alice, eur(3000))); + env.close(); + + AMM const amm(env, alice, eur(1000), usd(2000), Ter(tesSUCCESS)); + env.close(); + + BEAST_EXPECT( + amm.expectBalances(usd(2000), eur(1000), IOUAmount{1414213562373095, -12})); + + // gw2 deep-freezes the AMM-EUR trust line. + env(trust( + gw2, + STAmount{Issue{eur.currency, amm.ammAccount()}, 0}, + tfSetFreeze | tfSetDeepFreeze)); + env.close(); + + if (features[fixCleanup3_4_0]) + { + env(amm::ammClawback(gw, alice, usd, eur, usd(1000)), Ter(tesSUCCESS)); + env.close(); + + env.require(Balance(alice, usd(1000))); + env.require(Balance(alice, eur(2500))); + BEAST_EXPECT( + amm.expectBalances(usd(1000), eur(500), IOUAmount{7071067811865475, -13})); + BEAST_EXPECT(amm.expectLPTokens(alice, IOUAmount{7071067811865475, -13})); + } + else + { + // Pre-fixCleanup3_4_0: same isAMMNode guard issue blocks the + // clawback on deep-frozen AMM trust lines. + env(amm::ammClawback(gw, alice, usd, eur, usd(1000)), Ter(tecINVARIANT_FAILED)); + } + } } void @@ -2530,6 +2733,7 @@ class AMMClawback_test : public beast::unit_test::Suite // precision loss caught in transaction layer -> tecPRECISION_LOSS all - fixAMMClawbackRounding - featureMPTokensV2, all - featureMPTokensV2, + all - fixCleanup3_4_0, all}) { testAMMClawbackSpecificAmount(features); From 6f5de9067aedad3ae5f7bb555d102ca67a67fb60 Mon Sep 17 00:00:00 2001 From: Peter Chen <34582813+PeterChen13579@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:37:38 +0000 Subject: [PATCH 060/102] chore: Mark unreachable branches in Confidential Transfer with UNREACHABLE (#7903) --- src/libxrpl/protocol/ConfidentialTransfer.cpp | 91 ++++++++++++++++--- .../token/ConfidentialMPTClawback.cpp | 55 +++++++++-- .../token/ConfidentialMPTConvert.cpp | 53 +++++++++-- .../token/ConfidentialMPTConvertBack.cpp | 46 +++++++++- .../token/ConfidentialMPTMergeInbox.cpp | 35 ++++++- .../transactors/token/ConfidentialMPTSend.cpp | 59 ++++++++++-- 6 files changed, 298 insertions(+), 41 deletions(-) diff --git a/src/libxrpl/protocol/ConfidentialTransfer.cpp b/src/libxrpl/protocol/ConfidentialTransfer.cpp index fe8a08c2ef..ecd4832928 100644 --- a/src/libxrpl/protocol/ConfidentialTransfer.cpp +++ b/src/libxrpl/protocol/ConfidentialTransfer.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -124,7 +125,12 @@ std::optional makeEcPair(Slice const& buffer) { if (buffer.length() != 2 * kEcCiphertextComponentLength) - return std::nullopt; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE("xrpl::makeEcPair : callers must pre-validate ciphertext length"); + return std::nullopt; + // LCOV_EXCL_STOP + } auto parsePubKey = [](Slice const& slice, secp256k1_pubkey& out) { return secp256k1_ec_pubkey_parse(secp256k1Context(), &out, slice.data(), slice.length()); @@ -266,7 +272,13 @@ std::optional encryptCanonicalZeroAmount(Slice const& pubKeySlice, AccountID const& account, MPTID const& mptId) { if (pubKeySlice.size() != kEcPubKeyLength) - return std::nullopt; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::encryptCanonicalZeroAmount : callers must pre-validate public key length"); + return std::nullopt; + // LCOV_EXCL_STOP + } EcPair pair{}; secp256k1_pubkey pubKey; @@ -274,14 +286,24 @@ encryptCanonicalZeroAmount(Slice const& pubKeySlice, AccountID const& account, M secp256k1Context(), &pubKey, pubKeySlice.data(), kEcPubKeyLength); res != 1) { - return std::nullopt; // LCOV_EXCL_LINE + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::encryptCanonicalZeroAmount : public key read from the ledger must already be " + "valid"); + return std::nullopt; + // LCOV_EXCL_STOP } if (auto res = generate_canonical_encrypted_zero( secp256k1Context(), &pair.c1, &pair.c2, &pubKey, account.data(), mptId.data()); res != 1) { - return std::nullopt; // LCOV_EXCL_LINE + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::encryptCanonicalZeroAmount : canonical zero generation cannot fail for a " + "valid public key"); + return std::nullopt; + // LCOV_EXCL_STOP } return serializeEcPair(pair); @@ -301,7 +323,11 @@ verifyRevealedAmount( issuer.publicKey.size() != kEcPubKeyLength || issuer.encryptedAmount.size() != kEcGamalEncryptedTotalLength) { - return tecINTERNAL; // LCOV_EXCL_LINE + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::verifyRevealedAmount : callers must pre-validate holder/issuer field lengths"); + return tecINTERNAL; + // LCOV_EXCL_STOP } auto const holderP = toParticipant(holder); @@ -313,7 +339,11 @@ verifyRevealedAmount( if (auditor->publicKey.size() != kEcPubKeyLength || auditor->encryptedAmount.size() != kEcGamalEncryptedTotalLength) { - return tecINTERNAL; // LCOV_EXCL_LINE + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::verifyRevealedAmount : callers must pre-validate auditor field lengths"); + return tecINTERNAL; + // LCOV_EXCL_STOP } auditorP = toParticipant(*auditor); auditorPtr = &auditorP; @@ -337,7 +367,12 @@ checkEncryptedAmountFormat(STObject const& object) if (!object.isFieldPresent(sfHolderEncryptedAmount) || !object.isFieldPresent(sfIssuerEncryptedAmount)) { - return temMALFORMED; // LCOV_EXCL_LINE + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::checkEncryptedAmountFormat : callers already enforce that these fields are " + "present"); + return temMALFORMED; + // LCOV_EXCL_STOP } if (object[sfHolderEncryptedAmount].length() != kEcGamalEncryptedTotalLength || @@ -366,7 +401,12 @@ TER verifySchnorrProof(Slice const& pubKeySlice, Slice const& proofSlice, uint256 const& contextHash) { if (proofSlice.size() != kEcSchnorrProofLength || pubKeySlice.size() != kEcPubKeyLength) - return tecINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE("xrpl::verifySchnorrProof : callers must pre-validate proof/public key length"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } if (mpt_verify_convert_proof(proofSlice.data(), pubKeySlice.data(), contextHash.data()) != 0) return tecBAD_PROOF; @@ -385,7 +425,12 @@ verifyClawbackProof( if (ciphertext.size() != kEcGamalEncryptedTotalLength || pubKeySlice.size() != kEcPubKeyLength || proof.size() != kEcClawbackProofLength) { - return tecINTERNAL; // LCOV_EXCL_LINE + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::verifyClawbackProof : callers must pre-validate ciphertext/public " + "key/proof length"); + return tecINTERNAL; + // LCOV_EXCL_STOP } if (mpt_verify_clawback_proof( @@ -420,7 +465,12 @@ verifySendProof( amountCommitment.size() != kEcPedersenCommitmentLength || balanceCommitment.size() != kEcPedersenCommitmentLength) { - return tecINTERNAL; // LCOV_EXCL_LINE + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::verifySendProof : callers must pre-validate proof/participant/commitment " + "lengths"); + return tecINTERNAL; + // LCOV_EXCL_STOP } std::vector participants; @@ -433,12 +483,22 @@ verifySendProof( if (auditor->publicKey.size() != kEcPubKeyLength || auditor->encryptedAmount.size() != kEcGamalEncryptedTotalLength) { - return tecINTERNAL; // LCOV_EXCL_LINE + // LCOV_EXCL_START + UNREACHABLE("xrpl::verifySendProof : callers must pre-validate auditor field lengths"); + return tecINTERNAL; + // LCOV_EXCL_STOP } participants.push_back(toParticipant(*auditor)); } if (participants.size() != recipientCount) - return tecINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::verifySendProof : participant count must match the requested recipient " + "count"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } if (mpt_verify_send_proof( proof.data(), @@ -468,7 +528,12 @@ verifyConvertBackProof( spendingBalance.size() != kEcGamalEncryptedTotalLength || balanceCommitment.size() != kEcPedersenCommitmentLength) { - return tecINTERNAL; // LCOV_EXCL_LINE + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::verifyConvertBackProof : callers must pre-validate proof/public " + "key/balance/commitment lengths"); + return tecINTERNAL; + // LCOV_EXCL_STOP } if (mpt_verify_convert_back_proof( diff --git a/src/libxrpl/tx/transactors/token/ConfidentialMPTClawback.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTClawback.cpp index 6366e99105..19ec99702a 100644 --- a/src/libxrpl/tx/transactors/token/ConfidentialMPTClawback.cpp +++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTClawback.cpp @@ -1,6 +1,7 @@ #include #include +#include #include #include #include @@ -70,7 +71,14 @@ ConfidentialMPTClawback::preclaim(PreclaimContext const& ctx) // Sanity check: account must be the same as issuer if (sleIssuance->getAccountID(sfIssuer) != account) - return tefINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTClawback::preclaim : preflight already validated the " + "submitter is the issuer"); + return tefINTERNAL; + // LCOV_EXCL_STOP + } // Check if issuance has issuer ElGamal public key if (!sleIssuance->isFieldPresent(sfIssuerEncryptionKey)) @@ -127,7 +135,14 @@ ConfidentialMPTClawback::doApply() auto sleHolderMPToken = view().peek(keylet::mptoken(mptIssuanceID, holder)); if (!sleIssuance || !sleHolderMPToken) - return tecINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTClawback::doApply : preclaim already validated these " + "objects exist"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } auto const clawAmount = ctx_.tx[sfMPTAmount]; @@ -137,11 +152,25 @@ ConfidentialMPTClawback::doApply() // After clawback, the balance should be encrypted zero. auto const encZeroForHolder = encryptCanonicalZeroAmount(holderPubKey, holder, mptIssuanceID); if (!encZeroForHolder) - return tecINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTClawback::doApply : canonical zero encryption cannot fail " + "for an already-valid holder public key"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } auto encZeroForIssuer = encryptCanonicalZeroAmount(issuerPubKey, holder, mptIssuanceID); if (!encZeroForIssuer) - return tecINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTClawback::doApply : canonical zero encryption cannot fail " + "for an already-valid issuer public key"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } // Set holder's confidential balances to encrypted zero (*sleHolderMPToken)[sfConfidentialBalanceInbox] = *encZeroForHolder; @@ -154,14 +183,28 @@ ConfidentialMPTClawback::doApply() // Sanity check: the issuance must have an auditor public key if // auditing is enabled. if (!sleIssuance->isFieldPresent(sfAuditorEncryptionKey)) - return tecINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTClawback::doApply : the holder's auditor balance implies " + "the issuance has an auditor public key"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } auto const auditorPubKey = (*sleIssuance)[sfAuditorEncryptionKey]; auto encZeroForAuditor = encryptCanonicalZeroAmount(auditorPubKey, holder, mptIssuanceID); if (!encZeroForAuditor) - return tecINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTClawback::doApply : canonical zero encryption cannot " + "fail for an already-valid auditor public key"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } (*sleHolderMPToken)[sfAuditorEncryptedBalance] = std::move(*encZeroForAuditor); } diff --git a/src/libxrpl/tx/transactors/token/ConfidentialMPTConvert.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTConvert.cpp index 454eb39ead..5be3892151 100644 --- a/src/libxrpl/tx/transactors/token/ConfidentialMPTConvert.cpp +++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTConvert.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -89,7 +90,14 @@ ConfidentialMPTConvert::preclaim(PreclaimContext const& ctx) // already checked in preflight, but should also check that issuer on the // issuance isn't the account either if (sleIssuance->getAccountID(sfIssuer) == account) - return tefINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTConvert::preclaim : issuer derived from the MPT ID must " + "match the ledger's stored issuer"); + return tefINTERNAL; + // LCOV_EXCL_STOP + } bool const hasAuditor = ctx.tx.isFieldPresent(sfAuditorEncryptedAmount); bool const requiresAuditor = sleIssuance->isFieldPresent(sfAuditorEncryptionKey); @@ -207,11 +215,25 @@ ConfidentialMPTConvert::doApply() auto sleMptoken = view().peek(keylet::mptoken(mptIssuanceID, accountID_)); if (!sleMptoken) - return tecINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTConvert::doApply : preclaim already validated the MPToken " + "exists"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } auto sleIssuance = view().peek(keylet::mptokenIssuance(mptIssuanceID)); if (!sleIssuance) - return tecINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTConvert::doApply : preclaim already validated the issuance " + "exists"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } auto const amtToConvert = ctx_.tx[sfMPTAmount]; auto const amt = (*sleMptoken)[~sfMPTAmount].valueOr(0); @@ -273,7 +295,14 @@ ConfidentialMPTConvert::doApply() if (auditorEc) { if (!sleMptoken->isFieldPresent(sfAuditorEncryptedBalance)) - return tecINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTConvert::doApply : issuance-level auditing implies " + "the MPToken already carries an auditor balance"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } auto sum = homomorphicAdd(*auditorEc, (*sleMptoken)[sfAuditorEncryptedBalance]); if (!sum) @@ -308,7 +337,14 @@ ConfidentialMPTConvert::doApply() (*sleMptoken)[sfHolderEncryptionKey], accountID_, mptIssuanceID); if (!zeroBalance) - return tecINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTConvert::doApply : canonical zero encryption cannot fail " + "for an already-valid holder public key"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } (*sleMptoken)[sfConfidentialBalanceSpending] = std::move(*zeroBalance); } @@ -316,7 +352,12 @@ ConfidentialMPTConvert::doApply() { // both sfIssuerEncryptedBalance and sfConfidentialBalanceInbox should // exist together - return tecINTERNAL; // LCOV_EXCL_LINE + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTConvert::doApply : confidential balance fields must be all " + "present or all absent"); + return tecINTERNAL; + // LCOV_EXCL_STOP } view().update(sleIssuance); diff --git a/src/libxrpl/tx/transactors/token/ConfidentialMPTConvertBack.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTConvertBack.cpp index 87f9e476d6..1e3617ffbd 100644 --- a/src/libxrpl/tx/transactors/token/ConfidentialMPTConvertBack.cpp +++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTConvertBack.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -72,7 +73,14 @@ verifyProofs( std::shared_ptr const& mptoken) { if (!mptoken->isFieldPresent(sfHolderEncryptionKey)) - return tecINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::verifyProofs : preclaim already validated the holder encryption key is " + "present"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } auto const mptIssuanceID = tx[sfMPTokenIssuanceID]; auto const account = tx[sfAccount]; @@ -169,7 +177,14 @@ ConfidentialMPTConvertBack::preclaim(PreclaimContext const& ctx) // already checked in preflight, but should also check that issuer on // the issuance isn't the account either if (sleIssuance->getAccountID(sfIssuer) == account) - return tefINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTConvertBack::preclaim : issuer derived from the MPT ID must " + "match the ledger's stored issuer"); + return tefINTERNAL; + // LCOV_EXCL_STOP + } auto const sleMptoken = ctx.view.read(keylet::mptoken(mptIssuanceID, account)); if (!sleMptoken) @@ -185,7 +200,14 @@ ConfidentialMPTConvertBack::preclaim(PreclaimContext const& ctx) // Sanity check: holder's MPToken must have auditor balance field if auditing // is enabled if (requiresAuditor && !sleMptoken->isFieldPresent(sfAuditorEncryptedBalance)) - return tefINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTConvertBack::preclaim : issuance-level auditing implies the " + "MPToken already carries an auditor balance"); + return tefINTERNAL; + // LCOV_EXCL_STOP + } // if the total circulating confidential balance is smaller than what the // holder is trying to convert back, we know for sure this txn should @@ -215,11 +237,25 @@ ConfidentialMPTConvertBack::doApply() auto sleMptoken = view().peek(keylet::mptoken(mptIssuanceID, accountID_)); if (!sleMptoken) - return tecINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTConvertBack::doApply : preclaim already validated the " + "MPToken exists"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } auto sleIssuance = view().peek(keylet::mptokenIssuance(mptIssuanceID)); if (!sleIssuance) - return tecINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTConvertBack::doApply : preclaim already validated the " + "issuance exists"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } auto const amtToConvertBack = ctx_.tx[sfMPTAmount]; auto const amt = (*sleMptoken)[~sfMPTAmount].valueOr(0); diff --git a/src/libxrpl/tx/transactors/token/ConfidentialMPTMergeInbox.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTMergeInbox.cpp index 0b98382a61..6485578cb4 100644 --- a/src/libxrpl/tx/transactors/token/ConfidentialMPTMergeInbox.cpp +++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTMergeInbox.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -49,7 +50,14 @@ ConfidentialMPTMergeInbox::preclaim(PreclaimContext const& ctx) // already checked in preflight, but should also check that issuer on the // issuance isn't the account either if (sleIssuance->getAccountID(sfIssuer) == ctx.tx[sfAccount]) - return tefINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTMergeInbox::preclaim : issuer derived from the MPT ID must " + "match the ledger's stored issuer"); + return tefINTERNAL; + // LCOV_EXCL_STOP + } auto const sleMptoken = ctx.view.read(keylet::mptoken(ctx.tx[sfMPTokenIssuanceID], ctx.tx[sfAccount])); @@ -82,14 +90,26 @@ ConfidentialMPTMergeInbox::doApply() auto const mptIssuanceID = ctx_.tx[sfMPTokenIssuanceID]; auto sleMptoken = view().peek(keylet::mptoken(mptIssuanceID, accountID_)); if (!sleMptoken) - return tecINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTMergeInbox::doApply : preclaim already validated the " + "MPToken exists"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } // sanity check if (!sleMptoken->isFieldPresent(sfConfidentialBalanceSpending) || !sleMptoken->isFieldPresent(sfConfidentialBalanceInbox) || !sleMptoken->isFieldPresent(sfHolderEncryptionKey)) { - return tecINTERNAL; // LCOV_EXCL_LINE + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTMergeInbox::doApply : preclaim already validated these " + "fields are present"); + return tecINTERNAL; + // LCOV_EXCL_STOP } // Merge inbox into spending: spending = spending + inbox @@ -114,7 +134,14 @@ ConfidentialMPTMergeInbox::doApply() encryptCanonicalZeroAmount((*sleMptoken)[sfHolderEncryptionKey], accountID_, mptIssuanceID); if (!zeroEncryption) - return tecINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTMergeInbox::doApply : canonical zero encryption cannot fail " + "for an already-valid holder public key"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } (*sleMptoken)[sfConfidentialBalanceInbox] = std::move(*zeroEncryption); diff --git a/src/libxrpl/tx/transactors/token/ConfidentialMPTSend.cpp b/src/libxrpl/tx/transactors/token/ConfidentialMPTSend.cpp index f4c7b98c41..e713ae5029 100644 --- a/src/libxrpl/tx/transactors/token/ConfidentialMPTSend.cpp +++ b/src/libxrpl/tx/transactors/token/ConfidentialMPTSend.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -105,7 +106,14 @@ verifySendProofs( { // Sanity check if (!sleSenderMPToken || !sleDestinationMPToken || !sleIssuance) - return tecINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::detail::verifySendProofs : caller must pre-validate sender/destination/" + "issuance existence"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } auto const hasAuditor = ctx.tx.isFieldPresent(sfAuditorEncryptedAmount); @@ -204,7 +212,14 @@ ConfidentialMPTSend::preclaim(PreclaimContext const& ctx) // Sanity check: issuer isn't the sender if (sleIssuance->getAccountID(sfIssuer) == ctx.tx[sfAccount]) - return tefINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTSend::preclaim : issuer derived from the MPT ID must match " + "the ledger's stored issuer"); + return tefINTERNAL; + // LCOV_EXCL_STOP + } // Check sender's MPToken existence auto const sleSenderMPToken = ctx.view.read(keylet::mptoken(mptIssuanceID, account)); @@ -238,7 +253,12 @@ ConfidentialMPTSend::preclaim(PreclaimContext const& ctx) (!sleSenderMPToken->isFieldPresent(sfAuditorEncryptedBalance) || !sleDestinationMPToken->isFieldPresent(sfAuditorEncryptedBalance))) { - return tefINTERNAL; // LCOV_EXCL_LINE + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTSend::preclaim : issuance-level auditing implies both " + "MPTokens already carry an auditor balance"); + return tefINTERNAL; + // LCOV_EXCL_STOP } // Check lock @@ -283,7 +303,14 @@ ConfidentialMPTSend::doApply() auto const sleDestAcct = view().read(keylet::account(destination)); if (!sleSenderMPToken || !sleDestinationMPToken || !sleIssuance || !sleDestAcct) - return tecINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + UNREACHABLE( + "xrpl::ConfidentialMPTSend::doApply : preclaim already validated these objects " + "exist"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } // Deposit preauth authorization was already verified in preclaim. // Remove any expired credentials. @@ -353,7 +380,13 @@ ConfidentialMPTSend::doApply() auto rerandomizedDestEc = rerandomizeCiphertext( destEc, (*sleDestinationMPToken)[sfHolderEncryptionKey], sendChallenge); if (!rerandomizedDestEc) - return tecINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + JLOG(ctx_.journal.error()) + << "ConfidentialMPTSend failed to rerandomize destination inbox ciphertext."; + return tecINTERNAL; + // LCOV_EXCL_STOP + } auto const curInbox = (*sleDestinationMPToken)[sfConfidentialBalanceInbox]; auto newInbox = homomorphicAdd(curInbox, *rerandomizedDestEc); @@ -374,7 +407,13 @@ ConfidentialMPTSend::doApply() auto rerandomizedIssuerEc = rerandomizeCiphertext(issuerEc, (*sleIssuance)[sfIssuerEncryptionKey], sendChallenge); if (!rerandomizedIssuerEc) - return tecINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + JLOG(ctx_.journal.error()) + << "ConfidentialMPTSend failed to rerandomize destination issuer ciphertext."; + return tecINTERNAL; + // LCOV_EXCL_STOP + } auto const curIssuerEnc = (*sleDestinationMPToken)[sfIssuerEncryptedBalance]; auto newIssuerEnc = homomorphicAdd(curIssuerEnc, *rerandomizedIssuerEc); @@ -396,7 +435,13 @@ ConfidentialMPTSend::doApply() auto rerandomizedAuditorEc = rerandomizeCiphertext( *auditorEc, (*sleIssuance)[sfAuditorEncryptionKey], sendChallenge); if (!rerandomizedAuditorEc) - return tecINTERNAL; // LCOV_EXCL_LINE + { + // LCOV_EXCL_START + JLOG(ctx_.journal.error()) + << "ConfidentialMPTSend failed to rerandomize destination auditor ciphertext."; + return tecINTERNAL; + // LCOV_EXCL_STOP + } auto const curAuditorEnc = (*sleDestinationMPToken)[sfAuditorEncryptedBalance]; auto newAuditorEnc = homomorphicAdd(curAuditorEnc, *rerandomizedAuditorEc); From 909cc5bba90879b6187595d46af743fa38df7c51 Mon Sep 17 00:00:00 2001 From: Bryan Date: Mon, 10 Aug 2026 21:37:53 +0000 Subject: [PATCH 061/102] fix: Prevent silent zero AMM clawbacks due to integer MPT rounding (#7704) Co-authored-by: Bart --- .../tx/transactors/dex/AMMClawback.cpp | 9 +- src/test/app/AMMClawbackMPT_test.cpp | 155 +++++++++++++++++- 2 files changed, 156 insertions(+), 8 deletions(-) diff --git a/src/libxrpl/tx/transactors/dex/AMMClawback.cpp b/src/libxrpl/tx/transactors/dex/AMMClawback.cpp index c1ef9f875e..455b2ad5c5 100644 --- a/src/libxrpl/tx/transactors/dex/AMMClawback.cpp +++ b/src/libxrpl/tx/transactors/dex/AMMClawback.cpp @@ -256,7 +256,7 @@ AMMClawback::applyGuts(Sandbox& sb) } if (!isTesSuccess(result)) - return result; // LCOV_EXCL_LINE + return result; if (sb.rules().enabled(fixCleanup3_3_0) && sb.rules().enabled(fixAMMv1_3)) { @@ -353,6 +353,13 @@ AMMClawback::equalWithdrawMatchingOneAmount( auto amountRounded = getRoundedAsset(rules, amountBalance, frac, IsDeposit::No); + // The requested clawback amount is likely too small and results in + // one-sided pool withdrawal due to round off. Fail so the issuer can + // clawback a larger amount. + if (rules.enabled(fixCleanup3_4_0) && + (amountRounded == beast::kZero || amount2Rounded == beast::kZero)) + return {tecAMM_FAILED, STAmount{}, STAmount{}, STAmount{}}; + return AMMWithdraw::withdraw( sb, ammSle, diff --git a/src/test/app/AMMClawbackMPT_test.cpp b/src/test/app/AMMClawbackMPT_test.cpp index 6facafde4a..6c7aa99156 100644 --- a/src/test/app/AMMClawbackMPT_test.cpp +++ b/src/test/app/AMMClawbackMPT_test.cpp @@ -137,7 +137,6 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite AMM amm(env, gw, btc(100), usd(100)); env.close(); amm.deposit(alice, 1'000); - env.close(); // can not clawback when tfMPTCanClawback is not enabled env(amm::ammClawback(gw, alice, btc, usd, std::nullopt), Ter(tecNO_PERMISSION)); @@ -503,6 +502,150 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite } } + void + testAMMClawbackAmountRoundsToZero(FeatureBitset features) + { + // Ensure a clawback that rounds down to zero MPT fails with + // tecAMM_FAILED instead of silently burning the holder's LP. + testcase("test AMMClawback amount that rounds down to zero"); + using namespace jtx; + + Env env(*this, features); + Account const gw{"gateway"}; + Account const alice{"alice"}; + Account const bob{"bob"}; + env.fund(XRP(10'000'000), gw, alice, bob); + env.close(); + + env(fset(gw, asfAllowTrustLineClawback)); + env.close(); + + // The clawed asset (amountRounded) rounds to zero while its XRP + // counterpart is always large. + { + MPTTester const mptBtc( + {.env = env, + .issuer = gw, + .holders = {alice, bob}, + .pay = 1'000, + .flags = tfMPTCanClawback | kMptDexFlags}); + MPT const btc = mptBtc; + + AMM amm(env, alice, btc(3), XRP(333'000)); + amm.deposit(bob, btc(3), XRP(333'000)); + + [[maybe_unused]] auto const [poolBtcBefore, poolXrpBefore, lptBefore] = amm.balances(); + BEAST_EXPECT(poolBtcBefore == btc(6)); + + auto const issuerOABefore = mptBtc.getBalance(gw); + auto const aliceLpBefore = amm.getLPTokensBalance(alice.id()); + auto const bobLpBefore = amm.getLPTokensBalance(bob.id()); + + // Attempt to clawback 1/6th of the BTC pool. When the zero-rounding + // guard is active (gated by fixCleanup3_4_0) the rounded amount + // drops to 0 and should trigger tecAMM_FAILED. + env(amm::ammClawback(gw, alice, btc, XRP, btc(1)), + Ter(features[fixCleanup3_4_0] ? TER{tecAMM_FAILED} : TER{tesSUCCESS})); + env.close(); + + [[maybe_unused]] auto const [poolBtcAfter, poolXrpAfter, lptAfter] = amm.balances(); + auto const issuerOAAfter = mptBtc.getBalance(gw); + auto const aliceLpAfter = amm.getLPTokensBalance(alice.id()); + auto const bobLpAfter = amm.getLPTokensBalance(bob.id()); + + if (features[fixCleanup3_4_0]) + { + // Post-fixCleanup3_4_0: Clawback fails because the BTC balance + // would round to zero. All balances must remain untouched. + BEAST_EXPECT(poolBtcAfter == poolBtcBefore); + BEAST_EXPECT(poolXrpAfter == poolXrpBefore); + BEAST_EXPECT(issuerOAAfter == issuerOABefore); + BEAST_EXPECT(aliceLpAfter == aliceLpBefore); + BEAST_EXPECT(bobLpAfter == bobLpBefore); + } + else + { + // Pre-fixCleanup3_4_0: BTC rounds to zero and the clawback + // silently burns alice's LP without clawing back any BTC. + BEAST_EXPECT(poolBtcAfter == poolBtcBefore); + BEAST_EXPECT(poolXrpAfter < poolXrpBefore); + BEAST_EXPECT(issuerOAAfter == issuerOABefore); + BEAST_EXPECT(aliceLpAfter < aliceLpBefore); + BEAST_EXPECT(bobLpAfter == bobLpBefore); + } + } + + // The pool above only ever rounds the clawed asset (amountRounded) to + // zero; its XRP counterpart is always large. Exercise the other operand + // of the guard (amount2Rounded == 0) with an MPT/MPT pool where the + // *paired* asset is the tiny integer that floors to zero while the + // clawed asset still rounds non-zero. + { + Account const carol{"carol"}; + Account const dan{"dan"}; + env.fund(XRP(10'000'000), carol, dan); + env.close(); + + MPTTester const mptBtc( + {.env = env, + .issuer = gw, + .holders = {carol, dan}, + .pay = 100'000, + .flags = tfMPTCanClawback | kMptDexFlags}); + MPT const btc = mptBtc; + + MPTTester const mptEth( + {.env = env, + .issuer = gw, + .holders = {carol, dan}, + .pay = 1'000, + .flags = tfMPTCanClawback | kMptDexFlags}); + MPT const eth = mptEth; + + // btc pool dwarfs the eth pool, so a ~1/12th claw withdraws a + // non-zero btc amount while the eth counterpart rounds to zero. + AMM amm(env, carol, btc(3'000), eth(3)); + amm.deposit(dan, btc(3'000), eth(3)); + + [[maybe_unused]] auto const [poolBtcBefore, poolEthBefore, lptBefore] = amm.balances(); + BEAST_EXPECT(poolBtcBefore == btc(6'000)); + BEAST_EXPECT(poolEthBefore == eth(6)); + + auto const carolLpBefore = amm.getLPTokensBalance(carol.id()); + auto const danLpBefore = amm.getLPTokensBalance(dan.id()); + + env(amm::ammClawback(gw, carol, btc, eth, btc(500)), + Ter(features[fixCleanup3_4_0] ? TER{tecAMM_FAILED} : TER{tesSUCCESS})); + env.close(); + + [[maybe_unused]] auto const [poolBtcAfter, poolEthAfter, lptAfter] = amm.balances(); + auto const carolLpAfter = amm.getLPTokensBalance(carol.id()); + auto const danLpAfter = amm.getLPTokensBalance(dan.id()); + + if (features[fixCleanup3_4_0]) + { + // Post-fixCleanup3_4_0: clawback fails because the ETH (Asset2) + // balance would round to zero (guard fires via + // amount2Rounded == 0). All balances must remain untouched. + BEAST_EXPECT(poolBtcAfter == poolBtcBefore); + BEAST_EXPECT(poolEthAfter == poolEthBefore); + BEAST_EXPECT(carolLpAfter == carolLpBefore); + BEAST_EXPECT(danLpAfter == danLpBefore); + } + else + { + // Pre-fixCleanup3_4_0: the asymmetric round-off goes through. + // btc is clawed (non-zero) but eth rounds to zero, so the eth + // pool is untouched while carol's LP is burned. This asymmetry + // proves amount2Rounded == 0 is the trigger. + BEAST_EXPECT(poolBtcAfter < poolBtcBefore); + BEAST_EXPECT(poolEthAfter == poolEthBefore); + BEAST_EXPECT(carolLpAfter < carolLpBefore); + BEAST_EXPECT(danLpAfter == danLpBefore); + } + } + } + void testAMMClawbackAll(FeatureBitset features) { @@ -543,7 +686,6 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite // gw clawback all BTC from alice amm.deposit(bob, btc(1'000'000000), usd(2000)); - env.close(); BEAST_EXPECT(amm.expectBalances(btc(3'000'000000), usd(3000), IOUAmount(3000000))); auto aliceBTC = env.balance(alice, btc); @@ -921,7 +1063,6 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite BEAST_EXPECT(amm.expectBalances(btc(2'000'000000), usd(8'000), IOUAmount(4'000'000))); amm.deposit(bob, btc(1'000'000000), usd(4'000)); - env.close(); BEAST_EXPECT(amm.expectBalances(btc(3'000'000000), usd(12'000), IOUAmount(6'000'000))); auto aliceBTC = env.balance(alice, btc); @@ -1361,7 +1502,6 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite env.close(); BEAST_EXPECT(amm.expectBalances(XRP(100), btc(400), IOUAmount(200000))); amm.deposit(alice, btc(400)); - env.close(); BEAST_EXPECT(amm.expectBalances(XRP(100), btc(800), IOUAmount{282842'712474619, -9})); auto aliceBTC = env.balance(alice, MPT(btc)); @@ -1407,7 +1547,6 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite env.close(); BEAST_EXPECT(amm.expectBalances(usd(100), btc(400), IOUAmount(200))); amm.deposit(alice, btc(400)); - env.close(); BEAST_EXPECT(amm.expectBalances(usd(100), btc(800), IOUAmount{282'842712474619, -12})); auto aliceBTC = env.balance(alice, MPT(btc)); @@ -1462,7 +1601,6 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite env.close(); BEAST_EXPECT(amm.expectBalances(usd(100), btc(400), IOUAmount(200))); amm.deposit(alice, btc(400)); - env.close(); BEAST_EXPECT(amm.expectBalances(usd(100), btc(800), IOUAmount{282'842712474619, -12})); auto aliceBTC = env.balance(alice, MPT(btc)); @@ -1669,7 +1807,7 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite env(amm::ammClawback(gw, alice, btc, usd, std::nullopt), Ter(tecNO_PERMISSION)); // Although USD is clawable with asfAllowTrustLineClawback. - // When tfClawTwoAssets is set, we will claw Asser2 as well. + // When tfClawTwoAssets is set, we will claw Asset2 as well. // But Asset2 is not clawable. tfMPTCanClawback was not set for BTC. env(amm::ammClawback(gw, alice, usd, btc, std::nullopt), Txflags(tfClawTwoAssets), @@ -1819,6 +1957,9 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite testInvalidRequest(all); testFeatureDisabled(all); testAMMClawbackAmount(all); + testAMMClawbackAmount(all - fixCleanup3_4_0); + testAMMClawbackAmountRoundsToZero(all); + testAMMClawbackAmountRoundsToZero(all - fixCleanup3_4_0); testAMMClawbackAll(all); testAMMClawbackAmountSameIssuer(all); testAMMClawbackAllSameIssuer(all); From 639943123cced8fe0aff2b6477527558d31b5f6b Mon Sep 17 00:00:00 2001 From: Chenna Keshava B S <21219765+ckeshava@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:49:02 +0000 Subject: [PATCH 062/102] fix: Validate buy/sell flag in nft RPC input (#7725) --- src/test/app/NFToken_test.cpp | 82 +++++++++++++++++++ .../rpc/handlers/orderbook/NFTOffersHelpers.h | 11 +++ 2 files changed, 93 insertions(+) diff --git a/src/test/app/NFToken_test.cpp b/src/test/app/NFToken_test.cpp index a7437eea7f..7fcd34640b 100644 --- a/src/test/app/NFToken_test.cpp +++ b/src/test/app/NFToken_test.cpp @@ -4790,6 +4790,87 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite checkOffers("nft_buy_offers", 501, 2, __LINE__); } + void + testNftXxxOffersMarkerWrongSide(FeatureBitset features) + { + // A pagination marker passed to nft_buy_offers / nft_sell_offers must + // reference an offer on the same side (buy vs. sell) as the directory + // being enumerated. A wrong-side marker is rejected with invalidParams. + // + // Note: the pre-fix code also returned invalidParams for a wrong-side + // marker, but only after scanning the entire target directory (an + // O(directory size) walk usable to burn CPU). The fix short-circuits + // that scan. The scan-avoidance is not observable from the RPC + // response, so this test locks the rejection contract (wrong-side -> + // error, same-side -> success) rather than the performance property. + testcase("nft_buy_offers and nft_sell_offers wrong-side marker"); + + using namespace test::jtx; + + Env env{*this, features}; + + Account const issuer{"issuer"}; + Account const buyer{"buyer"}; + + env.fund(XRP(10000), issuer, buyer); + env.close(); + + // Mint a transferable NFT. + uint256 const nftID{token::getNextID(env, issuer, 0u, tfTransferable)}; + env(token::mint(issuer, 0), Txflags(tfTransferable)); + env.close(); + + // Create one sell offer (from the issuer, who owns the NFT) and one + // buy offer (from the buyer) for the same NFT. + env(token::createOffer(issuer, nftID, XRP(100)), Txflags(tfSellNFToken)); + env(token::createOffer(buyer, nftID, XRP(50)), token::Owner(issuer)); + env.close(); + + // Grab the index of the single offer on each side from the RPC + // response so we can use it as a marker. + auto firstOfferIndex = [this, &env, &nftID](char const* request) { + json::Value params; + params[jss::nft_id] = to_string(nftID); + json::Value const result = env.rpc("json", request, to_string(params))[jss::result]; + BEAST_EXPECT(result.isMember(jss::offers) && result[jss::offers].size() == 1); + return result[jss::offers][0u][jss::nft_offer_index].asString(); + }; + + std::string const sellOfferIndex = firstOfferIndex("nft_sell_offers"); + std::string const buyOfferIndex = firstOfferIndex("nft_buy_offers"); + + auto queryWithMarker = [&env, &nftID](char const* request, std::string const& marker) { + json::Value params; + params[jss::nft_id] = to_string(nftID); + params[jss::marker] = marker; + return env.rpc("json", request, to_string(params))[jss::result]; + }; + + // A marker referencing an offer on the wrong side is rejected with + // invalidParams. + { + // Sell-side marker passed to nft_buy_offers. + json::Value const result = queryWithMarker("nft_buy_offers", sellOfferIndex); + BEAST_EXPECT(result[jss::error].asString() == "invalidParams"); + } + { + // Buy-side marker passed to nft_sell_offers. + json::Value const result = queryWithMarker("nft_sell_offers", buyOfferIndex); + BEAST_EXPECT(result[jss::error].asString() == "invalidParams"); + } + + // A same-side marker is still accepted. With a single offer on each + // side, resuming after it simply yields no further offers. + { + json::Value const result = queryWithMarker("nft_buy_offers", buyOfferIndex); + BEAST_EXPECT(!result.isMember(jss::error)); + } + { + json::Value const result = queryWithMarker("nft_sell_offers", sellOfferIndex); + BEAST_EXPECT(!result.isMember(jss::error)); + } + } + void testNFTokenNegOffer(FeatureBitset features) { @@ -7305,6 +7386,7 @@ protected: testNFTokenWithTickets(features); testNFTokenDeleteAccount(features); testNftXxxOffers(features); + testNftXxxOffersMarkerWrongSide(features); testNFTokenNegOffer(features); testIOUWithTransferFee(features); testBrokeredSaleToSelf(features); diff --git a/src/xrpld/rpc/handlers/orderbook/NFTOffersHelpers.h b/src/xrpld/rpc/handlers/orderbook/NFTOffersHelpers.h index e03830ae0d..21bf3f8be8 100644 --- a/src/xrpld/rpc/handlers/orderbook/NFTOffersHelpers.h +++ b/src/xrpld/rpc/handlers/orderbook/NFTOffersHelpers.h @@ -93,6 +93,17 @@ enumerateNFTOffers(rpc::JsonContext& context, uint256 const& nftId, Keylet const if (!sle || nftId != sle->getFieldH256(sfNFTokenID)) return rpcError(RpcInvalidParams); + // Reject a marker that references an offer on the opposite side + // (buy vs. sell) of the directory being enumerated. Without this + // check the marker's node hint points into the other directory, so + // forEachItemAfter never finds `startAfter` and instead scans every + // page of `directory` before returning invalidParams -- turning an + // O(1) rejection into an O(directory size) walk. + auto const offerDir = + sle->isFlag(lsfSellNFToken) ? keylet::nftSells(nftId) : keylet::nftBuys(nftId); + if (directory.key != offerDir.key) + return rpcError(RpcInvalidParams); + startHint = sle->getFieldU64(sfNFTokenOfferNode); appendNftOfferJson(context.app, sle, jsonOffers); offers.reserve(reserve); From 0a572833eae96c28a30e7f5dcc143fb26cfa33bd Mon Sep 17 00:00:00 2001 From: Alex Kremer Date: Tue, 11 Aug 2026 12:38:40 +0000 Subject: [PATCH 063/102] chore: Gtest migration followups second pass (#7888) --- .cspell.config.yaml | 1 + cmake/XrplCov.cmake | 1 + include/xrpl/basics/Buffer.h | 14 + src/benchmarks/libxrpl/nodestore/Backend.cpp | 19 +- .../libxrpl/nodestore/NodeStoreBench.h | 5 +- src/tests/libxrpl/basics/Buffer.cpp | 517 +++++++++++------- src/tests/libxrpl/basics/IntrusiveShared.cpp | 11 +- src/tests/libxrpl/basics/base_uint.cpp | 227 ++++---- src/tests/libxrpl/shamap/SHAMap.cpp | 3 +- 9 files changed, 446 insertions(+), 352 deletions(-) diff --git a/.cspell.config.yaml b/.cspell.config.yaml index 21b0145f43..bb763e9935 100644 --- a/.cspell.config.yaml +++ b/.cspell.config.yaml @@ -365,6 +365,7 @@ words: - xchain - ximinez - XMACRO + - xored - xrpkuwait - xrpl - xrpld diff --git a/cmake/XrplCov.cmake b/cmake/XrplCov.cmake index 86ba534a88..05d9ed3806 100644 --- a/cmake/XrplCov.cmake +++ b/cmake/XrplCov.cmake @@ -44,6 +44,7 @@ setup_target_for_coverage_gcovr( EXCLUDE "src/test" "src/tests" + "src/benchmarks" "include/xrpl/beast/test" "include/xrpl/beast/unit_test" "${CMAKE_BINARY_DIR}/pb-xrpl.libpb" diff --git a/include/xrpl/basics/Buffer.h b/include/xrpl/basics/Buffer.h index 705a5ef51a..00a6b7ecf9 100644 --- a/include/xrpl/basics/Buffer.h +++ b/include/xrpl/basics/Buffer.h @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -156,6 +157,19 @@ public: } /** @} */ + /** + * Set every byte in the buffer to the given value. + * + * The size is unchanged, and this is a no-op on an empty buffer. + * + * @param value the byte to write to every position. + */ + void + fill(std::uint8_t value) noexcept + { + std::fill_n(p_.get(), size_, value); + } + /** * Reset the buffer. * All memory is deallocated. The resulting size is 0. diff --git a/src/benchmarks/libxrpl/nodestore/Backend.cpp b/src/benchmarks/libxrpl/nodestore/Backend.cpp index cd3e15bd65..9d5937f869 100644 --- a/src/benchmarks/libxrpl/nodestore/Backend.cpp +++ b/src/benchmarks/libxrpl/nodestore/Backend.cpp @@ -41,10 +41,11 @@ struct RunState release() { harness.reset(); - Batch{}.swap(present); - Batch{}.swap(recent); - std::vector{}.swap(missing); - std::vector{}.swap(shuffle); + present = Batch{}; + recent = Batch{}; + missing = std::vector{}; + shuffle = std::vector{}; + avgPayload = 0; } }; @@ -239,9 +240,13 @@ registerWorkload(BackendConfig const& bc, Workload const& w) if (!w.pinToPool) { auto rs = std::make_shared(); - auto* b = benchmark::RegisterBenchmark(name, makeRunner(w, cfg, rs)); - b->RangeMultiplier(10)->Range(kPoolSizes.front(), kPoolSizes.back()); - b->Threads(1)->Threads(4)->Threads(8)->UseRealTime(); + benchmark::RegisterBenchmark(name, makeRunner(w, cfg, rs)) + ->RangeMultiplier(10) + ->Range(kPoolSizes.front(), kPoolSizes.back()) + ->Threads(1) + ->Threads(4) + ->Threads(8) + ->UseRealTime(); return; } diff --git a/src/benchmarks/libxrpl/nodestore/NodeStoreBench.h b/src/benchmarks/libxrpl/nodestore/NodeStoreBench.h index debdc5d47a..6122dd2535 100644 --- a/src/benchmarks/libxrpl/nodestore/NodeStoreBench.h +++ b/src/benchmarks/libxrpl/nodestore/NodeStoreBench.h @@ -297,12 +297,11 @@ struct BackendConfig inline std::vector const& backendConfigs() { + // Use factory settings for each DB static std::vector const kConfigs = { {.name = "nudb", .config = "type=nudb"}, #if XRPL_ROCKSDB_AVAILABLE - {.name = "rocksdb", - .config = "type=rocksdb,open_files=2000,filter_bits=12,cache_mb=256," - "file_size_mb=8,file_size_mult=2"}, + {.name = "rocksdb", .config = "type=rocksdb"}, #endif }; return kConfigs; diff --git a/src/tests/libxrpl/basics/Buffer.cpp b/src/tests/libxrpl/basics/Buffer.cpp index 9cdf610282..a3f78e8bcf 100644 --- a/src/tests/libxrpl/basics/Buffer.cpp +++ b/src/tests/libxrpl/basics/Buffer.cpp @@ -4,6 +4,7 @@ #include +#include #include #include #include @@ -12,8 +13,18 @@ namespace xrpl::test { +static_assert(std::is_nothrow_move_constructible_v); +static_assert(std::is_nothrow_move_assignable_v); + struct BufferTest : public ::testing::Test { + static constexpr auto kRandomData = std::to_array( + {0xa8, 0xa1, 0x38, 0x45, 0x23, 0xec, 0xe4, 0x23, 0x71, 0x6d, 0x2a, + 0x18, 0xb4, 0x70, 0xcb, 0xf5, 0xac, 0x2d, 0x89, 0x4d, 0x19, 0x9c, + 0xf0, 0x2c, 0x15, 0xd1, 0xf9, 0x9b, 0x66, 0xd2, 0x30, 0xd3}); + + static constexpr std::size_t kHalf = kRandomData.size() / 2; + static bool sane(Buffer const& b) { @@ -22,239 +33,321 @@ struct BufferTest : public ::testing::Test return b.data() != nullptr; } + + /** + * Check the state Buffer documents for a moved-from buffer: "the other buffer is reset", i.e. + * empty and sane. + * + * Zeroing the size is not incidental tidiness. Moving the member unique_ptr nulls the data + * pointer whether Buffer wants it or not, so a moved-from buffer that kept its old size would + * lie about itself everywhere: alloc() would take its `n == size_` early-out and hand back a + * null pointer while still reporting the old size, fill() would run std::fill_n over a null + * pointer, and the Slice conversion would publish {nullptr, oldSize} to callers. A moved-from + * Buffer has to be a usable empty Buffer rather than a landmine, which is why the tests below + * assert this state instead of treating a moved-from buffer as untouchable. + */ + static void + checkEmptyAfterMove(Buffer const& buf) + { + EXPECT_TRUE(sane(buf)); + EXPECT_TRUE(buf.empty()); + } + + Buffer const emptyBuffer; + Buffer const firstHalf{kRandomData.data(), kHalf}; + Buffer const secondHalf{kRandomData.data() + kHalf, kHalf}; + Buffer const whole{kRandomData.data(), kRandomData.size()}; }; -TEST_F(BufferTest, buffer) +TEST_F(BufferTest, default_constructed_is_empty) { - std::uint8_t const data[] = {0xa8, 0xa1, 0x38, 0x45, 0x23, 0xec, 0xe4, 0x23, 0x71, 0x6d, 0x2a, - 0x18, 0xb4, 0x70, 0xcb, 0xf5, 0xac, 0x2d, 0x89, 0x4d, 0x19, 0x9c, - 0xf0, 0x2c, 0x15, 0xd1, 0xf9, 0x9b, 0x66, 0xd2, 0x30, 0xd3}; + Buffer const b; - Buffer const b0; - EXPECT_TRUE(sane(b0)); - EXPECT_TRUE(b0.empty()); + EXPECT_TRUE(sane(b)); + EXPECT_TRUE(b.empty()); + EXPECT_EQ(b.data(), nullptr); +} - Buffer b1{0}; - EXPECT_TRUE(sane(b1)); - EXPECT_TRUE(b1.empty()); - std::memcpy(b1.alloc(16), data, 16); - EXPECT_TRUE(sane(b1)); - EXPECT_FALSE(b1.empty()); - EXPECT_EQ(b1.size(), 16); +TEST_F(BufferTest, zero_sized_construction_is_empty) +{ + Buffer const b{0}; - Buffer b2{b1.size()}; - EXPECT_TRUE(sane(b2)); - EXPECT_FALSE(b2.empty()); - EXPECT_EQ(b2.size(), b1.size()); - std::memcpy(b2.data(), data + 16, 16); + EXPECT_TRUE(sane(b)); + EXPECT_TRUE(b.empty()); +} - Buffer b3{data, sizeof(data)}; - EXPECT_TRUE(sane(b3)); - EXPECT_FALSE(b3.empty()); - EXPECT_EQ(b3.size(), sizeof(data)); - EXPECT_EQ(std::memcmp(b3.data(), data, b3.size()), 0); +TEST_F(BufferTest, alloc_grows_an_empty_buffer) +{ + Buffer b{0}; + std::memcpy(b.alloc(kHalf), kRandomData.data(), kHalf); - // Check equality and inequality comparisons. - // For code readability, we want to use general - // EXPECT_TRUE instead of specific EXPECT_EQ etc. - EXPECT_TRUE(b0 == b0); - EXPECT_TRUE(b0 != b1); - EXPECT_TRUE(b1 == b1); - EXPECT_TRUE(b1 != b2); - EXPECT_TRUE(b2 != b3); + EXPECT_TRUE(sane(b)); + EXPECT_FALSE(b.empty()); + EXPECT_EQ(b.size(), kHalf); + EXPECT_EQ(b, firstHalf); +} - // Check copy constructors and copy assignments: - { - Buffer x{b0}; - EXPECT_EQ(x, b0); - EXPECT_TRUE(sane(x)); - Buffer y{b1}; - EXPECT_EQ(y, b1); - EXPECT_TRUE(sane(y)); - x = b2; - EXPECT_EQ(x, b2); - EXPECT_TRUE(sane(x)); - x = y; - EXPECT_EQ(x, y); - EXPECT_TRUE(sane(x)); - y = b3; - EXPECT_EQ(y, b3); - EXPECT_TRUE(sane(y)); - x = b0; - EXPECT_EQ(x, b0); - EXPECT_TRUE(sane(x)); +TEST_F(BufferTest, sized_construction_reserves_without_filling) +{ + Buffer b{kHalf}; + + EXPECT_TRUE(sane(b)); + EXPECT_FALSE(b.empty()); + EXPECT_EQ(b.size(), kHalf); + + std::memcpy(b.data(), kRandomData.data() + kHalf, kHalf); + EXPECT_EQ(b, secondHalf); +} + +TEST_F(BufferTest, construction_copies_raw_memory) +{ + Buffer const b{kRandomData.data(), kRandomData.size()}; + + EXPECT_TRUE(sane(b)); + EXPECT_FALSE(b.empty()); + EXPECT_EQ(b.size(), kRandomData.size()); + EXPECT_EQ(std::memcmp(b.data(), kRandomData.data(), b.size()), 0); +} + +TEST_F(BufferTest, equality_compares_contents) +{ + // Uses EXPECT_TRUE rather than EXPECT_EQ/EXPECT_NE because the operators are what is under test + // here. + EXPECT_TRUE(emptyBuffer == emptyBuffer); + EXPECT_TRUE(firstHalf == firstHalf); + + EXPECT_TRUE(emptyBuffer != firstHalf); + EXPECT_TRUE(firstHalf != secondHalf); + EXPECT_TRUE(secondHalf != whole); +} + +TEST_F(BufferTest, copy_construction) +{ + Buffer const fromEmpty{emptyBuffer}; + EXPECT_TRUE(sane(fromEmpty)); + EXPECT_EQ(fromEmpty, emptyBuffer); + + Buffer const fromNonEmpty{firstHalf}; + EXPECT_TRUE(sane(fromNonEmpty)); + EXPECT_EQ(fromNonEmpty, firstHalf); +} + +TEST_F(BufferTest, copy_assignment) +{ + Buffer b{emptyBuffer}; + + // empty <- non-empty + b = secondHalf; + EXPECT_TRUE(sane(b)); + EXPECT_EQ(b, secondHalf); + + // non-empty <- non-empty of a different size + b = whole; + EXPECT_TRUE(sane(b)); + EXPECT_EQ(b, whole); + + // non-empty <- empty + b = emptyBuffer; + EXPECT_TRUE(sane(b)); + EXPECT_EQ(b, emptyBuffer); +} + +TEST_F(BufferTest, self_assignment_preserves_contents) +{ #ifdef __clang__ #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wself-assign-overloaded" #endif - x = x; - EXPECT_EQ(x, b0); - EXPECT_TRUE(sane(x)); - y = y; - EXPECT_EQ(y, b3); - EXPECT_TRUE(sane(y)); + Buffer emptyCopy{emptyBuffer}; + emptyCopy = emptyCopy; + EXPECT_TRUE(sane(emptyCopy)); + EXPECT_EQ(emptyCopy, emptyBuffer); + + Buffer wholeCopy{whole}; + wholeCopy = wholeCopy; + EXPECT_TRUE(sane(wholeCopy)); + EXPECT_EQ(wholeCopy, whole); #ifdef __clang__ #pragma clang diagnostic pop #endif - } +} - // Check move constructor & move assignments: +TEST_F(BufferTest, move_construct_from_empty) +{ + Buffer source; + Buffer const moved{std::move(source)}; + + checkEmptyAfterMove(source); // NOLINT(bugprone-use-after-move) + EXPECT_TRUE(sane(moved)); + EXPECT_TRUE(moved.empty()); +} + +TEST_F(BufferTest, move_construct_from_non_empty) +{ + Buffer source{firstHalf}; + Buffer const moved{std::move(source)}; + + checkEmptyAfterMove(source); // NOLINT(bugprone-use-after-move) + EXPECT_TRUE(sane(moved)); + EXPECT_EQ(moved, firstHalf); +} + +TEST_F(BufferTest, move_assign_empty_to_empty) +{ + Buffer target; + Buffer source; + + target = std::move(source); + + EXPECT_TRUE(sane(target)); + EXPECT_TRUE(target.empty()); + checkEmptyAfterMove(source); // NOLINT(bugprone-use-after-move) +} + +TEST_F(BufferTest, move_assign_non_empty_to_empty) +{ + Buffer target; + Buffer source{firstHalf}; + + target = std::move(source); + + EXPECT_TRUE(sane(target)); + EXPECT_EQ(target, firstHalf); + checkEmptyAfterMove(source); // NOLINT(bugprone-use-after-move) +} + +TEST_F(BufferTest, move_assign_empty_to_non_empty) +{ + Buffer target{firstHalf}; + Buffer source; + + target = std::move(source); + + EXPECT_TRUE(sane(target)); + EXPECT_TRUE(target.empty()); + checkEmptyAfterMove(source); // NOLINT(bugprone-use-after-move) +} + +TEST_F(BufferTest, move_assign_non_empty_to_non_empty) +{ + Buffer target{firstHalf}; + Buffer sameSize{secondHalf}; + Buffer largerSize{whole}; + + target = std::move(sameSize); + EXPECT_TRUE(sane(target)); + EXPECT_EQ(target, secondHalf); + checkEmptyAfterMove(sameSize); // NOLINT(bugprone-use-after-move) + + target = std::move(largerSize); + EXPECT_TRUE(sane(target)); + EXPECT_EQ(target, whole); + checkEmptyAfterMove(largerSize); // NOLINT(bugprone-use-after-move) +} + +TEST_F(BufferTest, construction_from_slice) +{ + Buffer const fromEmpty{static_cast(emptyBuffer)}; + EXPECT_TRUE(sane(fromEmpty)); + EXPECT_EQ(fromEmpty, emptyBuffer); + + Buffer const fromNonEmpty{static_cast(whole)}; + EXPECT_TRUE(sane(fromNonEmpty)); + EXPECT_EQ(fromNonEmpty, whole); +} + +TEST_F(BufferTest, assignment_from_slice) +{ + Buffer b; + + // empty <- empty slice + b = static_cast(emptyBuffer); + EXPECT_TRUE(sane(b)); + EXPECT_EQ(b, emptyBuffer); + + // empty <- non-empty slice + b = static_cast(firstHalf); + EXPECT_TRUE(sane(b)); + EXPECT_EQ(b, firstHalf); + + // non-empty <- non-empty slice + b = static_cast(secondHalf); + EXPECT_TRUE(sane(b)); + EXPECT_EQ(b, secondHalf); + + // non-empty <- empty slice + b = static_cast(emptyBuffer); + EXPECT_TRUE(sane(b)); + EXPECT_EQ(b, emptyBuffer); +} + +TEST_F(BufferTest, resize_allocates_and_clear_releases) +{ + auto check = [](Buffer const& original, std::size_t size) { + SCOPED_TRACE(::testing::Message() << "size: " << size); + + Buffer b{original}; + + // Resizing to zero is equivalent to clearing. + b(size); + EXPECT_TRUE(sane(b)); + EXPECT_EQ(b.size(), size); + EXPECT_EQ(b.data() == nullptr, size == 0); + + b(size + 1); + EXPECT_TRUE(sane(b)); + EXPECT_EQ(b.size(), size + 1); + EXPECT_NE(b.data(), nullptr); + + b.clear(); + EXPECT_TRUE(sane(b)); + EXPECT_TRUE(b.empty()); + EXPECT_EQ(b.data(), nullptr); + + // clear() is idempotent. + b.clear(); + EXPECT_TRUE(sane(b)); + EXPECT_TRUE(b.empty()); + EXPECT_EQ(b.data(), nullptr); + }; + + for (auto size = 0uz; size < kHalf; ++size) { - static_assert(std::is_nothrow_move_constructible_v); - static_assert(std::is_nothrow_move_assignable_v); - - { // Move-construct from empty buf - Buffer x; - Buffer const y{std::move(x)}; - EXPECT_TRUE(sane(x)); // NOLINT(bugprone-use-after-move) - EXPECT_TRUE(x.empty()); // NOLINT(bugprone-use-after-move) - EXPECT_TRUE(sane(y)); - EXPECT_TRUE(y.empty()); - EXPECT_EQ(x, y); // NOLINT(bugprone-use-after-move) - } - - { // Move-construct from non-empty buf - Buffer x{b1}; - Buffer const y{std::move(x)}; - EXPECT_TRUE(sane(x)); // NOLINT(bugprone-use-after-move) - EXPECT_TRUE(x.empty()); // NOLINT(bugprone-use-after-move) - EXPECT_TRUE(sane(y)); - EXPECT_EQ(y, b1); - } - - { // Move assign empty buf to empty buf - Buffer x; - Buffer y; - - x = std::move(y); - EXPECT_TRUE(sane(x)); - EXPECT_TRUE(x.empty()); - EXPECT_TRUE(sane(y)); // NOLINT(bugprone-use-after-move) - EXPECT_TRUE(y.empty()); // NOLINT(bugprone-use-after-move) - } - - { // Move assign non-empty buf to empty buf - Buffer x; - Buffer y{b1}; - - x = std::move(y); - EXPECT_TRUE(sane(x)); - EXPECT_EQ(x, b1); - EXPECT_TRUE(sane(y)); // NOLINT(bugprone-use-after-move) - EXPECT_TRUE(y.empty()); // NOLINT(bugprone-use-after-move) - } - - { // Move assign empty buf to non-empty buf - Buffer x{b1}; - Buffer y; - - x = std::move(y); - EXPECT_TRUE(sane(x)); - EXPECT_TRUE(x.empty()); - EXPECT_TRUE(sane(y)); // NOLINT(bugprone-use-after-move) - EXPECT_TRUE(y.empty()); // NOLINT(bugprone-use-after-move) - } - - { // Move assign non-empty buf to non-empty buf - Buffer x{b1}; - Buffer y{b2}; - Buffer z{b3}; - - x = std::move(y); - EXPECT_TRUE(sane(x)); - EXPECT_FALSE(x.empty()); - EXPECT_TRUE(sane(y)); // NOLINT(bugprone-use-after-move) - EXPECT_TRUE(y.empty()); // NOLINT(bugprone-use-after-move) - - x = std::move(z); - EXPECT_TRUE(sane(x)); - EXPECT_FALSE(x.empty()); - EXPECT_TRUE(sane(z)); // NOLINT(bugprone-use-after-move) - EXPECT_TRUE(z.empty()); // NOLINT(bugprone-use-after-move) - } - } - - { - Buffer w{static_cast(b0)}; - EXPECT_TRUE(sane(w)); - EXPECT_EQ(w, b0); - - Buffer x{static_cast(b1)}; - EXPECT_TRUE(sane(x)); - EXPECT_EQ(x, b1); - - Buffer y{static_cast(b2)}; - EXPECT_TRUE(sane(y)); - EXPECT_EQ(y, b2); - - Buffer z{static_cast(b3)}; - EXPECT_TRUE(sane(z)); - EXPECT_EQ(z, b3); - - // Assign empty slice to empty buffer - w = static_cast(b0); - EXPECT_TRUE(sane(w)); - EXPECT_EQ(w, b0); - - // Assign non-empty slice to empty buffer - w = static_cast(b1); - EXPECT_TRUE(sane(w)); - EXPECT_EQ(w, b1); - - // Assign non-empty slice to non-empty buffer - x = static_cast(b2); - EXPECT_TRUE(sane(x)); - EXPECT_EQ(x, b2); - - // Assign non-empty slice to non-empty buffer - y = static_cast(z); - EXPECT_TRUE(sane(y)); - EXPECT_EQ(y, z); - - // Assign empty slice to non-empty buffer: - z = static_cast(b0); - EXPECT_TRUE(sane(z)); - EXPECT_EQ(z, b0); - } - - { - auto test = [](Buffer const& b, std::size_t i) { - Buffer x{b}; - - // Try to allocate some number of bytes, possibly - // zero (which means clear) and sanity check - x(i); - EXPECT_TRUE(sane(x)); - EXPECT_EQ(x.size(), i); - EXPECT_EQ((x.data() == nullptr), (i == 0)); - - // Try to allocate some more data (always non-zero) - x(i + 1); - EXPECT_TRUE(sane(x)); - EXPECT_EQ(x.size(), i + 1); - EXPECT_NE(x.data(), nullptr); - - // Try to clear: - x.clear(); - EXPECT_TRUE(sane(x)); - EXPECT_TRUE(x.empty()); - EXPECT_EQ(x.data(), nullptr); - - // Try to clear again: - x.clear(); - EXPECT_TRUE(sane(x)); - EXPECT_TRUE(x.empty()); - EXPECT_EQ(x.data(), nullptr); - }; - - for (std::size_t i = 0; i < 16; ++i) - { - test(b0, i); - test(b1, i); - } + check(emptyBuffer, size); + check(firstHalf, size); } } +TEST_F(BufferTest, fill_sets_every_byte) +{ + Buffer b{4}; + b.fill(0xab); + + EXPECT_EQ(b.size(), 4); + for (auto const byte : Slice{b}) + EXPECT_EQ(byte, 0xab); +} + +TEST_F(BufferTest, fill_overwrites_and_keeps_size) +{ + Buffer b{4}; + b.fill(0xab); + b.fill(0x00); + + EXPECT_EQ(b.size(), 4); + for (auto const byte : Slice{b}) + EXPECT_EQ(byte, 0x00); +} + +TEST_F(BufferTest, fill_on_empty_buffer_is_a_noop) +{ + Buffer empty; + empty.fill(0xff); + + EXPECT_TRUE(empty.empty()); + EXPECT_EQ(empty.data(), nullptr); +} + } // namespace xrpl::test diff --git a/src/tests/libxrpl/basics/IntrusiveShared.cpp b/src/tests/libxrpl/basics/IntrusiveShared.cpp index b9f8930b7b..c6c9fcfef0 100644 --- a/src/tests/libxrpl/basics/IntrusiveShared.cpp +++ b/src/tests/libxrpl/basics/IntrusiveShared.cpp @@ -92,6 +92,7 @@ public: static constexpr std::size_t kMaxStates = 128; static std::array, kMaxStates> state; static std::atomic nextId; + static TrackedState getState(std::size_t id) { @@ -100,13 +101,12 @@ public: return state[id].load(std::memory_order_acquire); } + static void resetStates(bool resetCallback) { for (std::size_t i = 0; i < kMaxStates; ++i) - { state[i].store(TrackedState::Uninitialized, std::memory_order_release); - } nextId.store(0, std::memory_order_release); if (resetCallback) TIBase::tracingCallback = [](TrackedState, std::optional) {}; @@ -120,6 +120,7 @@ public: { TIBase::resetStates(resetCallback); } + ~ResetStatesGuard() { TIBase::resetStates(resetCallback); @@ -130,6 +131,7 @@ public: { state[id].store(TrackedState::Alive, std::memory_order_relaxed); } + ~TIBase() override { using enum TrackedState; @@ -218,9 +220,7 @@ TEST(IntrusiveSharedTest, basics) EXPECT_EQ(TIBase::getState(id), Alive); EXPECT_EQ(b->useCount(), 1); for (auto i = 0uz; i < 10; ++i) - { strong.push_back(b); - } b.reset(); EXPECT_EQ(TIBase::getState(id), Alive); strong.resize(strong.size() - 1); @@ -244,8 +244,7 @@ TEST(IntrusiveSharedTest, basics) EXPECT_EQ(TIBase::getState(id), PartiallyDeleted); while (!weak.empty()) { - weak.resize(weak.size() - 1); - if (!weak.empty()) + if (weak.resize(weak.size() - 1); !weak.empty()) { EXPECT_EQ(TIBase::getState(id), PartiallyDeleted); } diff --git a/src/tests/libxrpl/basics/base_uint.cpp b/src/tests/libxrpl/basics/base_uint.cpp index 10795f4563..969705b5b7 100644 --- a/src/tests/libxrpl/basics/base_uint.cpp +++ b/src/tests/libxrpl/basics/base_uint.cpp @@ -6,6 +6,7 @@ #include +#include #include #include @@ -205,125 +206,119 @@ TEST_F(BaseUintTest, base_uint) Blob const raw{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12}; EXPECT_EQ(BaseUInt96::kBytes, raw.size()); - BaseUInt96 u = BaseUInt96::fromRaw(raw); - uset.insert(u); - EXPECT_EQ(raw.size(), u.size()); - EXPECT_EQ(to_string(u), "0102030405060708090A0B0C"); - EXPECT_EQ(toShortString(u), "01020304..."); - EXPECT_EQ(*u.data(), 1); - EXPECT_EQ(u.signum(), 1); - EXPECT_FALSE(!u); - EXPECT_FALSE(u.isZero()); - EXPECT_TRUE(u.isNonZero()); - unsigned char t = 0; - for (auto& d : u) - { - EXPECT_EQ(d, ++t); - } + BaseUInt96 ascending = BaseUInt96::fromRaw(raw); + uset.insert(ascending); + EXPECT_EQ(raw.size(), ascending.size()); + EXPECT_EQ(to_string(ascending), "0102030405060708090A0B0C"); + EXPECT_EQ(toShortString(ascending), "01020304..."); + EXPECT_EQ(*ascending.data(), 1); + EXPECT_EQ(ascending.signum(), 1); + EXPECT_FALSE(!ascending); + EXPECT_FALSE(ascending.isZero()); + EXPECT_TRUE(ascending.isNonZero()); + unsigned char expectedByte = 0; + for (auto& byte : ascending) + EXPECT_EQ(byte, ++expectedByte); - // Test hash_append by "hashing" with a no-op hasher (h) + // Test hash_append by "hashing" with a no-op hasher (hasher) // and then extracting the bytes that were written during hashing - // back into another base_uint (w) for comparison with the original - Nonhash<96> h{}; - hash_append(h, u); - BaseUInt96 const w = - BaseUInt96::fromRaw(std::vector(h.data.begin(), h.data.end())); - EXPECT_EQ(w, u); + // back into another base_uint (rehashed) for comparison with the original + Nonhash<96> hasher{}; + hash_append(hasher, ascending); + BaseUInt96 const rehashed = + BaseUInt96::fromRaw(std::vector(hasher.data.begin(), hasher.data.end())); + EXPECT_EQ(rehashed, ascending); - BaseUInt96 v{~u}; - uset.insert(v); - EXPECT_EQ(to_string(v), "FEFDFCFBFAF9F8F7F6F5F4F3"); - EXPECT_EQ(toShortString(v), "FEFDFCFB..."); - EXPECT_EQ(*v.data(), 0xfe); - EXPECT_EQ(v.signum(), 1); - EXPECT_FALSE(!v); - EXPECT_FALSE(v.isZero()); - EXPECT_TRUE(v.isNonZero()); + BaseUInt96 complement{~ascending}; + uset.insert(complement); + EXPECT_EQ(to_string(complement), "FEFDFCFBFAF9F8F7F6F5F4F3"); + EXPECT_EQ(toShortString(complement), "FEFDFCFB..."); + EXPECT_EQ(*complement.data(), 0xfe); + EXPECT_EQ(complement.signum(), 1); + EXPECT_FALSE(!complement); + EXPECT_FALSE(complement.isZero()); + EXPECT_TRUE(complement.isNonZero()); - t = 0xff; - for (auto& d : v) - { - EXPECT_EQ(d, --t); - } + expectedByte = 0xff; + for (auto& byte : complement) + EXPECT_EQ(byte, --expectedByte); - EXPECT_LT(u, v); - EXPECT_GT(v, u); + EXPECT_LT(ascending, complement); + EXPECT_GT(complement, ascending); - v = u; - EXPECT_EQ(v, u); + complement = ascending; + EXPECT_EQ(complement, ascending); - BaseUInt96 z{beast::kZero}; - uset.insert(z); - EXPECT_EQ(to_string(z), "000000000000000000000000"); - EXPECT_EQ(toShortString(z), "00000000..."); - EXPECT_EQ(*z.data(), 0); - EXPECT_EQ(*z.begin(), 0); - EXPECT_EQ(*std::prev(z.end(), 1), 0); - EXPECT_EQ(z.signum(), 0); - EXPECT_TRUE(!z); - EXPECT_TRUE(z.isZero()); - EXPECT_FALSE(z.isNonZero()); - for (auto& d : z) - { - EXPECT_EQ(d, 0); - } + BaseUInt96 zero{beast::kZero}; + uset.insert(zero); + EXPECT_EQ(to_string(zero), "000000000000000000000000"); + EXPECT_EQ(toShortString(zero), "00000000..."); + EXPECT_EQ(*zero.data(), 0); + EXPECT_EQ(*zero.begin(), 0); + EXPECT_EQ(*std::prev(zero.end(), 1), 0); + EXPECT_EQ(zero.signum(), 0); + EXPECT_TRUE(!zero); + EXPECT_TRUE(zero.isZero()); + EXPECT_FALSE(zero.isNonZero()); + for (auto& byte : zero) + EXPECT_EQ(byte, 0); { // There are several ways to create a zero. beast::kZero is tested above. Test some // others. - BaseUInt96 const z1; - EXPECT_EQ(z1, z) << to_string(z1); + BaseUInt96 const defaultZero; + EXPECT_EQ(defaultZero, zero) << to_string(defaultZero); - BaseUInt96 const z2{}; - EXPECT_EQ(z2, z) << to_string(z2); + BaseUInt96 const bracedZero{}; + EXPECT_EQ(bracedZero, zero) << to_string(bracedZero); - BaseUInt96 const z3{0u}; - EXPECT_EQ(z3, z) << to_string(z3); + BaseUInt96 const zeroFromUInt{0u}; + EXPECT_EQ(zeroFromUInt, zero) << to_string(zeroFromUInt); } - BaseUInt96 n{z}; - n++; - EXPECT_EQ(n, BaseUInt96(1)); - n--; - EXPECT_EQ(n, beast::kZero); - EXPECT_EQ(n, z); - n--; - EXPECT_EQ(to_string(n), "FFFFFFFFFFFFFFFFFFFFFFFF"); - EXPECT_EQ(toShortString(n), "FFFFFFFF..."); - n = beast::kZero; - EXPECT_EQ(n, z); + BaseUInt96 counter{zero}; + counter++; + EXPECT_EQ(counter, BaseUInt96(1)); + counter--; + EXPECT_EQ(counter, beast::kZero); + EXPECT_EQ(counter, zero); + counter--; + EXPECT_EQ(to_string(counter), "FFFFFFFFFFFFFFFFFFFFFFFF"); + EXPECT_EQ(toShortString(counter), "FFFFFFFF..."); + counter = beast::kZero; + EXPECT_EQ(counter, zero); - BaseUInt96 zp1{z}; - zp1++; - BaseUInt96 zm1{z}; - zm1--; - BaseUInt96 const x{zm1 ^ zp1}; - uset.insert(x); - EXPECT_EQ(to_string(x), "FFFFFFFFFFFFFFFFFFFFFFFE") << to_string(x); - EXPECT_EQ(toShortString(x), "FFFFFFFF...") << toShortString(x); + BaseUInt96 zeroPlusOne{zero}; + zeroPlusOne++; + BaseUInt96 zeroMinusOne{zero}; + zeroMinusOne--; + BaseUInt96 const xored{zeroMinusOne ^ zeroPlusOne}; + uset.insert(xored); + EXPECT_EQ(to_string(xored), "FFFFFFFFFFFFFFFFFFFFFFFE") << to_string(xored); + EXPECT_EQ(toShortString(xored), "FFFFFFFF...") << toShortString(xored); EXPECT_EQ(uset.size(), 4); - BaseUInt96 tmp; - EXPECT_TRUE(tmp.parseHex(to_string(u))); - EXPECT_EQ(tmp, u); - tmp = z; + BaseUInt96 parsed; + EXPECT_TRUE(parsed.parseHex(to_string(ascending))); + EXPECT_EQ(parsed, ascending); + parsed = zero; // fails with extra char - EXPECT_FALSE(tmp.parseHex("A" + to_string(u))); - tmp = z; + EXPECT_FALSE(parsed.parseHex("A" + to_string(ascending))); + parsed = zero; // fails with extra char at end - EXPECT_FALSE(tmp.parseHex(to_string(u) + "A")); + EXPECT_FALSE(parsed.parseHex(to_string(ascending) + "A")); // fails with a non-hex character at some point in the string: - tmp = z; + parsed = zero; for (std::size_t i = 0; i != 24; ++i) { - std::string x = to_string(z); - x[i] = ('G' + (i % 10)); - EXPECT_FALSE(tmp.parseHex(x)); + std::string xored = to_string(zero); + xored[i] = ('G' + (i % 10)); + EXPECT_FALSE(parsed.parseHex(xored)); } // Walking 1s: @@ -332,8 +327,8 @@ TEST_F(BaseUintTest, base_uint) std::string s1 = "000000000000000000000000"; s1[i] = '1'; - EXPECT_TRUE(tmp.parseHex(s1)); - EXPECT_EQ(to_string(tmp), s1); + EXPECT_TRUE(parsed.parseHex(s1)); + EXPECT_EQ(to_string(parsed), s1); } // Walking 0s: @@ -342,8 +337,8 @@ TEST_F(BaseUintTest, base_uint) std::string s1 = "111111111111111111111111"; s1[i] = '0'; - EXPECT_TRUE(tmp.parseHex(s1)); - EXPECT_EQ(to_string(tmp), s1); + EXPECT_TRUE(parsed.parseHex(s1)); + EXPECT_EQ(to_string(parsed), s1); } // Constexpr constructors @@ -357,39 +352,27 @@ TEST_F(BaseUintTest, base_uint) // Using the constexpr constructor in a non-constexpr context // with an error in the parsing throws an exception. { - // Invalid length for string. - bool caught = false; - try - { - // Try to prevent constant evaluation. - std::vector str(23, '7'); + // Invalid length for string. The vector keeps this out of a constant + // expression, so the constructor throws instead of failing to compile. + auto tooShort = [] { + std::vector const str(23, '7'); std::string_view const sView(str.data(), str.size()); [[maybe_unused]] BaseUInt96 const t96(sView); - } - catch (std::invalid_argument const& e) - { - EXPECT_EQ(e.what(), std::string("invalid length for hex string")); - caught = true; - } - EXPECT_TRUE(caught); + }; + EXPECT_THAT( + tooShort, + ::testing::ThrowsMessage("invalid length for hex string")); } { // Invalid character in string. - bool caught = false; - try - { - // Try to prevent constant evaluation. + auto badCharacter = [] { std::vector str(23, '7'); str.push_back('G'); std::string_view const sView(str.data(), str.size()); [[maybe_unused]] BaseUInt96 const t96(sView); - } - catch (std::range_error const& e) - { - EXPECT_EQ(e.what(), std::string("invalid hex character")); - caught = true; - } - EXPECT_TRUE(caught); + }; + EXPECT_THAT( + badCharacter, ::testing::ThrowsMessage("invalid hex character")); } // Verify that constexpr base_uints interpret a string the same @@ -412,11 +395,11 @@ TEST_F(BaseUintTest, base_uint) "fFfFfFfFfFfFfFfFfFfFfFfF", }); - for (StrBaseUInt const& t : kTestCases) + for (StrBaseUInt const& expectedByte : kTestCases) { BaseUInt96 t96; - EXPECT_TRUE(t96.parseHex(t.str)); - EXPECT_EQ(t96, t.tst); + EXPECT_TRUE(t96.parseHex(expectedByte.str)); + EXPECT_EQ(t96, expectedByte.tst); } } } diff --git a/src/tests/libxrpl/shamap/SHAMap.cpp b/src/tests/libxrpl/shamap/SHAMap.cpp index e662e16be4..c84cdf504f 100644 --- a/src/tests/libxrpl/shamap/SHAMap.cpp +++ b/src/tests/libxrpl/shamap/SHAMap.cpp @@ -16,7 +16,6 @@ #include #include -#include #include #include #include @@ -113,7 +112,7 @@ protected: intToVuc(std::uint8_t v) { Buffer vuc{32}; - std::fill_n(vuc.data(), vuc.size(), v); + vuc.fill(v); return vuc; } }; From c74724a7197da3db16cc1c7274543c7ba4ce2dbc Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Tue, 11 Aug 2026 12:44:01 +0000 Subject: [PATCH 064/102] build: Reimagine linker warnings in different scenarios (#7974) --- cmake/XrplCompiler.cmake | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/cmake/XrplCompiler.cmake b/cmake/XrplCompiler.cmake index e262acf1c9..21566add01 100644 --- a/cmake/XrplCompiler.cmake +++ b/cmake/XrplCompiler.cmake @@ -188,6 +188,32 @@ else() endif() endif() +# Linker warnings are errors where we control the toolchain and the dependencies: CI and the Nix dev shell. +# On non-Nix macOS we suppress the deployment target warning: an old Conan profile may not pin os.version. +if(is_macos OR is_linux) + if(is_ci OR is_nix_compiler) + if(is_macos) + set(fatal_warnings_flag "-Wl,-fatal_warnings") + else() + set(fatal_warnings_flag "-Wl,--fatal-warnings") + endif() + message( + STATUS + "Treating all linker warnings as errors (${fatal_warnings_flag})" + ) + target_link_options(common INTERFACE "${fatal_warnings_flag}") + unset(fatal_warnings_flag) + elseif(is_macos) + set(silence_flag "-Wl,-deployment_target_mismatches,suppress") + message( + STATUS + "Silencing macOS deployment target mismatch warnings (${silence_flag})" + ) + target_link_options(common INTERFACE "${silence_flag}") + unset(silence_flag) + endif() +endif() + # Antithesis instrumentation will only be built and deployed using machines running Linux. if(voidstar) if(NOT CMAKE_BUILD_TYPE STREQUAL "Debug") From d43e5acaa70f8d45a1691ab522a9c84c4fc95006 Mon Sep 17 00:00:00 2001 From: luisfernandomendozav <109832400+luisfernandomendozav@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:23:07 +0000 Subject: [PATCH 065/102] fix: Validate account/ident type in gateway_balances (#7655) --- API-CHANGELOG.md | 1 + src/test/rpc/GatewayBalances_test.cpp | 40 +++++++++++++++++++ .../rpc/handlers/account/GatewayBalances.cpp | 6 +++ 3 files changed, 47 insertions(+) diff --git a/API-CHANGELOG.md b/API-CHANGELOG.md index bc3672588e..c853cfb07c 100644 --- a/API-CHANGELOG.md +++ b/API-CHANGELOG.md @@ -54,6 +54,7 @@ This section contains changes targeting a future version. - `submit`: The `fail_hard` field now returns an error if the value is not a boolean. [#6529](https://github.com/XRPLF/rippled/pull/6529) - `subscribe`: The `taker` field in the `books` array now returns `actMalformed` instead of `badIssuer` if the value is not a valid account. [#6529](https://github.com/XRPLF/rippled/pull/6529) - Fixed a bug in `Forwarded` HTTP header parsing where the extracted IP address could be incorrect when no comma or semicolon delimiter follows the address. This could cause the server to misidentify a client's IP address when operating behind a reverse proxy. [#6529](https://github.com/XRPLF/rippled/pull/6529) +- `gateway_balances`: The `account` and `ident` fields now return an `invalidParams` error if the value is not a string, instead of an `internal` error. [#7655](https://github.com/XRPLF/rippled/pull/7655) - `account_lines`: The `peer` field now returns an error if the value is not a string. [#7728](https://github.com/XRPLF/rippled/pull/7728) ## XRP Ledger server version 3.1.0 diff --git a/src/test/rpc/GatewayBalances_test.cpp b/src/test/rpc/GatewayBalances_test.cpp index 106b9b5f1a..91d9126f61 100644 --- a/src/test/rpc/GatewayBalances_test.cpp +++ b/src/test/rpc/GatewayBalances_test.cpp @@ -176,6 +176,45 @@ public: }); } + void + testGWBInvalidAccount(FeatureBitset features) + { + testcase("Gateway Balances with non-string account/ident"); + using namespace std::chrono_literals; + using namespace jtx; + Env env(*this, features); + + Account const alice{"alice"}; + env.fund(XRP(10000), alice); + env.close(); + + auto wsc = makeWSClient(env.app().config()); + + // A non-string "account" must be rejected cleanly with invalidParams + // rather than throwing a Json::LogicError that surfaces as internal. + json::Value qry; + qry[jss::account] = 42; + qry[jss::hotwallet] = alice.human(); + + forAllApiVersions([&, this](unsigned apiVersion) { + qry[jss::api_version] = apiVersion; + auto jv = wsc->invoke("gateway_balances", qry); + expect(jv[jss::status] == "error"); + BEAST_EXPECT(jv[jss::result][jss::error] == "invalidParams"); + }); + + // The same applies to a non-string "ident". + json::Value qry2; + qry2[jss::ident] = 42; + + forAllApiVersions([&, this](unsigned apiVersion) { + qry2[jss::api_version] = apiVersion; + auto jv = wsc->invoke("gateway_balances", qry2); + expect(jv[jss::status] == "error"); + BEAST_EXPECT(jv[jss::result][jss::error] == "invalidParams"); + }); + } + void testGWBOverflow() { @@ -280,6 +319,7 @@ public: { testGWB(feature); testGWBApiVersions(feature); + testGWBInvalidAccount(feature); } testGWBWithMPT(); testGWBOverflow(); diff --git a/src/xrpld/rpc/handlers/account/GatewayBalances.cpp b/src/xrpld/rpc/handlers/account/GatewayBalances.cpp index ff19d1d1e5..041e878a3f 100644 --- a/src/xrpld/rpc/handlers/account/GatewayBalances.cpp +++ b/src/xrpld/rpc/handlers/account/GatewayBalances.cpp @@ -63,6 +63,12 @@ doGatewayBalances(rpc::JsonContext& context) if (!(params.isMember(jss::account) || params.isMember(jss::ident))) return rpc::missingFieldError(jss::account); + if (params.isMember(jss::account) && !params[jss::account].isString()) + return rpc::invalidFieldError(jss::account); + + if (params.isMember(jss::ident) && !params[jss::ident].isString()) + return rpc::invalidFieldError(jss::ident); + std::string const strIdent( params.isMember(jss::account) ? params[jss::account].asString() : params[jss::ident].asString()); From a3147740f2f610f0810a6e4eb56aa7cbe4c5cc12 Mon Sep 17 00:00:00 2001 From: klemenfn <102049210+klemenfn@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:24:56 +0000 Subject: [PATCH 066/102] build: Fix GCC 14 compilation (#7981) Co-authored-by: Ayaz Salikhov --- include/xrpl/json/json_value.h | 2 ++ src/xrpld/rpc/detail/RPCLedgerHelpers.cpp | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/include/xrpl/json/json_value.h b/include/xrpl/json/json_value.h index be126d8b8e..57936a774f 100644 --- a/include/xrpl/json/json_value.h +++ b/include/xrpl/json/json_value.h @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -524,6 +525,7 @@ public: class ValueIteratorBase { public: + using iterator_category = std::bidirectional_iterator_tag; using size_t = unsigned int; using difference_type = int; using SelfType = ValueIteratorBase; diff --git a/src/xrpld/rpc/detail/RPCLedgerHelpers.cpp b/src/xrpld/rpc/detail/RPCLedgerHelpers.cpp index 52e68e87f1..19fe294924 100644 --- a/src/xrpld/rpc/detail/RPCLedgerHelpers.cpp +++ b/src/xrpld/rpc/detail/RPCLedgerHelpers.cpp @@ -331,6 +331,13 @@ getLedger<>(std::shared_ptr&, LedgerShortcut shortcut, Context c template Status getLedger<>(std::shared_ptr&, uint256 const&, Context const&); +// explicit instantiation of ledgerFromSpecifier +template Status +ledgerFromSpecifier<>( + std::shared_ptr&, + org::xrpl::rpc::v1::LedgerSpecifier const&, + Context const&); + // The previous version of the lookupLedger command would accept the // "ledger_index" argument as a string and silently treat it as a request to // return the current ledger which, while not strictly wrong, could cause a lot From 6ca2fb84d4da09d01af6d1f233147374599aa440 Mon Sep 17 00:00:00 2001 From: Mayukha Vadari Date: Tue, 11 Aug 2026 18:15:35 +0000 Subject: [PATCH 067/102] refactor: Replace Boost trim and to_lower with libxrpl helpers (#7995) --- include/xrpl/basics/StringUtilities.h | 22 ++++++++++ src/libxrpl/basics/StringUtilities.cpp | 40 +++++++++++++++++-- src/libxrpl/crypto/RFC1751.cpp | 4 +- src/libxrpl/server/Manifest.cpp | 6 +-- src/libxrpl/server/Port.cpp | 4 +- src/tests/libxrpl/basics/StringUtilities.cpp | 40 +++++++++++++++++++ src/xrpld/app/main/GRPCServer.cpp | 4 +- src/xrpld/app/main/Main.cpp | 6 +-- src/xrpld/core/detail/Config.cpp | 3 +- src/xrpld/rpc/detail/ServerHandler.cpp | 4 +- .../rpc/handlers/account/AccountInfo.cpp | 4 +- .../rpc/handlers/admin/data/CanDelete.cpp | 5 +-- .../server_info/ServerDefinitions.cpp | 4 +- 13 files changed, 118 insertions(+), 28 deletions(-) diff --git a/include/xrpl/basics/StringUtilities.h b/include/xrpl/basics/StringUtilities.h index 2b360d2fda..d606613c65 100644 --- a/include/xrpl/basics/StringUtilities.h +++ b/include/xrpl/basics/StringUtilities.h @@ -125,9 +125,31 @@ struct ParsedUrl bool parseUrl(ParsedUrl& pUrl, std::string const& strUrl); +/** + * Remove leading and trailing ASCII whitespace. + * + * Whitespace is the fixed set " \t\n\v\f\r"; the current locale is not + * consulted, so the result depends only on the input. + * + * @param str The string to trim. + * @return @p str without leading or trailing whitespace. + */ std::string trimWhitespace(std::string str); +/** + * Fold ASCII upper case letters to lower case. + * + * Only 'A' through 'Z' are remapped; every other byte is left alone and the + * current locale is not consulted, so the result depends only on the input. + * + * @param str The string to fold. + * @return @p str with each ASCII upper case letter replaced by its lower case + * equivalent. + */ +std::string +toLower(std::string str); + std::optional toUInt64(std::string const& s); diff --git a/src/libxrpl/basics/StringUtilities.cpp b/src/libxrpl/basics/StringUtilities.cpp index 2b7deecb8e..9eb1bff995 100644 --- a/src/libxrpl/basics/StringUtilities.cpp +++ b/src/libxrpl/basics/StringUtilities.cpp @@ -5,15 +5,15 @@ #include #include -#include -#include #include #include #include +#include #include #include #include +#include #include #include @@ -67,7 +67,7 @@ parseUrl(ParsedUrl& pUrl, std::string const& strUrl) } pUrl.scheme = smMatch[1]; - boost::algorithm::to_lower(pUrl.scheme); + pUrl.scheme = toLower(pUrl.scheme); pUrl.username = smMatch[2]; pUrl.password = smMatch[3]; std::string const domain = smMatch[4]; @@ -93,10 +93,42 @@ parseUrl(ParsedUrl& pUrl, std::string const& strUrl) return true; } +namespace { + +// Deliberately not std::isspace / std::tolower: those consult the current C +// locale, so the same input could trim or fold differently depending on +// process-wide state set by something else entirely. Everything these helpers +// are used on (config keys and values, URL schemes, hex digests) is ASCII, and +// the callers want a fixed answer, so spell the ASCII rules out. + +constexpr bool +isAsciiSpace(char c) +{ + return c == ' ' || c == '\t' || c == '\n' || c == '\v' || c == '\f' || c == '\r'; +} + +constexpr char +toAsciiLower(char c) +{ + return (c >= 'A' && c <= 'Z') ? static_cast(c - 'A' + 'a') : c; +} + +} // namespace + std::string trimWhitespace(std::string str) { - boost::trim(str); + auto const end = std::ranges::find_if_not(str | std::views::reverse, isAsciiSpace).base(); + str.erase(end, str.end()); + str.erase(str.begin(), std::ranges::find_if_not(str, isAsciiSpace)); + + return str; +} + +std::string +toLower(std::string str) +{ + std::ranges::transform(str, str.begin(), toAsciiLower); return str; } diff --git a/src/libxrpl/crypto/RFC1751.cpp b/src/libxrpl/crypto/RFC1751.cpp index 4b17e1443c..f6342928ab 100644 --- a/src/libxrpl/crypto/RFC1751.cpp +++ b/src/libxrpl/crypto/RFC1751.cpp @@ -1,11 +1,11 @@ #include +#include #include #include #include #include -#include #include #include @@ -397,7 +397,7 @@ RFC1751::getKeyFromEnglish(std::string& strKey, std::string const& strHuman) std::string strTrimmed(strHuman); - boost::algorithm::trim(strTrimmed); + strTrimmed = trimWhitespace(strTrimmed); boost::algorithm::split( vWords, strTrimmed, boost::algorithm::is_space(), boost::algorithm::token_compress_on); diff --git a/src/libxrpl/server/Manifest.cpp b/src/libxrpl/server/Manifest.cpp index 0760196a3b..c85c8445f0 100644 --- a/src/libxrpl/server/Manifest.cpp +++ b/src/libxrpl/server/Manifest.cpp @@ -23,8 +23,6 @@ #include #include -#include - #include #include #include @@ -277,7 +275,7 @@ loadValidatorToken(std::vector const& blob, beast::Journal journal) [](std::size_t init, std::string const& s) { return init + s.size(); })); for (auto const& line : blob) - tokenStr += boost::algorithm::trim_copy(line); + tokenStr += trimWhitespace(line); tokenStr = base64Decode(tokenStr); @@ -653,7 +651,7 @@ ManifestCache::load( [](std::size_t init, std::string const& s) { return init + s.size(); })); for (auto const& line : configRevocation) - revocationStr += boost::algorithm::trim_copy(line); + revocationStr += trimWhitespace(line); auto mo = deserializeManifest(base64Decode(revocationStr)); diff --git a/src/libxrpl/server/Port.cpp b/src/libxrpl/server/Port.cpp index 694d4448d5..a7892bc0e8 100644 --- a/src/libxrpl/server/Port.cpp +++ b/src/libxrpl/server/Port.cpp @@ -1,5 +1,6 @@ #include +#include #include #include #include @@ -9,7 +10,6 @@ #include #include -#include #include #include #include @@ -98,7 +98,7 @@ populate( while (std::getline(ss, ip, ',')) { - boost::algorithm::trim(ip); + ip = trimWhitespace(ip); bool v4 = false; boost::asio::ip::network_v4 v4Net; boost::asio::ip::network_v6 v6Net; diff --git a/src/tests/libxrpl/basics/StringUtilities.cpp b/src/tests/libxrpl/basics/StringUtilities.cpp index a10711abdb..0180e25db0 100644 --- a/src/tests/libxrpl/basics/StringUtilities.cpp +++ b/src/tests/libxrpl/basics/StringUtilities.cpp @@ -290,4 +290,44 @@ TEST_F(StringUtilitiesTest, to_string) EXPECT_EQ(result, "hello"); } +TEST_F(StringUtilitiesTest, trimWhitespace) +{ + EXPECT_EQ(trimWhitespace(""), ""); + EXPECT_EQ(trimWhitespace(" "), ""); + EXPECT_EQ(trimWhitespace("abc"), "abc"); + EXPECT_EQ(trimWhitespace(" abc"), "abc"); + EXPECT_EQ(trimWhitespace("abc "), "abc"); + EXPECT_EQ(trimWhitespace(" \t\n\v\f\r abc \t\n\v\f\r "), "abc"); + + // Interior whitespace is preserved. + EXPECT_EQ(trimWhitespace(" a b\tc "), "a b\tc"); +} + +TEST_F(StringUtilitiesTest, toLower) +{ + EXPECT_EQ(toLower(""), ""); + EXPECT_EQ(toLower("ABC"), "abc"); + EXPECT_EQ(toLower("AbC123"), "abc123"); + EXPECT_EQ(toLower("already lower"), "already lower"); + + // Only 'A'-'Z' are remapped. Neighbouring punctuation and digits, which a + // buggy range check could catch, must survive untouched. + EXPECT_EQ(toLower("@[`{_^"), "@[`{_^"); +} + +// Both helpers are documented as depending only on their input. Guard that by +// checking the bytes just outside ASCII, which a locale-aware isspace/tolower +// could classify differently. +TEST_F(StringUtilitiesTest, trimAndLowerIgnoreLocale) +{ + // 0xA0 is NO-BREAK SPACE in Latin-1 and is whitespace to some locales. + std::string const nbsp("\xA0", 1); + EXPECT_EQ(trimWhitespace(nbsp), nbsp); + EXPECT_EQ(trimWhitespace(" " + nbsp + " "), nbsp); + + // 0xC0 is LATIN CAPITAL LETTER A WITH GRAVE in Latin-1. + std::string const agrave("\xC0", 1); + EXPECT_EQ(toLower(agrave), agrave); +} + } // namespace xrpl diff --git a/src/xrpld/app/main/GRPCServer.cpp b/src/xrpld/app/main/GRPCServer.cpp index 1b20ff1d49..fc4a9794bd 100644 --- a/src/xrpld/app/main/GRPCServer.cpp +++ b/src/xrpld/app/main/GRPCServer.cpp @@ -9,6 +9,7 @@ #include #include +#include #include #include #include @@ -24,7 +25,6 @@ #include #include -#include #include #include #include @@ -371,7 +371,7 @@ GRPCServerImpl::GRPCServerImpl(Application& app) std::string ip; while (std::getline(ss, ip, ',')) { - boost::algorithm::trim(ip); + ip = trimWhitespace(ip); auto const addr = boost::asio::ip::make_address(ip); if (addr.is_unspecified()) diff --git a/src/xrpld/app/main/Main.cpp b/src/xrpld/app/main/Main.cpp index a23b84f2e8..ba6520db5f 100644 --- a/src/xrpld/app/main/Main.cpp +++ b/src/xrpld/app/main/Main.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -21,7 +22,6 @@ #include #include -#include #include #include // IWYU pragma: keep #include @@ -211,7 +211,7 @@ public: boost::split(v, patterns, boost::algorithm::is_any_of(",")); selectors_.reserve(v.size()); std::ranges::for_each(v, [this](std::string s) { - boost::trim(s); + s = trimWhitespace(s); if (selectors_.empty() || !s.empty()) selectors_.emplace_back(beast::unit_test::Selector::ModeT::Automatch, s); }); @@ -614,7 +614,7 @@ run(int argc, char** argv) std::vector result; for (auto& s : strVec) { - boost::trim(s); + s = trimWhitespace(s); if (!s.empty()) result.push_back(std::stoi(s)); } diff --git a/src/xrpld/core/detail/Config.cpp b/src/xrpld/core/detail/Config.cpp index e93ccec56e..f263fb49ab 100644 --- a/src/xrpld/core/detail/Config.cpp +++ b/src/xrpld/core/detail/Config.cpp @@ -20,7 +20,6 @@ #include #include #include -#include #include #include #include @@ -185,7 +184,7 @@ parseIniFile(std::string const& strInput, bool const bTrim) for (auto& strValue : vLines) { if (bTrim) - boost::algorithm::trim(strValue); + strValue = trimWhitespace(strValue); if (strValue.empty() || strValue[0] == '#') { diff --git a/src/xrpld/rpc/detail/ServerHandler.cpp b/src/xrpld/rpc/detail/ServerHandler.cpp index 0181d5b10f..827d8705fd 100644 --- a/src/xrpld/rpc/detail/ServerHandler.cpp +++ b/src/xrpld/rpc/detail/ServerHandler.cpp @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -44,7 +45,6 @@ #include #include -#include #include #include #include @@ -113,7 +113,7 @@ authorized(Port const& port, std::map const& h) if ((it == h.end()) || (!it->second.starts_with("Basic "))) return false; std::string strUserPass64 = it->second.substr(6); - boost::trim(strUserPass64); + strUserPass64 = trimWhitespace(strUserPass64); std::string const strUserPass = base64Decode(strUserPass64); std::string::size_type const nColon = strUserPass.find(':'); if (nColon == std::string::npos) diff --git a/src/xrpld/rpc/handlers/account/AccountInfo.cpp b/src/xrpld/rpc/handlers/account/AccountInfo.cpp index f131af01e5..eed4e4cfe3 100644 --- a/src/xrpld/rpc/handlers/account/AccountInfo.cpp +++ b/src/xrpld/rpc/handlers/account/AccountInfo.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -22,7 +23,6 @@ #include #include -#include #include #include @@ -57,7 +57,7 @@ injectSLE(json::Value& jv, SLE const& sle) auto const& hash = sle.getFieldH128(sfEmailHash); Blob const b(hash.begin(), hash.end()); std::string md5 = strHex(makeSlice(b)); - boost::to_lower(md5); + md5 = toLower(md5); // VFALCO TODO Give a name to this constant and move it // to a more visible location. jv[jss::urlgravatar] = str(boost::format("https://www.gravatar.com/avatar/%s") % md5); diff --git a/src/xrpld/rpc/handlers/admin/data/CanDelete.cpp b/src/xrpld/rpc/handlers/admin/data/CanDelete.cpp index 91db16bb4f..5c96bfb215 100644 --- a/src/xrpld/rpc/handlers/admin/data/CanDelete.cpp +++ b/src/xrpld/rpc/handlers/admin/data/CanDelete.cpp @@ -3,14 +3,13 @@ #include #include +#include #include #include #include #include #include -#include - #include #include #include @@ -38,7 +37,7 @@ doCanDelete(rpc::JsonContext& context) else { std::string canDeleteStr = canDelete.asString(); - boost::to_lower(canDeleteStr); + canDeleteStr = toLower(canDeleteStr); if (canDeleteStr.find_first_not_of("0123456789") == std::string::npos) { diff --git a/src/xrpld/rpc/handlers/server_info/ServerDefinitions.cpp b/src/xrpld/rpc/handlers/server_info/ServerDefinitions.cpp index 32a084a833..c297c2482d 100644 --- a/src/xrpld/rpc/handlers/server_info/ServerDefinitions.cpp +++ b/src/xrpld/rpc/handlers/server_info/ServerDefinitions.cpp @@ -2,6 +2,7 @@ #include +#include #include #include #include @@ -14,7 +15,6 @@ #include #include -#include #include #include @@ -106,7 +106,7 @@ ServerDefinitions::translate(std::string const& inp) std::string token = inpToProcess.substr(0, pos); if (token.size() > 1) { - boost::algorithm::to_lower(token); + token = toLower(token); token[0] -= ('a' - 'A'); out += token; } From 26cc683ec143e8a5fcc6dd09c2c1fe25ac08b94c Mon Sep 17 00:00:00 2001 From: Gregory Tsipenyuk Date: Tue, 11 Aug 2026 18:15:51 +0000 Subject: [PATCH 068/102] fix: Assorted MPT/DEX fixes (#7299) Co-authored-by: Valentin Balaschenko <13349202+vlntb@users.noreply.github.com> --- include/xrpl/ledger/helpers/AMMHelpers.h | 28 +- include/xrpl/ledger/helpers/MPTokenHelpers.h | 8 + include/xrpl/protocol/AmountConversions.h | 2 +- include/xrpl/protocol/QualityFunction.h | 9 + include/xrpl/tx/paths/detail/StrandFlow.h | 25 +- include/xrpl/tx/transactors/dex/AMMWithdraw.h | 7 + src/libxrpl/ledger/helpers/MPTokenHelpers.cpp | 13 +- src/libxrpl/protocol/QualityFunction.cpp | 17 +- src/libxrpl/protocol/STAmount.cpp | 90 ++- src/libxrpl/tx/invariants/MPTInvariant.cpp | 7 +- src/libxrpl/tx/paths/BookStep.cpp | 246 ++++-- src/libxrpl/tx/paths/MPTEndpointStep.cpp | 3 +- src/libxrpl/tx/paths/OfferStream.cpp | 63 +- .../tx/transactors/check/CheckCash.cpp | 2 +- .../tx/transactors/dex/AMMClawback.cpp | 12 + .../tx/transactors/dex/AMMWithdraw.cpp | 47 +- src/test/app/AMMClawbackMPT_test.cpp | 252 +++++++ src/test/app/AMMExtendedMPT_test.cpp | 133 +++- src/test/app/AMMExtended_test.cpp | 48 +- src/test/app/AMMMPT_test.cpp | 157 +++- src/test/app/AMM_test.cpp | 71 +- src/test/app/EscrowToken_test.cpp | 181 +++++ src/test/app/FlowMPT_test.cpp | 160 ++++ src/test/app/OfferMPT_test.cpp | 711 ++++++++++++++++++ src/test/protocol/STAmount_test.cpp | 85 +++ 25 files changed, 2198 insertions(+), 179 deletions(-) diff --git a/include/xrpl/ledger/helpers/AMMHelpers.h b/include/xrpl/ledger/helpers/AMMHelpers.h index 7d41bfce81..a68171c426 100644 --- a/include/xrpl/ledger/helpers/AMMHelpers.h +++ b/include/xrpl/ledger/helpers/AMMHelpers.h @@ -226,7 +226,7 @@ getAMMOfferStartWithTakerGets( auto getAmounts = [&pool, &tfee](Number const& nTakerGetsProposed) { // Round downward to minimize the offer and to maximize the quality. - // This has the most impact when takerGets is XRP. + // This has the most impact when takerGets is integral. auto const takerGets = toAmount(getAsset(pool.out), nTakerGetsProposed, Number::RoundingMode::Downward); return TAmounts{swapAssetOut(pool, takerGets, tfee), takerGets}; @@ -294,7 +294,7 @@ getAMMOfferStartWithTakerPays( auto getAmounts = [&pool, &tfee](Number const& nTakerPaysProposed) { // Round downward to minimize the offer and to maximize the quality. - // This has the most impact when takerPays is XRP. + // This has the most impact when takerPays is integral. auto const takerPays = toAmount(getAsset(pool.in), nTakerPaysProposed, Number::RoundingMode::Downward); return TAmounts{takerPays, swapAssetIn(pool, takerPays, tfee)}; @@ -313,11 +313,11 @@ getAMMOfferStartWithTakerPays( * is equal to LOB quality (in this case AMM offer quality is * better than LOB quality) or AMM offer is equal to LOB quality * (in this case SPQ is better than LOB quality). - * Pre-amendment code calculates takerPays first. If takerGets is XRP, - * it is rounded down, which results in worse offer quality than - * LOB quality, and the offer might fail to generate. - * Post-amendment code calculates the XRP offer side first. The result - * is rounded down, which makes the offer quality better. + * Pre-amendment code calculates takerPays first. If takerGets is the + * economically coarser integral side, it is rounded down, which results in + * worse offer quality than LOB quality, and the offer might fail to generate. + * Post-amendment code calculates the economically coarser integral offer side + * first. The result is rounded down, which makes the offer quality better. * It might not be possible to match either SPQ or AMM offer to LOB * quality. This generally happens at higher fees. * @param pool AMM pool balances @@ -396,10 +396,18 @@ changeSpotPriceQuality( return std::nullopt; } - // Generate the offer starting with XRP side. Return seated offer amounts - // if the offer can be generated, otherwise nullopt. auto amounts = [&]() { - if (isXRP(getAsset(pool.out))) + bool const inIntegral = getAsset(pool.in).integral(); + bool const outIntegral = getAsset(pool.out).integral(); + + // Preserve historical behavior for fractional pairs and XRP/IOU-style + // one-integral-side pairs. For two integral assets, pick the side whose + // minimum unit is economically coarser at this quality. + // + // Quality::rate() is input units per output unit, so one output unit is + // coarser when it costs at least one input unit. Ties use takerGets, + // matching the historical XRP-output behavior. + if (outIntegral && (!inIntegral || Number(quality.rate()) >= 1)) return getAMMOfferStartWithTakerGets(pool, quality, tfee); return getAMMOfferStartWithTakerPays(pool, quality, tfee); }(); diff --git a/include/xrpl/ledger/helpers/MPTokenHelpers.h b/include/xrpl/ledger/helpers/MPTokenHelpers.h index 5418e5b26a..7babefd196 100644 --- a/include/xrpl/ledger/helpers/MPTokenHelpers.h +++ b/include/xrpl/ledger/helpers/MPTokenHelpers.h @@ -261,6 +261,14 @@ checkCreateMPT( xrpl::MPTIssue const& mptIssue, xrpl::AccountID const& holder, SLE::ref sponsorSle, + std::uint32_t flags, + beast::Journal j); + +TER +checkCreateMPT( + xrpl::ApplyView& view, + xrpl::MPTIssue const& mptIssue, + xrpl::AccountID const& holder, beast::Journal j); //------------------------------------------------------------------------------ diff --git a/include/xrpl/protocol/AmountConversions.h b/include/xrpl/protocol/AmountConversions.h index 3bcd80e827..ed68be62fe 100644 --- a/include/xrpl/protocol/AmountConversions.h +++ b/include/xrpl/protocol/AmountConversions.h @@ -154,7 +154,7 @@ T toAmount(Asset const& asset, Number const& n, Number::RoundingMode mode = Number::getround()) { SaveNumberRoundMode const rm(Number::getround()); - if (isXRP(asset)) + if (asset.integral()) Number::setround(mode); if constexpr (std::is_same_v) diff --git a/include/xrpl/protocol/QualityFunction.h b/include/xrpl/protocol/QualityFunction.h index 128b37ce12..4fcc730c42 100644 --- a/include/xrpl/protocol/QualityFunction.h +++ b/include/xrpl/protocol/QualityFunction.h @@ -60,6 +60,15 @@ public: std::optional outFromAvgQ(Quality const& quality); + /** + * Return whether `out` produces at least the requested + * average quality. + * @param quality requested average quality (quality limit) + * @param out output amount to test + */ + [[nodiscard]] bool + satisfiesAvgQ(Quality const& quality, Number const& out) const; + /** * Return true if the quality function is constant */ diff --git a/include/xrpl/tx/paths/detail/StrandFlow.h b/include/xrpl/tx/paths/detail/StrandFlow.h index c932c49cca..fcca97ecfc 100644 --- a/include/xrpl/tx/paths/detail/StrandFlow.h +++ b/include/xrpl/tx/paths/detail/StrandFlow.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -373,7 +374,7 @@ qualityUpperBound(ReadView const& v, Strand const& strand) * increases quality of AMM steps, increasing the strand's composite * quality as the result. */ -template +template inline TOutAmt limitOut( ReadView const& v, @@ -411,21 +412,29 @@ limitOut( auto const out = qf->outFromAvgQ(limitQuality); if (!out) return remainingOut; - if constexpr (std::is_same_v) + if constexpr (std::is_same_v || std::is_same_v) { - return XRPAmount{*out}; + auto const roundedOut = TOutAmt{*out}; + // Integral outputs that round above the continuous target can + // realize worse average quality than the requested limit. Keep the + // default rounded value when it still satisfies the limit, since it + // is the largest matching offer; otherwise round down. + if (v.rules().enabled(featureMPTokensV2) && roundedOut > *out && + !qf->satisfiesAvgQ(limitQuality, roundedOut)) + { + NumberRoundModeGuard const g(Number::RoundingMode::Downward); + return TOutAmt{*out}; + } + return roundedOut; } else if constexpr (std::is_same_v) { return IOUAmount{*out}; } - else if constexpr (std::is_same_v) - { - return MPTAmount{*out}; - } else { - return STAmount{remainingOut.asset(), out->mantissa(), out->exponent()}; + static constexpr bool kAlwaysFalse = !std::is_same_v; + static_assert(kAlwaysFalse, "Unhandled StepAmount type"); } }(); // A tiny difference could be due to the round off diff --git a/include/xrpl/tx/transactors/dex/AMMWithdraw.h b/include/xrpl/tx/transactors/dex/AMMWithdraw.h index 7004dd57c1..6861fa7bc4 100644 --- a/include/xrpl/tx/transactors/dex/AMMWithdraw.h +++ b/include/xrpl/tx/transactors/dex/AMMWithdraw.h @@ -118,6 +118,7 @@ public: Sandbox& view, SLE const& ammSle, AccountID const account, + std::optional const& clawbackIssuer, AccountID const& ammAccount, STAmount const& amountBalance, STAmount const& amount2Balance, @@ -138,6 +139,11 @@ public: * @param view * @param ammSle AMM ledger entry * @param ammAccount AMM account + * @param clawbackIssuer when set (AMMClawback path), the issuer performing + * the clawback. A recreated MPToken is only auto-authorized when the + * asset's issuer matches this account, so a clawback cannot grant + * authorization on behalf of a different (paired-asset) issuer. + * @param account LP account * @param amountBalance current LP asset1 balance * @param amountWithdraw asset1 withdraw amount * @param amount2Withdraw asset2 withdraw amount @@ -153,6 +159,7 @@ public: Sandbox& view, SLE const& ammSle, AccountID const& ammAccount, + std::optional const& clawbackIssuer, AccountID const& account, STAmount const& amountBalance, STAmount const& amountWithdraw, diff --git a/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp b/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp index 6fe7328fa7..b239d0d3d1 100644 --- a/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp +++ b/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp @@ -952,6 +952,7 @@ checkCreateMPT( xrpl::MPTIssue const& mptIssue, xrpl::AccountID const& holder, SLE::ref sponsorSle, + std::uint32_t flags, beast::Journal j) { if (mptIssue.getIssuer() == holder) @@ -961,7 +962,7 @@ checkCreateMPT( auto const mptokenID = keylet::mptoken(mptIssuanceID.key, holder); if (!view.exists(mptokenID)) { - if (auto const err = createMPToken(view, mptIssue.getMptID(), holder, sponsorSle, 0); + if (auto const err = createMPToken(view, mptIssue.getMptID(), holder, sponsorSle, flags); !isTesSuccess(err)) { return err; @@ -977,6 +978,16 @@ checkCreateMPT( return tesSUCCESS; } +TER +checkCreateMPT( + xrpl::ApplyView& view, + xrpl::MPTIssue const& mptIssue, + xrpl::AccountID const& holder, + beast::Journal j) +{ + return checkCreateMPT(view, mptIssue, holder, {}, 0, j); +} + std::int64_t maxMPTAmount(SLE const& sleIssuance) { diff --git a/src/libxrpl/protocol/QualityFunction.cpp b/src/libxrpl/protocol/QualityFunction.cpp index e862770406..ffe583b7e1 100644 --- a/src/libxrpl/protocol/QualityFunction.cpp +++ b/src/libxrpl/protocol/QualityFunction.cpp @@ -38,7 +38,22 @@ QualityFunction::outFromAvgQ(Quality const& quality) return std::nullopt; return out; } - return std::nullopt; + // The sole caller (StrandFlow::limitOut) only invokes this on a non-const + // quality function, so m_ != 0 here, and a real payment/offer never yields + // a zero-rate limit quality (it would divide by zero above). This fallback + // is therefore unreachable in practice. + return std::nullopt; // LCOV_EXCL_LINE +} + +bool +QualityFunction::satisfiesAvgQ(Quality const& quality, Number const& out) const +{ + // satisfiesAvgQ is only reached from StrandFlow::limitOut *after* + // outFromAvgQ returned a value, which requires a non-zero rate. So a + // zero-rate quality never reaches here; this guard is defensive. + if (quality.rate() == beast::kZero) + return false; // LCOV_EXCL_LINE + return m_ * out + b_ >= 1 / quality.rate(); } } // namespace xrpl diff --git a/src/libxrpl/protocol/STAmount.cpp b/src/libxrpl/protocol/STAmount.cpp index 212c34322b..83b2983756 100644 --- a/src/libxrpl/protocol/STAmount.cpp +++ b/src/libxrpl/protocol/STAmount.cpp @@ -1445,6 +1445,59 @@ public: operator=(DontAffectNumberRoundMode const&) = delete; }; +Number::RoundingMode +roundMode(bool const resultNegative, bool const roundUp) +{ + using enum Number::RoundingMode; + // STAmount roundUp means "away from zero". The legacy scaled-mantissa + // multiply and divide paths reach that result with slightly different + // mechanics, including a final TowardsZero materialization in multiply. + // + // The MPT/V2 Number path already performs the operation under the directed + // mode below. Use the same mode again when converting back to STAmount so a + // fractional integral result stays consistently rounded after Number + // arithmetic, independent of whether the operation was multiply or divide. + return roundUp ^ resultNegative ? Upward : Downward; +} + +STAmount +roundNumberResult( + Asset const& asset, + bool const resultNegative, + bool const roundUp, + Number const& number) +{ + // MPT/V2 Number arithmetic uses directed rounding both for the operation + // and for materializing the final integral amount. + NumberRoundModeGuard const finalRound(roundMode(resultNegative, roundUp)); + auto result = STAmount{asset, number}; + [[maybe_unused]] bool const nonzeroPositiveRoundUp = + roundUp && !resultNegative && number != beast::kZero; + ALWAYS( + !nonzeroPositiveRoundUp || result != beast::kZero, + "xrpl::roundNumberResult : positive rounded-up MPT result is representable"); + + if (roundUp && !resultNegative && !result) + { + // Intended to preserve existing mulRound/divRound behavior for a + // positive result too small to represent in the target asset. + // + // Unreachable in practice: when roundUp is set, roundMode() above + // selects Upward, and materializing a Number into an STAmount honors + // that mode (Number::operator rep()), so any positive value rounds up + // to at least the smallest representable unit. Hence, a positive result + // is never !result here; the only zero case is a zero operand, which + // the mulRound/divRound callers handle before reaching this function. + // LCOV_EXCL_START + if (asset.integral()) + return STAmount{asset, 1}; + return STAmount{asset, STAmount::kMinValue, STAmount::kMinOffset, false}; + // LCOV_EXCL_STOP + } + + return result; +} + } // anonymous namespace // Pass the canonicalizeRound function pointer as a template parameter. @@ -1486,6 +1539,22 @@ mulRoundImpl(STAmount const& v1, STAmount const& v2, Asset const& asset, bool ro return STAmount(asset, minV * maxV); } + bool const resultNegative = v1.negative() != v2.negative(); + + if (asset.holds() && isFeatureEnabled(featureMPTokensV2, false)) + { + // MPT DEX can combine 63-bit MPT amounts with IOU-shaped transfer + // rates. Use Number arithmetic under MPTokensV2 so the rounded + // operation is not limited by the legacy uint64_t scaled mantissa. + Number result; + { + NumberRoundModeGuard const operationRound(roundMode(resultNegative, roundUp)); + result = Number{v1} * Number{v2}; + } + + return roundNumberResult(asset, resultNegative, roundUp, result); + } + std::uint64_t value1 = v1.mantissa(), value2 = v2.mantissa(); int offset1 = v1.exponent(), offset2 = v2.exponent(); @@ -1506,9 +1575,6 @@ mulRoundImpl(STAmount const& v1, STAmount const& v2, Asset const& asset, bool ro --offset2; } } - - bool const resultNegative = v1.negative() != v2.negative(); - // We multiply the two mantissas (each is between 10^15 // and 10^16), so their product is in the 10^30 to 10^32 // range. Dividing their product by 10^14 maintains the @@ -1575,6 +1641,22 @@ divRoundImpl(STAmount const& num, STAmount const& den, Asset const& asset, bool if (num == beast::kZero) return {asset}; + bool const resultNegative = (num.negative() != den.negative()); + + if (asset.holds() && isFeatureEnabled(featureMPTokensV2, false)) + { + // Match the multiply path above: Number performs the rounded + // operation, then STAmount materializes the final MPT amount using the + // same final rounding mode as the legacy path below. + Number result; + { + NumberRoundModeGuard const operationRound(roundMode(resultNegative, roundUp)); + result = Number{num} / Number{den}; + } + + return roundNumberResult(asset, resultNegative, roundUp, result); + } + std::uint64_t numVal = num.mantissa(), denVal = den.mantissa(); int numOffset = num.exponent(), denOffset = den.exponent(); @@ -1596,8 +1678,6 @@ divRoundImpl(STAmount const& num, STAmount const& den, Asset const& asset, bool } } - bool const resultNegative = (num.negative() != den.negative()); - // We divide the two mantissas (each is between 10^15 // and 10^16). To maintain precision, we multiply the // numerator by 10^17 (the product is in the range of diff --git a/src/libxrpl/tx/invariants/MPTInvariant.cpp b/src/libxrpl/tx/invariants/MPTInvariant.cpp index 77c5ad781e..12ec078c82 100644 --- a/src/libxrpl/tx/invariants/MPTInvariant.cpp +++ b/src/libxrpl/tx/invariants/MPTInvariant.cpp @@ -282,12 +282,13 @@ ValidMPTIssuance::finalize( "but created bad number of mptokens"; return false; } - // At most one MPToken may be created on withdraw/clawback since: + // At most two MPToken may be created on withdraw/clawback since: // - Liquidity Provider must have at least one token in order - // participate in AMM pool liquidity. + // participate in AMM pool liquidity or have LPTokens only. // - At most two MPTokens may be deleted if AMM pool, which has exactly // two tokens, is empty after withdraw/clawback. - if (mptokensCreated_ > 1 || mptokensDeleted_ > 2) + SOMETIMES(mptokensCreated_ == 2, "AMM withdraw/clawback recreated two MPTokens"); + if (mptokensCreated_ > 2 || mptokensDeleted_ > 2) { JLOG(j.fatal()) << "Invariant failed: MPT authorize succeeded " "but created/deleted bad number of mptokens"; diff --git a/src/libxrpl/tx/paths/BookStep.cpp b/src/libxrpl/tx/paths/BookStep.cpp index e7c2e9ee29..2823627108 100644 --- a/src/libxrpl/tx/paths/BookStep.cpp +++ b/src/libxrpl/tx/paths/BookStep.cpp @@ -44,7 +44,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -653,7 +655,15 @@ limitStepIn( // under an amendment. ofrAmt = offer.limitIn(ofrAmt, inLmt, /* roundUp */ false); stpAmt.out = ofrAmt.out; - ownerGives = mulRatio(ofrAmt.out, transferRateOut, QUALITY_ONE, /*roundUp*/ false); + // Round up for MPT output so the offer owner pays the full + // ceil(amount × rate) fee, matching direct Payment semantics. IOU uses + // floating-point arithmetic so the floor/ceil distinction is sub-epsilon + // there; preserve the historical false to avoid changing IOU behavior. + ownerGives = mulRatio( + ofrAmt.out, + transferRateOut, + QUALITY_ONE, + /*roundUp*/ std::is_same_v); } } @@ -672,7 +682,11 @@ limitStepOut( if (limit < stpAmt.out) { stpAmt.out = limit; - ownerGives = mulRatio(stpAmt.out, transferRateOut, QUALITY_ONE, /*roundUp*/ false); + ownerGives = mulRatio( + stpAmt.out, + transferRateOut, + QUALITY_ONE, + /*roundUp*/ std::is_same_v); ofrAmt = offer.limitOut( ofrAmt, stpAmt.out, @@ -727,17 +741,20 @@ BookStep::forEachOffer( bool const isAssetInMPT = assetIn.holds(); auto const& owner = offer.owner(); - if (isAssetInMPT) - { - // Create MPToken for the offer's owner. No need to check - // for the reserve since the offer is removed if it is consumed. - // Therefore, the owner count remains the same. - if (auto const err = checkCreateMPT(sb, assetIn.get(), owner, {}, j_); - !isTesSuccess(err)) + auto removeOffer = [&](std::string_view logMessage = {}) { + auto const key = offer.key(); + if (!logMessage.empty()) { - return true; + JLOG(j_.trace()) << logMessage << (key ? " " + to_string(*key) : ""); } - } + if (key) + offers.permRmOffer(*key); + if (!offerAttempted) + { + // Change quality only if no previous offers were tried. + ofrQ = std::nullopt; + } + }; // It shouldn't matter from auth point of view whether it's sb // or afView. Amendment guard this change just in case. @@ -745,17 +762,15 @@ BookStep::forEachOffer( // Make sure offer owner has authorization to own Assets from issuer // and MPT assets can be traded/transferred. // An account can always own XRP or their own Assets. - if (!isTesSuccess(requireAuth(applyView, assetIn, owner)) || !checkMPTDEX(sb, owner)) + // Missing MPTokens are allowed during offer discovery; they are + // created later if the offer is actually consumed. + auto const authType = isAssetInMPT ? AuthType::WeakAuth : AuthType::Legacy; + if (!isTesSuccess(requireAuth(applyView, assetIn, owner, authType)) || + !checkMPTDEX(sb, owner)) { // Offer owner not authorized to hold IOU/MPT from issuer. // Remove this offer even if no crossing occurs. - if (auto const key = offer.key()) - offers.permRmOffer(*key); - if (!offerAttempted) - { - // Change quality only if no previous offers were tried. - ofrQ = std::nullopt; - } + removeOffer(); // Returning true causes offers.step() to delete the offer. return true; } @@ -768,52 +783,88 @@ BookStep::forEachOffer( static_cast(this)->getOfrOutRate(prevStep_, owner, strandDst_, trOut)); auto ofrAmt = offer.amount(); - TAmounts stpAmt{mulRatio(ofrAmt.in, ofrInRate, QUALITY_ONE, /*roundUp*/ true), ofrAmt.out}; - - // owner pays the transfer fee. - auto ownerGives = mulRatio(ofrAmt.out, ofrOutRate, QUALITY_ONE, /*roundUp*/ false); - - auto const funds = offer.isFunded() - ? ownerGives // Offer owner is issuer; they have unlimited funds - : offers.ownerFunds(); - - // Only if CLOB offer - if (funds < ownerGives) + TAmounts stpAmt{ofrAmt.in, ofrAmt.out}; + auto ownerGives = ofrAmt.out; + try { - // We already know offer.owner()!=offer.issueOut().account - ownerGives = funds; - stpAmt.out = mulRatio(ownerGives, QUALITY_ONE, ofrOutRate, /*roundUp*/ false); - - // It turns out we can prevent order book blocking by (strictly) - // rounding down the ceil_out() result. This adjustment changes - // transaction outcomes, so it must be made under an amendment. - ofrAmt = offer.limitOut(ofrAmt, stpAmt.out, /*roundUp*/ false); - + // All arithmetic in this block runs before the offer is consumed. + // A crafted MPTokensV2 offer can overflow while transfer rates or + // crossing limits are applied; remove that unusable offer instead + // of letting it persist as a tecINTERNAL source. stpAmt.in = mulRatio(ofrAmt.in, ofrInRate, QUALITY_ONE, /*roundUp*/ true); - } - // Limit offer's input if MPT, BookStep is the first step (an issuer - // is making a cross-currency payment), and this offer is not owned - // by the issuer. Otherwise, OutstandingAmount may overflow. - auto const& issuer = assetIn.getIssuer(); - if (isAssetInMPT && !prevStep_ && offer.owner() != issuer) - { - // Funds available to issue - auto const available = toAmount(accountFunds( - sb, - issuer, - assetIn, // STAmount{0}, but the default is not used - FreezeHandling::IgnoreFreeze, - AuthHandling::IgnoreAuth, - j_)); - if (stpAmt.in > available) + // owner pays the transfer fee. + ownerGives = mulRatio( + ofrAmt.out, + ofrOutRate, + QUALITY_ONE, + /*roundUp*/ std::is_same_v); + + auto const funds = offer.isFunded() + ? ownerGives // Offer owner is issuer; they have unlimited funds + : offers.ownerFunds(); + + // Only if CLOB offer + if (funds < ownerGives) { - limitStepIn(offer, ofrAmt, stpAmt, ownerGives, ofrInRate, ofrOutRate, available); - } - } + // We already know offer.owner()!=offer.issueOut().account + ownerGives = funds; + stpAmt.out = mulRatio(ownerGives, QUALITY_ONE, ofrOutRate, /*roundUp*/ false); - offerAttempted = true; - return callback(offer, ofrAmt, stpAmt, ownerGives, ofrInRate, ofrOutRate); + // It turns out we can prevent order book blocking by (strictly) + // rounding down the ceil_out() result. This adjustment changes + // transaction outcomes, so it must be made under an amendment. + ofrAmt = offer.limitOut(ofrAmt, stpAmt.out, /*roundUp*/ false); + + stpAmt.in = mulRatio(ofrAmt.in, ofrInRate, QUALITY_ONE, /*roundUp*/ true); + } + + // Limit offer's input if MPT, BookStep is the first step (an issuer + // is making a cross-currency payment), and this offer is not owned + // by the issuer. Otherwise, OutstandingAmount may overflow. + auto const& issuer = assetIn.getIssuer(); + if (isAssetInMPT && !prevStep_ && offer.owner() != issuer) + { + // Funds available to issue + auto const available = toAmount(accountFunds( + sb, + issuer, + assetIn, // STAmount{0}, but the default is not used + FreezeHandling::IgnoreFreeze, + AuthHandling::IgnoreAuth, + j_)); + if (stpAmt.in > available) + { + limitStepIn( + offer, ofrAmt, stpAmt, ownerGives, ofrInRate, ofrOutRate, available); + } + } + + offerAttempted = true; + return callback(offer, ofrAmt, stpAmt, ownerGives, ofrInRate, ofrOutRate); + } + catch (std::overflow_error const&) + { + if (sb.rules().enabled(featureMPTokensV2)) + { + SOMETIMES( + true, + "BookStep::forEachOffer removed MPT offer after " + "overflow during crossing"); + removeOffer("Removing offer with overflowing amount calculation"); + return true; + } + // An overflow can only be produced by a crafted MPT offer, and MPT + // offers require featureMPTokensV2 (enforced at OfferCreate + // preflight). So the amendment is always enabled when we get here + // and this legacy re-throw is unreachable in practice. + // LCOV_EXCL_START + XRPL_ASSERT( + sb.rules().enabled(featureMPTokensV2), + "xrpl::BookStep::forEachOffer : overflow implies MPTokensV2"); + throw; + // LCOV_EXCL_STOP + } }; // At any payment engine iteration, AMM offer can only be consumed once. @@ -873,6 +924,22 @@ BookStep::consumeOffer( // The offer owner gets the ofrAmt. The difference between ofrAmt and // stepAmt is a transfer fee that goes to book_.in.account { + if constexpr (std::is_same_v) + { + // If the offer's TakerPays asset is an MPT, the offer owner must + // hold an MPToken to receive it. Create one here if it doesn't + // already exist. + if (auto const err = checkCreateMPT(sb, book_.in.get(), offer.owner(), j_); + !isTesSuccess(err)) + { + // checkCreateMPT only fails on tecDIR_FULL (its source line is + // itself LCOV-excluded) or a missing offer-owner account, which + // cannot happen since that account owns the offer being + // consumed. Defensive and unreachable in practice. + Throw(err); // LCOV_EXCL_LINE + } + } + auto const dr = offer.send( sb, book_.in.getIssuer(), offer.owner(), toSTAmount(ofrAmt.in, book_.in), j_); if (!isTesSuccess(dr)) @@ -1043,6 +1110,13 @@ BookStep::revImp( auto ofrAdjAmt = ofrAmt; auto stpAdjAmt = stpAmt; auto ownerGivesAdj = ownerGives; + // This reduction can overflow via the transfer-rate mulRatio() on a + // 63-bit MPT amount (IOU rescales instead of throwing, and XRP stays + // under the int64 limit, so only MPT reaches it today), but + // savedIns/savedOuts are not updated until after it succeeds. The outer + // execOffer() catch can therefore remove the offer under + // featureMPTokensV2 (legacy propagate-the-exception behavior otherwise) + // without rolling back local state. limitStepOut( offer, ofrAdjAmt, @@ -1144,12 +1218,25 @@ BookStep::fwdImp( auto stpAdjAmt = stpAmt; auto ownerGivesAdj = ownerGives; + // limitStepIn()/limitStepOut() can throw std::overflow_error from the + // transfer-rate mulRatio() on a 63-bit MPT amount. (IOUAmount::mulRatio + // rescales rather than throwing, and XRP amounts/rates stay under the + // int64 limit, so in practice only MPT reaches this today.) execOffer() + // catches it: under featureMPTokensV2 the offending offer is removed; + // otherwise the legacy behavior (propagate the exception) is preserved. + // Keep candidate accumulator changes local until those calls succeed so + // the catch path does not observe partially updated state. Re-sum the + // staged sets to preserve historical flat_multiset summing behavior. + auto savedInsAdj = savedIns; + auto savedOutsAdj = savedOuts; + auto resultAdj = result; typename boost::container::flat_multiset::const_iterator lastOut; + if (stpAmt.in <= remainingIn) { - savedIns.insert(stpAmt.in); - lastOut = savedOuts.insert(stpAmt.out); - result = TAmounts(sum(savedIns), sum(savedOuts)); + savedInsAdj.insert(stpAmt.in); + lastOut = savedOutsAdj.insert(stpAmt.out); + resultAdj = TAmounts(sum(savedInsAdj), sum(savedOutsAdj)); // consume the offer even if stepAmt.in == remainingIn processMore = true; } @@ -1163,15 +1250,15 @@ BookStep::fwdImp( transferRateIn, transferRateOut, remainingIn); - savedIns.insert(remainingIn); - lastOut = savedOuts.insert(stpAdjAmt.out); - result.out = sum(savedOuts); - result.in = in; + savedInsAdj.insert(remainingIn); + lastOut = savedOutsAdj.insert(stpAdjAmt.out); + resultAdj.out = sum(savedOutsAdj); + resultAdj.in = in; processMore = false; } - if (result.out > cache_->out && result.in <= cache_->in) + if (resultAdj.out > cache_->out && resultAdj.in <= cache_->in) { // The step produced more output in the forward pass than the // reverse pass while consuming the same input (or less). If we @@ -1181,8 +1268,8 @@ BookStep::fwdImp( // input provided in the forward step and produce the output // requested from the reverse step. auto const lastOutAmt = *lastOut; - savedOuts.erase(lastOut); - auto const remainingOut = cache_->out - sum(savedOuts); + savedOutsAdj.erase(lastOut); + auto const remainingOut = cache_->out - sum(savedOutsAdj); auto ofrAdjAmtRev = ofrAmt; auto stpAdjAmtRev = stpAmt; auto ownerGivesAdjRev = ownerGives; @@ -1197,13 +1284,13 @@ BookStep::fwdImp( if (stpAdjAmtRev.in == remainingIn) { - result.in = in; - result.out = cache_->out; + resultAdj.in = in; + resultAdj.out = cache_->out; - savedIns.clear(); - savedIns.insert(result.in); - savedOuts.clear(); - savedOuts.insert(result.out); + savedInsAdj.clear(); + savedInsAdj.insert(resultAdj.in); + savedOutsAdj.clear(); + savedOutsAdj.insert(resultAdj.out); ofrAdjAmt = ofrAdjAmtRev; stpAdjAmt.in = remainingIn; @@ -1214,10 +1301,15 @@ BookStep::fwdImp( { // This is (likely) a problem case, and will be caught // with later checks - savedOuts.insert(lastOutAmt); + savedOutsAdj.insert(lastOutAmt); } } + // Commit the staged accounting only after limitStepIn()/limitStepOut() + // have succeeded. + savedIns = std::move(savedInsAdj); + savedOuts = std::move(savedOutsAdj); + result = resultAdj; remainingIn = in - result.in; this->consumeOffer(sb, offer, ofrAdjAmt, stpAdjAmt, ownerGivesAdj); diff --git a/src/libxrpl/tx/paths/MPTEndpointStep.cpp b/src/libxrpl/tx/paths/MPTEndpointStep.cpp index 0a0f6a9f27..a47cfa15a5 100644 --- a/src/libxrpl/tx/paths/MPTEndpointStep.cpp +++ b/src/libxrpl/tx/paths/MPTEndpointStep.cpp @@ -410,8 +410,7 @@ MPTEndpointOfferCrossingStep::checkCreateMPT(ApplyView& view, xrpl::DebtDirectio // for the reserve since the offer doesn't go on the books // if crossed. Insufficient reserve is allowed if the offer // crossed. See CreateOffer::applyGuts() for reserve check. - if (auto const err = xrpl::checkCreateMPT(view, mptIssue_, dst_, {}, j_); - !isTesSuccess(err)) + if (auto const err = xrpl::checkCreateMPT(view, mptIssue_, dst_, j_); !isTesSuccess(err)) { JLOG(j_.trace()) << "MPTEndpointStep::checkCreateMPT: failed create MPT"; resetCache(srcDebtDir); diff --git a/src/libxrpl/tx/paths/OfferStream.cpp b/src/libxrpl/tx/paths/OfferStream.cpp index ecc8416a2b..2f2fef49f0 100644 --- a/src/libxrpl/tx/paths/OfferStream.cpp +++ b/src/libxrpl/tx/paths/OfferStream.cpp @@ -29,6 +29,8 @@ #include #include +#include +#include namespace xrpl { @@ -136,17 +138,17 @@ template TOfferStreamBase::shouldRmSmallIncreasedQOffer() const { // Consider removing the offer if: - // o `TakerPays` is XRP (because of XRP drops granularity) or + // o `TakerPays` is integral (because XRP/MPT have indivisible units) or // o `TakerPays` and `TakerGets` are both IOU and `TakerPays`<`TakerGets` - static constexpr bool kInIsXrp = std::is_same_v; - static constexpr bool kOutIsXrp = std::is_same_v; + constexpr bool const kInIsIntegral = !std::is_same_v; + constexpr bool const kOutIsIntegral = !std::is_same_v; - if constexpr (kOutIsXrp) + if constexpr (!kInIsIntegral && kOutIsIntegral) { - // If `TakerGets` is XRP, the worst this offer's quality can change is - // to about 10^-81 `TakerPays` and 1 drop `TakerGets`. This will be - // remarkably good quality for any realistic asset, so these offers - // don't need this extra check. + // If only `TakerGets` is integral, the worst this offer's quality can + // change is to about 10^-81 `TakerPays` and 1 unit `TakerGets`. This + // will be perfect quality for any realistic asset, so these + // offers don't need this extra check. return false; } @@ -156,7 +158,7 @@ TOfferStreamBase::shouldRmSmallIncreasedQOffer() const TAmounts const ofrAmts{ toAmount(offer_.amount().in), toAmount(offer_.amount().out)}; - if constexpr (!kInIsXrp && !kOutIsXrp) + if constexpr (!kInIsIntegral && !kOutIsIntegral) { if (Number(ofrAmts.in) >= Number(ofrAmts.out)) return false; @@ -165,7 +167,12 @@ TOfferStreamBase::shouldRmSmallIncreasedQOffer() const TTakerGets const ownerFunds = toAmount(*ownerFunds_); auto const effectiveAmounts = [&] { - if (offer_.owner() != offer_.assetOut().getIssuer() && ownerFunds < ofrAmts.out) + // Issuer-owned IOU offers are self-funded without a limit. MPT issuer + // offers are bounded by remaining issuance capacity, so they still need + // to be clipped by ownerFunds. + bool const issuerHasUnlimitedFunds = offer_.owner() == offer_.assetOut().getIssuer() && + offer_.assetOut().template holds(); + if (!issuerHasUnlimitedFunds && ownerFunds < ofrAmts.out) { // adjust the amounts by owner funds. // @@ -305,7 +312,41 @@ TOfferStreamBase::step() continue; } - if (shouldRmSmallIncreasedQOffer()) + // Partially funded offers can be reduced before BookStep sees them. + // If that strict reduction overflows under MPTokensV2, remove the + // unusable offer instead of leaving it at the book tip. + bool shouldRemoveSmallIncreasedQOffer = false; + try + { + shouldRemoveSmallIncreasedQOffer = shouldRmSmallIncreasedQOffer(); + } + catch (std::overflow_error const&) + { + if (view_.rules().enabled(featureMPTokensV2)) + { + SOMETIMES( + true, + "OfferStream::step removed MPT offer with overflowing " + "reduced quality"); + permRmOffer(entry->key()); + JLOG(j_.warn()) << "Removing offer with overflowing reduced quality " + << entry->key(); + offer_ = TOffer{}; + continue; + } + // The strict reduction only overflows for a crafted MPT offer, and + // MPT offers require featureMPTokensV2 (enforced at OfferCreate + // preflight). So the amendment is always enabled here and this + // legacy re-throw is unreachable in practice. + // LCOV_EXCL_START + XRPL_ASSERT( + view_.rules().enabled(featureMPTokensV2), + "xrpl::TOfferStreamBase::step : overflow implies MPTokensV2"); + throw; + // LCOV_EXCL_STOP + } + + if (shouldRemoveSmallIncreasedQOffer) { auto const originalFunds = accountFundsHelper( cancelView_, diff --git a/src/libxrpl/tx/transactors/check/CheckCash.cpp b/src/libxrpl/tx/transactors/check/CheckCash.cpp index e4d8f192c0..857f759752 100644 --- a/src/libxrpl/tx/transactors/check/CheckCash.cpp +++ b/src/libxrpl/tx/transactors/check/CheckCash.cpp @@ -528,7 +528,7 @@ CheckCash::doApply() return tecINSUFFICIENT_RESERVE; if (auto const err = - checkCreateMPT(psb, mptID, accountID_, *sponsorSle, j_); + checkCreateMPT(psb, mptID, accountID_, *sponsorSle, 0, j_); !isTesSuccess(err)) { return err; diff --git a/src/libxrpl/tx/transactors/dex/AMMClawback.cpp b/src/libxrpl/tx/transactors/dex/AMMClawback.cpp index 455b2ad5c5..e690cd7693 100644 --- a/src/libxrpl/tx/transactors/dex/AMMClawback.cpp +++ b/src/libxrpl/tx/transactors/dex/AMMClawback.cpp @@ -227,6 +227,7 @@ AMMClawback::applyGuts(Sandbox& sb) sb, *ammSle, holder, + issuer, ammAccount, amountBalance, amount2Balance, @@ -311,6 +312,14 @@ AMMClawback::equalWithdrawMatchingOneAmount( STAmount const& holdLPtokens, STAmount const& amount) { + // The clawback issuer signs for its own asset only. Threaded into the + // withdrawal so a recreated MPToken is auto-authorized only for the + // clawback issuer's asset, never for a paired asset from another issuer. + // preflight guarantees sfAccount is the clawed asset's issuer (it rejects + // the tx as temMALFORMED when sfAsset's issuer != sfAccount), so this is + // the issuer, not just any signer. + AccountID const issuer = ctx_.tx[sfAccount]; + auto frac = Number{amount} / amountBalance; auto amount2Withdraw = amount2Balance * frac; @@ -324,6 +333,7 @@ AMMClawback::equalWithdrawMatchingOneAmount( sb, ammSle, holder, + issuer, ammAccount, amountBalance, amount2Balance, @@ -364,6 +374,7 @@ AMMClawback::equalWithdrawMatchingOneAmount( sb, ammSle, ammAccount, + issuer, holder, amountBalance, amountRounded, @@ -384,6 +395,7 @@ AMMClawback::equalWithdrawMatchingOneAmount( sb, ammSle, ammAccount, + issuer, holder, amountBalance, amount, diff --git a/src/libxrpl/tx/transactors/dex/AMMWithdraw.cpp b/src/libxrpl/tx/transactors/dex/AMMWithdraw.cpp index 5294dd0c7f..edd2cc2037 100644 --- a/src/libxrpl/tx/transactors/dex/AMMWithdraw.cpp +++ b/src/libxrpl/tx/transactors/dex/AMMWithdraw.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -516,6 +517,7 @@ AMMWithdraw::withdraw( view, ammSle, ammAccount, + std::nullopt, accountID_, amountBalance, amountWithdraw, @@ -536,6 +538,7 @@ AMMWithdraw::withdraw( Sandbox& view, SLE const& ammSle, AccountID const& ammAccount, + std::optional const& clawbackIssuer, AccountID const& account, STAmount const& amountBalance, STAmount const& amountWithdraw, @@ -703,14 +706,48 @@ AMMWithdraw::withdraw( if (mptokenKey && account != asset.getIssuer()) { auto const& mptIssue = asset.get(); + std::uint32_t createFlags = 0; if (auto const err = requireAuth(view, mptIssue, account, AuthType::WeakAuth); !isTesSuccess(err)) - return err; + { + if (authHandling != AuthHandling::IgnoreAuth || err != tecNO_AUTH) + { + // Unreachable in practice. Normal withdraws (authHandling + // != IgnoreAuth) are rejected for unauthorized holders in + // preclaim, so they never get here. Under clawback + // (IgnoreAuth) requireAuth returns a non-tecNO_AUTH error + // (e.g. tecEXPIRED) only for a domain-authorized MPT, but no + // such MPT can be in an AMM pool: a directly domain-gated + // RequireAuth MPT fails AMMCreate/deposit with tecNO_AUTH, + // and vault shares (whose recursive auth could yield + // tecEXPIRED) are rejected by AMMCreate with tecWRONG_ASSET. + return err; // LCOV_EXCL_LINE + } - if (auto const err = checkCreateMPT(view, mptIssue, account, {}, journal); + // AMMClawback ignores authorization so the issuer can recover + // MPT locked in the pool even if the holder deleted their + // MPToken. Only auto-authorize the recreated MPToken for the + // clawback issuer's own asset: authorization is granted by an + // asset's issuer, and the clawback transaction is signed by + // that issuer only for its own asset. For a paired asset issued + // by a different account, recreate the MPToken *unauthorized* so + // the clawback does not grant authorization on behalf of that + // issuer (which would bypass its lsfMPTRequireAuth). The holder + // still receives the paired asset (accountSend only requires the + // MPToken to exist, not to be authorized); the balance remains + // gated by its issuer until that issuer authorizes it. + if (clawbackIssuer && asset.getIssuer() == *clawbackIssuer) + createFlags = lsfMPTAuthorized; + } + + if (auto const err = checkCreateMPT(view, mptIssue, account, {}, createFlags, journal); !isTesSuccess(err)) { - return err; + // checkCreateMPT only fails on tecDIR_FULL (its source line is + // itself LCOV-excluded) or a missing account, which cannot + // happen since `account` is the withdrawing LP. Defensive and + // unreachable in practice. + return err; // LCOV_EXCL_LINE } } return tesSUCCESS; @@ -804,6 +841,7 @@ AMMWithdraw::equalWithdrawTokens( view, ammSle, accountID_, + std::nullopt, ammAccount, amountBalance, amount2Balance, @@ -856,6 +894,7 @@ AMMWithdraw::equalWithdrawTokens( Sandbox& view, SLE const& ammSle, AccountID const account, + std::optional const& clawbackIssuer, AccountID const& ammAccount, STAmount const& amountBalance, STAmount const& amount2Balance, @@ -878,6 +917,7 @@ AMMWithdraw::equalWithdrawTokens( view, ammSle, ammAccount, + clawbackIssuer, account, amountBalance, amountBalance, @@ -913,6 +953,7 @@ AMMWithdraw::equalWithdrawTokens( view, ammSle, ammAccount, + clawbackIssuer, account, amountBalance, amountWithdraw, diff --git a/src/test/app/AMMClawbackMPT_test.cpp b/src/test/app/AMMClawbackMPT_test.cpp index 6c7aa99156..1d75c4db22 100644 --- a/src/test/app/AMMClawbackMPT_test.cpp +++ b/src/test/app/AMMClawbackMPT_test.cpp @@ -16,6 +16,8 @@ #include #include #include +#include +#include #include #include #include @@ -1476,6 +1478,60 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite } } + void + testClawbackCreatesMissingMPToken(FeatureBitset features) + { + testcase("test AMMClawback creates missing MPToken"); + using namespace jtx; + + auto test = [&](std::optional const clawAmount) { + Env env{*this, features}; + Account const gw{"gateway"}; + Account const alice{"alice"}; + env.fund(XRP(1'000'000), gw, alice); + env.close(); + + MPTTester token( + {.env = env, + .issuer = gw, + .holders = {alice}, + .pay = 1'000, + .flags = tfMPTCanClawback | tfMPTRequireAuth | kMptDexFlags, + .authHolder = true}); + + AMM ammAlice(env, alice, token(1'000), XRP(1'000)); + env.close(); + BEAST_EXPECT(env.balance(alice, token) == token(0)); + + // The holder can delete the zero-balance MPToken while still + // holding LP tokens. A regular AMMWithdraw remains subject to + // RequireAuth and cannot recreate the missing token. + token.authorize({.account = alice, .flags = tfMPTUnauthorize}); + env.close(); + BEAST_EXPECT(!env.le(keylet::mptoken(token.issuanceID(), alice.id()))); + ammAlice.withdrawAll(alice, std::nullopt, Ter(tecNO_AUTH)); + env.close(); + BEAST_EXPECT(!env.le(keylet::mptoken(token.issuanceID(), alice.id()))); + + // AMMClawback ignores authorization and must be able to recreate + // the holder MPToken so the issuer can recover MPT from the pool. + std::optional amount; + if (clawAmount) + amount = token(*clawAmount); + env(amm::ammClawback(gw, alice, token, XRP, amount)); + env.close(); + + auto const sleMpt = env.le(keylet::mptoken(token.issuanceID(), alice.id())); + BEAST_EXPECT(sleMpt && sleMpt->isFlag(lsfMPTAuthorized)); + env.require(Balance(alice, token(0))); + + BEAST_EXPECT(clawAmount ? ammAlice.ammExists() : !ammAlice.ammExists()); + }; + + test(std::nullopt); + test(400); + } + void testSingleDepositAndClawback(FeatureBitset features) { @@ -1949,6 +2005,199 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite } } + // Test that AMMClawback succeeds when the LP has previously deleted both + // zero-balance MPToken objects in an MPT/MPT pool. The fix changes the + // ValidMPTIssuance invariant threshold from > 1 to > 2 so that the two + // MPToken creations triggered by the internal AMMWithdraw are permitted. + void + testClawbackAfterDeletingMPTokens(FeatureBitset features) + { + testcase("test AMMClawback after holder deletes zero-balance MPTokens"); + using namespace jtx; + + // Partial clawback (one asset): verify both MPTokens are recreated and + // the non-claw asset is returned to alice. + { + Env env(*this, features); + Account const gw{"gateway"}; + Account const alice{"alice"}; + env.fund(XRP(100'000), gw, alice); + env.close(); + + MPTTester btc( + {.env = env, + .issuer = gw, + .holders = {alice}, + .pay = 10'000, + .flags = tfMPTCanClawback | kMptDexFlags}); + + MPTTester eth( + {.env = env, + .issuer = gw, + .holders = {alice}, + .pay = 10'000, + .flags = tfMPTCanClawback | kMptDexFlags}); + + // Alice deposits everything into the MPT/MPT pool; her MPT + // balances drop to zero. + AMM const amm(env, alice, btc(10'000), eth(10'000)); + env.close(); + BEAST_EXPECT(amm.expectBalances(btc(10'000), eth(10'000), IOUAmount{10'000})); + + auto aliceBTC = env.balance(alice, btc); + auto aliceETH = env.balance(alice, eth); + BEAST_EXPECT(aliceBTC == btc(0)); + BEAST_EXPECT(aliceETH == eth(0)); + + // Alice deletes both zero-balance MPTokens to reclaim reserves. + btc.authorize({.account = alice, .flags = tfMPTUnauthorize}); + eth.authorize({.account = alice, .flags = tfMPTUnauthorize}); + BEAST_EXPECT(!env.le(keylet::mptoken(btc.issuanceID(), alice.id()))); + BEAST_EXPECT(!env.le(keylet::mptoken(eth.issuanceID(), alice.id()))); + + // gw claws back some BTC from alice's share in the pool. + // AMMWithdraw internally creates both missing MPTokens + // (mptokensCreated_ == 2); the invariant (> 2) allows this. + env(amm::ammClawback(gw, alice, btc, eth, btc(1'000))); + env.close(); + + // Both MPToken objects must have been recreated. + BEAST_EXPECT(env.le(keylet::mptoken(btc.issuanceID(), alice.id()))); + BEAST_EXPECT(env.le(keylet::mptoken(eth.issuanceID(), alice.id()))); + + // The non-claw asset (eth) was returned to alice. + BEAST_EXPECT(env.balance(alice, eth) > aliceETH); + // The claw asset (btc) was burned; alice's btc balance stays 0. + env.require(Balance(alice, aliceBTC)); + BEAST_EXPECT(amm.ammExists()); + } + + // Full clawback (two assets, tfClawTwoAssets): verify both MPTokens + // are recreated and the AMM is deleted when fully drained. + { + Env env(*this, features); + Account const gw{"gateway"}; + Account const alice{"alice"}; + env.fund(XRP(100'000), gw, alice); + env.close(); + + MPTTester btc( + {.env = env, + .issuer = gw, + .holders = {alice}, + .pay = 10'000, + .flags = tfMPTCanClawback | kMptDexFlags}); + + MPTTester eth( + {.env = env, + .issuer = gw, + .holders = {alice}, + .pay = 10'000, + .flags = tfMPTCanClawback | kMptDexFlags}); + + AMM const amm(env, alice, btc(10'000), eth(10'000)); + env.close(); + + auto aliceBTC = env.balance(alice, btc); + auto aliceETH = env.balance(alice, eth); + + btc.authorize({.account = alice, .flags = tfMPTUnauthorize}); + eth.authorize({.account = alice, .flags = tfMPTUnauthorize}); + BEAST_EXPECT(!env.le(keylet::mptoken(btc.issuanceID(), alice.id()))); + BEAST_EXPECT(!env.le(keylet::mptoken(eth.issuanceID(), alice.id()))); + + // Full two-asset clawback: both assets are clawed and alice + // receives nothing back. The AMM should be empty and deleted. + env(amm::ammClawback(gw, alice, btc, eth, std::nullopt), Txflags(tfClawTwoAssets)); + env.close(); + + BEAST_EXPECT(!amm.ammExists()); + // Both assets were clawed; alice's balances remain at zero. + env.require(Balance(alice, aliceBTC)); + env.require(Balance(alice, aliceETH)); + } + } + + void + testClawbackCrossIssuerPairedAssetAuth(FeatureBitset features) + { + testcase("test AMMClawback recreates paired-issuer MPToken unauthorized"); + using namespace jtx; + + // Cross-issuer MPT/MPT pool: btc is issued by gw, eth by gw2, and both + // require authorization. Alice deposits her entire balance of both and + // deletes the resulting zero-balance MPTokens. When gw claws back its + // own asset (btc), the two-asset withdrawal must recreate both of + // Alice's MPTokens so the pool can pay her the paired asset. The + // recreated MPToken may only be auto-authorized for the clawback + // issuer's own asset (btc); the paired asset's issuer (gw2) never + // consented, so eth must be recreated *unauthorized*, leaving gw2 in + // control of its own token and preserving its RequireAuth guarantee. + Env env(*this, features); + Account const gw{"gateway"}; + Account const gw2{"gateway2"}; + Account const alice{"alice"}; + env.fund(XRP(100'000), gw, gw2, alice); + env.close(); + + MPTTester btc( + {.env = env, + .issuer = gw, + .holders = {alice}, + .pay = 10'000, + .flags = tfMPTCanClawback | tfMPTRequireAuth | kMptDexFlags, + .authHolder = true}); + + MPTTester eth( + {.env = env, + .issuer = gw2, + .holders = {alice}, + .pay = 10'000, + .flags = tfMPTCanClawback | tfMPTRequireAuth | kMptDexFlags, + .authHolder = true}); + + // Alice deposits everything into the pool; her MPT balances drop to 0. + AMM const amm(env, alice, btc(10'000), eth(10'000)); + env.close(); + BEAST_EXPECT(amm.expectBalances(btc(10'000), eth(10'000), IOUAmount{10'000})); + BEAST_EXPECT(env.balance(alice, btc) == btc(0)); + BEAST_EXPECT(env.balance(alice, eth) == eth(0)); + + // Alice deletes both zero-balance MPTokens to reclaim reserves. + btc.authorize({.account = alice, .flags = tfMPTUnauthorize}); + eth.authorize({.account = alice, .flags = tfMPTUnauthorize}); + BEAST_EXPECT(!env.le(keylet::mptoken(btc.issuanceID(), alice.id()))); + BEAST_EXPECT(!env.le(keylet::mptoken(eth.issuanceID(), alice.id()))); + + // gw (issuer of btc) claws back part of Alice's btc. This is a + // cross-issuer pool, so tfClawTwoAssets is not permitted: only btc is + // clawed back, while the paired eth is returned to Alice. + env(amm::ammClawback(gw, alice, btc, eth, btc(1'000))); + env.close(); + + // Both MPTokens were recreated so the withdrawal could pay Alice. + auto const sleBtc = env.le(keylet::mptoken(btc.issuanceID(), alice.id())); + auto const sleEth = env.le(keylet::mptoken(eth.issuanceID(), alice.id())); + BEAST_EXPECT(sleBtc); + BEAST_EXPECT(sleEth); + + // The clawback issuer's own asset (btc) may be recreated authorized: + // gw has authority over its own token. + BEAST_EXPECT(sleBtc && sleBtc->isFlag(lsfMPTAuthorized)); + + // The paired asset (eth) is issued by gw2, who did not sign this + // transaction. It must be recreated *unauthorized* so gw2's RequireAuth + // is not bypassed. This is the core assertion for the cross-issuer fix. + BEAST_EXPECT(sleEth && !sleEth->isFlag(lsfMPTAuthorized)); + + // The clawback still completed: btc was clawed back (Alice keeps a zero + // btc balance) and the paired eth was delivered into Alice's now + // unauthorized, gw2-gated MPToken (non-zero raw balance). + BEAST_EXPECT(sleBtc && sleBtc->getFieldU64(sfMPTAmount) == 0); + BEAST_EXPECT(sleEth && sleEth->getFieldU64(sfMPTAmount) > 0); + BEAST_EXPECT(amm.ammExists()); + } + void run() override { @@ -1965,6 +2214,9 @@ class AMMClawbackMPT_test : public beast::unit_test::Suite testAMMClawbackAllSameIssuer(all); testAMMClawbackIssuesEachOther(all); testAssetFrozenOrLocked(all); + testClawbackCreatesMissingMPToken(all); + testClawbackAfterDeletingMPTokens(all); + testClawbackCrossIssuerPairedAssetAuth(all); testSingleDepositAndClawback(all); testLastHolderLPTokenBalance(all); testLastHolderLPTokenBalance(all - fixAMMv1_3 - fixAMMClawbackRounding); diff --git a/src/test/app/AMMExtendedMPT_test.cpp b/src/test/app/AMMExtendedMPT_test.cpp index f04ea39f2b..5059128d4b 100644 --- a/src/test/app/AMMExtendedMPT_test.cpp +++ b/src/test/app/AMMExtendedMPT_test.cpp @@ -188,20 +188,28 @@ private: {features}); // tfPassive -- place the offer without crossing it. - testAMM( - [&](AMM& ammAlice, Env& env) { - // Carol creates a passive offer that could cross AMM. - // Carol's offer should stay in the ledger. - auto const& btc = MPT(ammAlice[1]); - env(offer(carol_, XRP(100), btc(100), tfPassive)); - env.close(); - BEAST_EXPECT(ammAlice.expectBalances(XRP(10'100), btc(10'000), ammAlice.tokens())); - BEAST_EXPECT(expectOffers(env, carol_, 1, {{{XRP(100), btc(100)}}})); - }, - {{XRP(10'100), gAmmmpt(10'000)}}, - 0, - std::nullopt, - {features}); + { + Env env{*this, features}; + fund(env, gw_, {alice_, carol_}, XRP(30'000'000)); + + MPTTester const btc( + {.env = env, + .issuer = gw_, + .holders = {alice_, carol_}, + .pay = 30'000'000, + .flags = kMptDexFlags}); + + AMM const ammAlice(env, alice_, XRP(10'100'000), btc(10'000'000)); + + // Scale the exact-quality fixture up so the visual relationship + // stays clear: the passive CLOB offer has the same 1:1 quality as + // the generated AMM offer, so it should not cross. + env(offer(carol_, XRP(100'000), btc(100'000), tfPassive)); + env.close(); + BEAST_EXPECT( + ammAlice.expectBalances(XRP(10'100'000), btc(10'000'000), ammAlice.tokens())); + BEAST_EXPECT(expectOffers(env, carol_, 1, {{{XRP(100'000), btc(100'000)}}})); + } // tfPassive -- cross only offers of better quality. testAMM( @@ -1084,9 +1092,9 @@ private: // AMM is consumed up to the first cam Offer quality BEAST_EXPECT(ammCarol.expectBalances( - aBux(3'093'541'659'651'604), bBux(3'200'215'509'984'418), ammCarol.tokens())); + aBux(3'093'541'659'651'603), bBux(3'200'215'509'984'419), ammCarol.tokens())); BEAST_EXPECT(expectOffers( - env, cam, 1, {{Amounts{bBux(200'215'509'984'418), aBux(200'215'509'984'419)}}})); + env, cam, 1, {{Amounts{bBux(200'215'509'984'419), aBux(200'215'509'984'419)}}})); } void @@ -1241,7 +1249,7 @@ private: BEAST_EXPECT(sa == XRP(100'000'000)); // Bob gets ~99.99e12ETH. This is the amount Bob // can get out of AMM for 100,000,000XRP. - BEAST_EXPECT(equal(da, eth(99'999'900'000'100))); + BEAST_EXPECT(equal(da, eth(99'999'900'000'099))); } // carol holds ETH, sells ETH for XRP @@ -1505,6 +1513,96 @@ private: } } + void + pathFindMPTAMMExecutableSourceAmount() + { + testcase("Path Find: MPT AMM source amount is executable"); + using namespace jtx; + + auto const checkQuote = [&](std::int64_t usdPool, + std::int64_t eurPool, + std::int64_t deliverAmount, + std::int64_t expectedSourceAmount) { + Env env = pathTestEnv(); + env.fund(XRP(30'000), gw_, alice_, bob_, carol_); + env.close(); + + MPTTester const usd( + {.env = env, + .issuer = gw_, + .holders = {alice_, bob_, carol_}, + .pay = usdPool, + .flags = kMptDexFlags}); + + MPTTester const eur( + {.env = env, + .issuer = gw_, + .holders = {alice_, bob_, carol_}, + .pay = eurPool, + .flags = kMptDexFlags}); + + AMM const ammCarol(env, carol_, usd(usdPool), eur(eurPool)); + env.close(); + + STPathSet st; + STAmount sa, da; + auto const deliver = eur(deliverAmount); + std::tie(st, sa, da) = findPaths( + env, + alice_, + bob_, + deliver, + std::nullopt, + usd.issuanceID(), + std::nullopt, + std::nullopt); + + // Each quote must execute when used as an exact-output SendMax. + BEAST_EXPECT(equal(da, deliver)); + BEAST_EXPECT(equal(sa, usd(expectedSourceAmount))); + BEAST_EXPECT(!st.empty()); + + auto const before = eur.getBalance(bob_); + env(pay(alice_, bob_, deliver), + Json(jss::Paths, st.getJson(JsonOptions::Values::None)), + Sendmax(sa), + Txflags(tfNoRippleDirect)); + BEAST_EXPECT(eur.getBalance(bob_) == before + deliverAmount); + }; + + struct TestCase + { + std::int64_t usdPool; + std::int64_t eurPool; + std::int64_t deliverAmount; + std::int64_t expectedSourceAmount; + }; + + // Cover the original 2:1 pool and the same pool scaled down by 1000. + // clang-format off + TestCase const testCases[] = { + {.usdPool = 2'000'000, .eurPool = 1'000'000, .deliverAmount = 1, .expectedSourceAmount = 3}, + {.usdPool = 2'000'000, .eurPool = 1'000'000, .deliverAmount = 2, .expectedSourceAmount = 5}, + {.usdPool = 2'000'000, .eurPool = 1'000'000, .deliverAmount = 10, .expectedSourceAmount = 21}, + {.usdPool = 2'000'000, .eurPool = 1'000'000, .deliverAmount = 100, .expectedSourceAmount = 201}, + {.usdPool = 2'000'000, .eurPool = 1'000'000, .deliverAmount = 1'000, .expectedSourceAmount = 2'003}, + {.usdPool = 2'000, .eurPool = 1'000, .deliverAmount = 1, .expectedSourceAmount = 3}, + {.usdPool = 2'000, .eurPool = 1'000, .deliverAmount = 2, .expectedSourceAmount = 5}, + {.usdPool = 2'000, .eurPool = 1'000, .deliverAmount = 10, .expectedSourceAmount = 21}, + {.usdPool = 2'000, .eurPool = 1'000, .deliverAmount = 100, .expectedSourceAmount = 223}, + }; + // clang-format on + + for (auto const& testCase : testCases) + { + checkQuote( + testCase.usdPool, + testCase.eurPool, + testCase.deliverAmount, + testCase.expectedSourceAmount); + } + } + void testFalseDry(FeatureBitset features) { @@ -3583,6 +3681,7 @@ private: pathFind01(); pathFind02(); pathFind06(); + pathFindMPTAMMExecutableSourceAmount(); } void diff --git a/src/test/app/AMMExtended_test.cpp b/src/test/app/AMMExtended_test.cpp index bb532b361a..83c848b7c4 100644 --- a/src/test/app/AMMExtended_test.cpp +++ b/src/test/app/AMMExtended_test.cpp @@ -267,20 +267,39 @@ private: {features}); // tfPassive -- place the offer without crossing it. - testAMM( - [&](AMM& ammAlice, Env& env) { - // Carol creates a passive offer that could cross AMM. - // Carol's offer should stay in the ledger. - env(offer(carol_, XRP(100), USD(100), tfPassive)); - env.close(); - BEAST_EXPECT( - ammAlice.expectBalances(XRP(10'100), STAmount{USD, 10'000}, ammAlice.tokens())); - BEAST_EXPECT(expectOffers(env, carol_, 1, {{{XRP(100), STAmount{USD, 100}}}})); - }, - {{XRP(10'100), USD(10'000)}}, - 0, - std::nullopt, - {features}); + if (features[featureMPTokensV2]) + { + Env env{*this, features}; + fund(env, gw_, {alice_, carol_}, XRP(30'000'000), {USD(30'000'000)}); + + AMM const ammAlice(env, alice_, XRP(10'100'000), USD(10'000'000)); + + // Scale the exact-quality fixture up so the visual relationship + // stays clear: the passive CLOB offer has the same 1:1 quality as + // the generated AMM offer, so it should not cross. + env(offer(carol_, XRP(100'000), USD(100'000), tfPassive)); + env.close(); + BEAST_EXPECT( + ammAlice.expectBalances(XRP(10'100'000), USD(10'000'000), ammAlice.tokens())); + BEAST_EXPECT(expectOffers(env, carol_, 1, {{{XRP(100'000), USD(100'000)}}})); + } + else + { + testAMM( + [&](AMM& ammAlice, Env& env) { + // Carol creates a passive offer that could cross AMM. + // Carol's offer should stay in the ledger. + env(offer(carol_, XRP(100), USD(100), tfPassive)); + env.close(); + BEAST_EXPECT(ammAlice.expectBalances( + XRP(10'100), STAmount{USD, 10'000}, ammAlice.tokens())); + BEAST_EXPECT(expectOffers(env, carol_, 1, {{{XRP(100), STAmount{USD, 100}}}})); + }, + {{XRP(10'100), USD(10'000)}}, + 0, + std::nullopt, + {features}); + } // tfPassive -- cross only offers of better quality. testAMM( @@ -1359,6 +1378,7 @@ private: testRmFundedOffer(all_ - fixAMMv1_1 - fixAMMv1_3); testEnforceNoRipple(all_); testFillModes(all_); + testFillModes(all_ - featureMPTokensV2); testOfferCrossWithXRP(all_); testOfferCrossWithLimitOverride(all_); testCurrencyConversionEntire(all_); diff --git a/src/test/app/AMMMPT_test.cpp b/src/test/app/AMMMPT_test.cpp index 7078ea6769..90a267f56f 100644 --- a/src/test/app/AMMMPT_test.cpp +++ b/src/test/app/AMMMPT_test.cpp @@ -32,14 +32,17 @@ #include #include #include +#include #include #include #include #include #include +#include #include #include #include +#include #include #include #include @@ -3269,6 +3272,48 @@ private: ammAlice.expectBalances(MPT(ammAlice[1])(1), XRP(10'000), IOUAmount{100000})); }, {{XRP(10'000), gAmmmpt(10'000)}}); + + // MPT/MPT equal withdrawal after LP deletes both zero-balance MPTokens. + // AMMWithdraw must recreate both missing MPTokens; the invariant allows + // up to two MPToken creations per AMMWithdraw/AMMClawback (threshold > 2). + { + Env env{*this}; + env.fund(XRP(30'000), gw_, alice_); + env.close(); + MPTTester btc( + {.env = env, + .issuer = gw_, + .holders = {alice_}, + .pay = 10'000, + .flags = kMptDexFlags}); + MPTTester eth( + {.env = env, + .issuer = gw_, + .holders = {alice_}, + .pay = 10'000, + .flags = kMptDexFlags}); + + // Alice deposits everything into the MPT/MPT pool; her MPT + // balances drop to zero. + AMM ammAlice(env, alice_, btc(10'000), eth(10'000)); + BEAST_EXPECT(expectMPT(env, alice_, btc(0))); + BEAST_EXPECT(expectMPT(env, alice_, eth(0))); + + // Alice deletes both zero-balance MPTokens to reclaim reserve. + btc.authorize({.account = alice_, .flags = tfMPTUnauthorize}); + eth.authorize({.account = alice_, .flags = tfMPTUnauthorize}); + BEAST_EXPECT(!env.le(keylet::mptoken(btc.issuanceID(), alice_.id()))); + BEAST_EXPECT(!env.le(keylet::mptoken(eth.issuanceID(), alice_.id()))); + + // Equal withdrawal succeeds: both missing MPTokens are recreated + // (mptokensCreated_ == 2, which satisfies the > 2 invariant check). + ammAlice.withdrawAll(alice_); + BEAST_EXPECT(env.le(keylet::mptoken(btc.issuanceID(), alice_.id()))); + BEAST_EXPECT(env.le(keylet::mptoken(eth.issuanceID(), alice_.id()))); + BEAST_EXPECT(expectMPT(env, alice_, btc(10'000))); + BEAST_EXPECT(expectMPT(env, alice_, eth(10'000))); + BEAST_EXPECT(!ammAlice.ammExists()); + } } void @@ -4041,9 +4086,9 @@ private: { auto jtx = env.jt(tx, Seq(1), Fee(10)); env.app().config().features.erase(featureMPTokensV2); - PreflightContext const pfctx( + PreflightContext const ctx( env.app(), *jtx.stx, env.current()->rules(), TapNone, env.journal); - auto pf = AMMBid::checkExtraFeatures(pfctx); + auto pf = AMMBid::checkExtraFeatures(ctx); BEAST_EXPECT(pf == false); env.app().config().features.insert(featureMPTokensV2); } @@ -4053,9 +4098,9 @@ private: jtx.jv["Asset2"]["currency"] = "XRP"; jtx.jv["Asset2"].removeMember("mpt_issuance_id"); jtx.stx = env.ust(jtx); - PreflightContext const pfctx( + PreflightContext const ctx( env.app(), *jtx.stx, env.current()->rules(), TapNone, env.journal); - auto pf = AMMBid::preflight(pfctx); + auto pf = AMMBid::preflight(ctx); BEAST_EXPECT(pf == temBAD_AMM_TOKENS); } } @@ -4901,7 +4946,7 @@ private: XRP(10'100), MPT(ammAlice[1])(10'000'000000000001), ammAlice.tokens())); env.require(Balance(carol_, MPT(ammAlice[1])(30'199'999999999999))); - // Initial 30,000 - 10000(AMM pool LP) - 100(AMMoffer) - + // Initial 30,000 - 10000(AMM pool LP) - 100(AMM offer) - // - 100(offer) - 10(tx fee) - 10(tx fee of MPTTester init as // holder) - one reserve BEAST_EXPECT(expectLedgerEntryRoot( @@ -5010,12 +5055,12 @@ private: env.close(); BEAST_EXPECT( - amm.expectBalances(XRPAmount(909'090'909), btc(550'000000055001), amm.tokens())); - // Offer ~91XRP/49.99e12BTC + amm.expectBalances(XRPAmount(909'090'910), btc(549'999999450001), amm.tokens())); + // Offer ~91XRP/50e12BTC BEAST_EXPECT(expectOffers( - env, carol_, 1, {{Amounts{XRPAmount{9'090'909}, btc(4'999999950000)}}})); - // Carol pays 0.1% fee on 50'000000055000BTC = 50'000000055BTC - env.require(Balance(carol_, btc(29'949'949'999'944'943))); + env, carol_, 1, {{Amounts{XRPAmount{9'090'910}, btc(5'000000500000)}}})); + // Carol pays 0.1% fee on 49'999999450001BTC. + env.require(Balance(carol_, btc(29'949'950'000'550'548))); } { @@ -5065,15 +5110,15 @@ private: env.close(); BEAST_EXPECT(ammAlice.expectBalances( - btc(1'060'6848287928033), eth(1'037'0658372213574), ammAlice.tokens())); + btc(1'060'6848287928025), eth(1'037'0658372213582), ammAlice.tokens())); // Consumed offer ~72.93e13ETH/72.93e13BTC BEAST_EXPECT(expectOffers( - env, carol_, 1, {Amounts{eth(27'0658372213574), btc(27'0658372213575)}})); + env, carol_, 1, {Amounts{eth(27'0658372213582), btc(27'0658372213582)}})); BEAST_EXPECT(expectOffers(env, bob_, 0)); BEAST_EXPECT(expectOffers(env, ed, 0)); - env.require(Balance(carol_, btc(19'116'439'640'089'955))); - env.require(Balance(carol_, eth(20'729'341'627'786'426))); + env.require(Balance(carol_, btc(19'116'439'640'089'965))); + env.require(Balance(carol_, eth(20'729'341'627'786'418))); env.require(Balance(bob_, btc(20'100'000'000'000'000))); env.require(Balance(ed, eth(19'875'000'000'000'000))); } @@ -5672,6 +5717,87 @@ private: }); } + void + testAMMOfferGenerationPolicy(FeatureBitset features) + { + testcase("AMM payment offer generation picks economically coarser integral side"); + + using namespace jtx; + + enum class GeneratedFirst { TakerPays, TakerGets }; + + auto const check = [&](std::uint64_t mptUnitsPerXRP, GeneratedFirst generatedFirst) { + TAmounts const pool{ + XRPAmount{1'000'000}, MPTAmount{1'000'000'125}}; + TAmounts const clobOffer{ + kDropsPerXrp, MPTAmount{static_cast(mptUnitsPerXRP)}}; + Quality const clobQuality{clobOffer}; + + auto const expectedAmounts = generatedFirst == GeneratedFirst::TakerGets + ? getAMMOfferStartWithTakerGets(pool, clobQuality, 0) + : getAMMOfferStartWithTakerPays(pool, clobQuality, 0); + auto const otherAmounts = generatedFirst == GeneratedFirst::TakerGets + ? getAMMOfferStartWithTakerPays(pool, clobQuality, 0) + : getAMMOfferStartWithTakerGets(pool, clobQuality, 0); + BEAST_EXPECT(expectedAmounts); + BEAST_EXPECT(otherAmounts); + if (!expectedAmounts || !otherAmounts) + return; + + // Make the tested branch observable: these cases are chosen so the + // payment consumes different AMM amounts depending on which side + // is generated first. + BEAST_EXPECT(*expectedAmounts != *otherAmounts); + + Env env(*this, features); + auto const gw = Account("gw"); + auto const lp = Account("lp"); + auto const maker = Account("maker"); + auto const taker = Account("taker"); + auto const dst = Account("dst"); + + env.fund(XRP(10'000), gw, lp, maker, taker, dst); + env.close(); + + MPTTester const token( + {.env = env, .issuer = gw, .holders = {lp, maker, dst}, .flags = kMptDexFlags}); + env(pay(gw, lp, token(pool.out.value()))); + env(pay(gw, maker, token(10'000'000))); + env.close(); + + AMM const amm(env, lp, drops(pool.in), token(pool.out.value())); + auto const makerOfferSeq = env.seq(maker); + env(offer(maker, XRP(1), token(mptUnitsPerXRP)), Txflags(tfPassive)); + env.close(); + + env(pay(taker, dst, token(expectedAmounts->out.value())), + Sendmax(drops(expectedAmounts->in))); + env.close(); + + BEAST_EXPECT(amm.expectBalances( + drops(pool.in + expectedAmounts->in), + token((pool.out - expectedAmounts->out).value()), + amm.tokens())); + env.require(Balance(dst, token(expectedAmounts->out.value()))); + BEAST_EXPECT(env.le(keylet::offer(maker.id(), SeqProxy::rawSequence(makerOfferSeq)))); + }; + + // CLOB price: 10'000'000 MPT per 1 XRP, so one raw MPT unit is worth + // 0.1 drops. One drop is the economically coarser unit and the AMM + // offer is generated from takerPays. + check(10 * kDropsPerXrp.drops(), GeneratedFirst::TakerPays); + + // CLOB price: 1'000'000 MPT per 1 XRP, so one raw MPT unit is worth + // one drop. Ties use takerGets to preserve the historical XRP-output + // behavior. + check(kDropsPerXrp.drops(), GeneratedFirst::TakerGets); + + // CLOB price: 100'000 MPT per 1 XRP, so one raw MPT unit is worth + // 10 drops. MPT is the economically coarser unit and the AMM offer is + // generated from takerGets. + check(kDropsPerXrp.drops() / 10, GeneratedFirst::TakerGets); + } + void testTradingFee(FeatureBitset features) { @@ -7242,7 +7368,7 @@ private: // 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 + // These mirror the deposit tests: 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. @@ -7318,6 +7444,7 @@ private: testAMMTokens(); testAmendment(); testAMMAndCLOB(all); + testAMMOfferGenerationPolicy(all); testTradingFee(all); testTradingFee(all - fixAMMv1_3); testAdjustedTokens(all); diff --git a/src/test/app/AMM_test.cpp b/src/test/app/AMM_test.cpp index 8f8079c34a..e1732aaf0e 100644 --- a/src/test/app/AMM_test.cpp +++ b/src/test/app/AMM_test.cpp @@ -3778,6 +3778,21 @@ private: BEAST_EXPECT(amm.expectBalances(XRP(1'000), USD(500), amm.tokens())); BEAST_EXPECT(expectOffers(env, carol_, 1, {{Amounts{XRP(100), USD(55)}}})); } + else if (!features[featureMPTokensV2]) + { + BEAST_EXPECT(amm.expectBalances( + XRPAmount(909'090'909), + STAmount{USD, UINT64_C(550'000000055), -9}, + amm.tokens())); + BEAST_EXPECT(expectOffers( + env, + carol_, + 1, + {{Amounts{XRPAmount{9'090'909}, STAmount{USD, 4'99999995, -8}}}})); + BEAST_EXPECT( + env.balance(carol_, USD) == + STAmount(USD, UINT64_C(29'949'94999999494), -11)); + } else { // Post-amendment the transfer fee is taken into account @@ -3788,19 +3803,19 @@ private: // quality. // AMM offer ~50USD/91XRP BEAST_EXPECT(amm.expectBalances( - XRPAmount(909'090'909), - STAmount{USD, UINT64_C(550'000000055), -9}, + XRPAmount(909'090'910), + STAmount{USD, UINT64_C(549'99999945), -8}, amm.tokens())); - // Offer ~91XRP/49.99USD + // Offer ~91XRP/50USD BEAST_EXPECT(expectOffers( env, carol_, 1, - {{Amounts{XRPAmount{9'090'909}, STAmount{USD, 4'99999995, -8}}}})); + {{Amounts{XRPAmount{9'090'910}, STAmount{USD, 5'0000005, -7}}}})); // Carol pays 0.1% fee on ~50USD =~ 0.05USD BEAST_EXPECT( env.balance(carol_, USD) == - STAmount(USD, UINT64_C(29'949'94999999494), -11)); + STAmount(USD, UINT64_C(29'949'95000060055), -11)); } }, {{XRP(1'000), USD(500)}}, @@ -6451,7 +6466,7 @@ private: BEAST_EXPECT(expectOffers(env, bob_, 1, {{Amounts{USD(1), XRPAmount(500)}}})); BEAST_EXPECT(expectOffers(env, carol_, 1, {{Amounts{XRP(100), USD(55)}}})); } - else + else if (!features[featureMPTokensV2]) { BEAST_EXPECT(amm.expectBalances( XRPAmount(909'090'909), @@ -6464,6 +6479,19 @@ private: {{Amounts{XRPAmount{9'090'909}, STAmount{USD, 4'99999995, -8}}}})); BEAST_EXPECT(expectOffers(env, bob_, 1, {{Amounts{USD(1), XRPAmount(500)}}})); } + else + { + BEAST_EXPECT(amm.expectBalances( + XRPAmount(909'090'910), + STAmount{USD, UINT64_C(549'99999945), -8}, + amm.tokens())); + BEAST_EXPECT(expectOffers( + env, + carol_, + 1, + {{Amounts{XRPAmount{9'090'910}, STAmount{USD, 5'0000005, -7}}}})); + BEAST_EXPECT(expectOffers(env, bob_, 1, {{Amounts{USD(1), XRPAmount(500)}}})); + } } // There is no blocking offer, the same AMM liquidity is consumed @@ -6475,10 +6503,30 @@ private: AMM const amm(env, alice_, XRP(1'000), USD(500)); env(offer(carol_, XRP(100), USD(55))); env.close(); - BEAST_EXPECT(amm.expectBalances( - XRPAmount(909'090'909), STAmount{USD, UINT64_C(550'000000055), -9}, amm.tokens())); - BEAST_EXPECT(expectOffers( - env, carol_, 1, {{Amounts{XRPAmount{9'090'909}, STAmount{USD, 4'99999995, -8}}}})); + if (!features[featureMPTokensV2]) + { + BEAST_EXPECT(amm.expectBalances( + XRPAmount(909'090'909), + STAmount{USD, UINT64_C(550'000000055), -9}, + amm.tokens())); + BEAST_EXPECT(expectOffers( + env, + carol_, + 1, + {{Amounts{XRPAmount{9'090'909}, STAmount{USD, 4'99999995, -8}}}})); + } + else + { + BEAST_EXPECT(amm.expectBalances( + XRPAmount(909'090'910), + STAmount{USD, UINT64_C(549'99999945), -8}, + amm.tokens())); + BEAST_EXPECT(expectOffers( + env, + carol_, + 1, + {{Amounts{XRPAmount{9'090'910}, STAmount{USD, 5'0000005, -7}}}})); + } } } @@ -7400,6 +7448,7 @@ private: testFlags(); testRippling(); testAMMAndCLOB(all); + testAMMAndCLOB(all - featureMPTokensV2); testAMMAndCLOB(all - fixAMMv1_1 - fixAMMv1_3); testTradingFee(all); testTradingFee(all - fixAMMv1_3); @@ -7419,8 +7468,10 @@ private: testOverflowOffer(all - fixAMMv1_1 - fixAMMv1_3); testSwapRounding(); testFixChangeSpotPriceQuality(all); + testFixChangeSpotPriceQuality(all - featureMPTokensV2); testFixChangeSpotPriceQuality(all - fixAMMv1_1 - fixAMMv1_3); testFixAMMOfferBlockedByLOB(all); + testFixAMMOfferBlockedByLOB(all - featureMPTokensV2); testFixAMMOfferBlockedByLOB(all - fixAMMv1_1 - fixAMMv1_3); testLPTokenBalance(all); testLPTokenBalance(all - fixAMMv1_3); diff --git a/src/test/app/EscrowToken_test.cpp b/src/test/app/EscrowToken_test.cpp index 7e7509c3b7..72db63bd3f 100644 --- a/src/test/app/EscrowToken_test.cpp +++ b/src/test/app/EscrowToken_test.cpp @@ -3749,6 +3749,186 @@ struct EscrowToken_test : public beast::unit_test::Suite BEAST_EXPECT(issuerMPTEscrowed(env, mpt) == 0); } + void + testMPTLargeLockedRate(FeatureBitset features) + { + testcase("MPT large locked rate"); + using namespace test::jtx; + using namespace std::literals; + + auto constexpr escrowAmount = 200'000'000'000'000'000LL; + auto constexpr noOverflowEscrowAmount = 186'000'000'000'000'000LL; + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + + for (auto const testFeatures : + {features - featureMPTokensV2 - fixCleanup3_4_0, + features - featureMPTokensV2, + (features | featureMPTokensV2) - fixCleanup3_4_0, + features | featureMPTokensV2}) + { + bool const mptV2 = testFeatures[featureMPTokensV2]; + bool const tokenEscrowV1 = testFeatures[fixTokenEscrowV1]; + // The transfer-fee split in EscrowFinish only overflows on the + // legacy divideRound(amount, lockedRate, ...) path, which runs when + // fixCleanup3_4_0 is disabled. With fixCleanup3_4_0 the split uses + // mulRatio (128-bit intermediate), which cannot overflow. Without + // it, this large amount overflows unless the MPTokensV2 Number path + // is active. So the finish succeeds when either amendment is enabled. + bool const cleanup340 = testFeatures[fixCleanup3_4_0]; + bool const noOverflow = cleanup340 || mptV2; + auto const expectedErr = noOverflow ? Ter(tesSUCCESS) : Ter(tefEXCEPTION); + + // Finish with a large MPT amount and non-zero transfer fee. When the + // computation overflows (legacy divideRound path, no MPTokensV2) the + // finish fails with tefEXCEPTION and the escrow is untouched; + // otherwise it unlocks the escrow. + { + Env env{*this, testFeatures}; + env.fund(XRP(1'000), alice, bob, gw); + auto const baseFee = env.current()->fees().base; + + MPTTester const mpt( + {.env = env, + .issuer = gw, + .holders = {alice, bob}, + .transferFee = 1'000, + .flags = tfMPTCanEscrow | tfMPTCanTransfer}); + env(pay(gw, alice, mpt(escrowAmount))); + env.close(); + + auto const preAlice = env.balance(alice, mpt); + auto const preBob = env.balance(bob, mpt); + auto const seq = env.seq(alice); + env(escrow::create(alice, bob, mpt(escrowAmount)), + escrow::kCondition(escrow::kCb1), + escrow::kFinishTime(env.now() + 1s), + escrow::kCancelTime(env.now() + 500s), + Fee(baseFee * 150)); + env.close(); + + BEAST_EXPECT(mptEscrowed(env, alice, mpt) == escrowAmount); + BEAST_EXPECT(issuerMPTEscrowed(env, mpt) == escrowAmount); + + env(escrow::finish(bob, alice, seq), + escrow::kCondition(escrow::kCb1), + escrow::kFulfillment(escrow::kFb1), + Fee(baseFee * 150), + expectedErr); + env.close(); + + if (noOverflow) + { + BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(seq)))); + BEAST_EXPECT(env.balance(alice, mpt) == preAlice - mpt(escrowAmount)); + auto const postBob = env.balance(bob, mpt); + BEAST_EXPECT(postBob.value() > preBob.value()); + BEAST_EXPECT(postBob.value() < (preBob + mpt(escrowAmount)).value()); + auto const xferFee = escrowAmount - (postBob.value() - preBob.value()); + auto const expectedEscrow = tokenEscrowV1 ? 0 : xferFee; + BEAST_EXPECT(mptEscrowed(env, alice, mpt) == expectedEscrow); + BEAST_EXPECT(issuerMPTEscrowed(env, mpt) == expectedEscrow); + } + else + { + BEAST_EXPECT(env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(seq)))); + BEAST_EXPECT(env.balance(alice, mpt) == preAlice - mpt(escrowAmount)); + BEAST_EXPECT(env.balance(bob, mpt) == preBob); + BEAST_EXPECT(mptEscrowed(env, alice, mpt) == escrowAmount); + BEAST_EXPECT(issuerMPTEscrowed(env, mpt) == escrowAmount); + } + } + + // Control: a still-large amount below the legacy overflow boundary + // finishes successfully in both feature modes. + { + Env env{*this, testFeatures}; + env.fund(XRP(1'000), alice, bob, gw); + auto const baseFee = env.current()->fees().base; + + MPTTester const mpt( + {.env = env, + .issuer = gw, + .holders = {alice, bob}, + .transferFee = 1'000, + .flags = tfMPTCanEscrow | tfMPTCanTransfer}); + env(pay(gw, alice, mpt(noOverflowEscrowAmount))); + env.close(); + + auto const preAlice = env.balance(alice, mpt); + auto const preBob = env.balance(bob, mpt); + auto const seq = env.seq(alice); + env(escrow::create(alice, bob, mpt(noOverflowEscrowAmount)), + escrow::kCondition(escrow::kCb1), + escrow::kFinishTime(env.now() + 1s), + escrow::kCancelTime(env.now() + 500s), + Fee(baseFee * 150)); + env.close(); + + BEAST_EXPECT(mptEscrowed(env, alice, mpt) == noOverflowEscrowAmount); + BEAST_EXPECT(issuerMPTEscrowed(env, mpt) == noOverflowEscrowAmount); + + env(escrow::finish(bob, alice, seq), + escrow::kCondition(escrow::kCb1), + escrow::kFulfillment(escrow::kFb1), + Fee(baseFee * 150), + Ter(tesSUCCESS)); + env.close(); + + BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(seq)))); + BEAST_EXPECT(env.balance(alice, mpt) == preAlice - mpt(noOverflowEscrowAmount)); + auto const postBob = env.balance(bob, mpt); + BEAST_EXPECT(postBob.value() > preBob.value()); + BEAST_EXPECT(postBob.value() < (preBob + mpt(noOverflowEscrowAmount)).value()); + auto const xferFee = noOverflowEscrowAmount - (postBob.value() - preBob.value()); + auto const expectedEscrow = tokenEscrowV1 ? 0 : xferFee; + BEAST_EXPECT(mptEscrowed(env, alice, mpt) == expectedEscrow); + BEAST_EXPECT(issuerMPTEscrowed(env, mpt) == expectedEscrow); + } + + // Cancel returns the escrow to the owner using parity rate, so it + // does not hit the transfer-rate division in either feature mode. + { + Env env{*this, testFeatures}; + env.fund(XRP(1'000), alice, bob, gw); + auto const baseFee = env.current()->fees().base; + + MPTTester const mpt( + {.env = env, + .issuer = gw, + .holders = {alice, bob}, + .transferFee = 1'000, + .flags = tfMPTCanEscrow | tfMPTCanTransfer}); + env(pay(gw, alice, mpt(escrowAmount))); + env.close(); + + auto const preAlice = env.balance(alice, mpt); + auto const preBob = env.balance(bob, mpt); + auto const seq = env.seq(alice); + env(escrow::create(alice, bob, mpt(escrowAmount)), + escrow::kCondition(escrow::kCb1), + escrow::kFinishTime(env.now() + 1s), + escrow::kCancelTime(env.now() + 3s), + Fee(baseFee * 150)); + env.close(); + + BEAST_EXPECT(mptEscrowed(env, alice, mpt) == escrowAmount); + BEAST_EXPECT(issuerMPTEscrowed(env, mpt) == escrowAmount); + + env(escrow::cancel(alice, alice, seq), Fee(baseFee), Ter(tesSUCCESS)); + env.close(); + + BEAST_EXPECT(!env.le(keylet::escrow(alice.id(), SeqProxy::rawSequence(seq)))); + BEAST_EXPECT(env.balance(alice, mpt) == preAlice); + BEAST_EXPECT(env.balance(bob, mpt) == preBob); + BEAST_EXPECT(env.balance(gw, mpt) == -mpt(escrowAmount)); + BEAST_EXPECT(mptEscrowed(env, alice, mpt) == 0); + BEAST_EXPECT(issuerMPTEscrowed(env, mpt) == 0); + } + } + } + void testMPTRequireAuth(FeatureBitset features) { @@ -4047,6 +4227,7 @@ struct EscrowToken_test : public beast::unit_test::Suite testMPTMetaAndOwnership(features); testMPTGateway(features); testMPTLockedRate(features); + testMPTLargeLockedRate(features); testMPTRequireAuth(features); testMPTLock(features); testMPTCanTransfer(features); diff --git a/src/test/app/FlowMPT_test.cpp b/src/test/app/FlowMPT_test.cpp index a94834eb28..49e3f9be94 100644 --- a/src/test/app/FlowMPT_test.cpp +++ b/src/test/app/FlowMPT_test.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -742,6 +743,164 @@ struct FlowMPT_test : public beast::unit_test::Suite return result; } + void + testOfferOwnerMPTCreation(FeatureBitset features) + { + using namespace jtx; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + Account const gw("gw"); + + { + testcase("Reserve-edge offer owner cannot create another object"); + + Env env(*this, features); + + auto const baseFee = env.current()->fees().base; + auto const ownerIncrement = reserve(env, 1) - reserve(env, 0); + auto const xrpOffer = ownerIncrement - drops(1); + auto const bobStart = reserve(env, 2) - drops(1) + baseFee; + + env.fund(XRP(10'000), alice, gw); + env.fund(bobStart, bob); + env.close(); + + MPTTester const usd({.env = env, .issuer = gw, .maxAmt = 10}); + + env(offer(bob, usd(1), xrpOffer)); + env.close(); + + env.require(Balance(bob, reserve(env, 2) - drops(1)), Owners(bob, 1)); + + // This mirrors the full-crossing setup below. Bob has enough XRP + // for the resting offer, but not enough to pay a fee and add + // another owner-count object while the offer remains on ledger. + env(check::create(bob, alice, drops(1)), Ter(tecINSUFFICIENT_RESERVE)); + env.close(); + + env.require(Owners(bob, 1)); + BEAST_EXPECT(offersOnAccount(env, bob).size() == 1); + } + + { + testcase("Reserve-edge offer owner creates MPToken during consume"); + + Env env(*this, features); + + auto const baseFee = env.current()->fees().base; + auto const ownerIncrement = reserve(env, 1) - reserve(env, 0); + auto const xrpOffer = ownerIncrement - drops(1); + auto const bobStart = reserve(env, 2) - drops(1) + baseFee; + + env.fund(XRP(10'000), alice, carol, gw); + env.fund(bobStart, bob); + env.close(); + + MPTTester const usd({.env = env, .issuer = gw, .holders = {alice}, .maxAmt = 10}); + + env(pay(gw, alice, usd(1))); + env(offer(bob, usd(1), xrpOffer)); + env.close(); + + env.require(Balance(bob, reserve(env, 2) - drops(1)), Owners(bob, 1)); + BEAST_EXPECT(!env.le(keylet::mptoken(usd.issuanceID(), bob.id()))); + auto const carolXRP = env.balance(carol); + + // Bob has enough XRP for the resting offer but is close to + // reserve. The payment should not create Bob's USD MPToken until + // the offer is actually consumed, otherwise the temporary owner + // count increase can make the offer look underfunded during path + // execution. + env(pay(alice, carol, xrpOffer), + Path(~XRP), + Sendmax(usd(1)), + Txflags(tfNoRippleDirect)); + env.close(); + + env.require(Balance(carol, carolXRP + xrpOffer)); + env.require(Balance(bob, usd(1))); + env.require(Balance(bob, reserve(env, 1)), Owners(bob, 1)); + BEAST_EXPECT(env.le(keylet::mptoken(usd.issuanceID(), bob.id()))); + BEAST_EXPECT(offersOnAccount(env, bob).empty()); + } + + { + testcase("Partial offer owner creates MPToken during consume"); + + Env env(*this, features); + + auto const baseFee = env.current()->fees().base; + auto const ownerIncrement = reserve(env, 1) - reserve(env, 0); + auto const bobStart = reserve(env, 3) + baseFee; + + env.fund(XRP(10'000), alice, carol, gw); + env.fund(bobStart, bob); + env.close(); + + MPTTester const usd({.env = env, .issuer = gw, .holders = {alice}, .maxAmt = 10}); + + env(pay(gw, alice, usd(1))); + env(offer(bob, usd(2), drops(2 * ownerIncrement))); + env.close(); + + env.require(Balance(bob, reserve(env, 3)), Owners(bob, 1)); + BEAST_EXPECT(!env.le(keylet::mptoken(usd.issuanceID(), bob.id()))); + auto const carolXRP = env.balance(carol); + + // Partial consumption leaves Bob's offer on the ledger, so he ends + // up owning both the remaining offer and a newly created MPToken. + // The MPToken is created regardless of reserve; this setup simply + // funds Bob enough that he still meets reserve(2) afterward (the + // under-reserved case is covered in OfferMPT_test's no-reserve-check + // testcase). + env(pay(alice, carol, drops(ownerIncrement)), + Path(~XRP), + Sendmax(usd(1)), + Txflags(tfNoRippleDirect)); + env.close(); + + env.require(Balance(carol, carolXRP + drops(ownerIncrement))); + env.require(Balance(bob, usd(1))); + env.require(Balance(bob, reserve(env, 2)), Owners(bob, 2)); + BEAST_EXPECT(env.le(keylet::mptoken(usd.issuanceID(), bob.id()))); + BEAST_EXPECT(offersOnAccount(env, bob).size() == 1); + BEAST_EXPECT(isOffer(env, bob, usd(1), drops(ownerIncrement))); + } + + { + testcase("Issuer-owned offer does not create issuer MPToken"); + + Env env(*this, features); + + env.fund(XRP(10'000), alice, carol, gw); + env.close(); + + MPTTester const usd({.env = env, .issuer = gw, .holders = {alice}, .maxAmt = 10}); + + env(pay(gw, alice, usd(1))); + env(offer(gw, usd(1), drops(1'000))); + env.close(); + + BEAST_EXPECT(!env.le(keylet::mptoken(usd.issuanceID(), gw.id()))); + auto const carolXRP = env.balance(carol); + + // The issuer can own an offer that receives its own MPT without an + // MPToken. Consuming that offer should keep the issuer side + // tokenless. + env(pay(alice, carol, drops(1'000)), + Path(~XRP), + Sendmax(usd(1)), + Txflags(tfNoRippleDirect)); + env.close(); + + env.require(Balance(alice, usd(0))); + env.require(Balance(carol, carolXRP + drops(1'000))); + BEAST_EXPECT(!env.le(keylet::mptoken(usd.issuanceID(), gw.id()))); + BEAST_EXPECT(offersOnAccount(env, gw).empty()); + } + } + void testSelfPayment1(FeatureBitset features) { @@ -2121,6 +2280,7 @@ struct FlowMPT_test : public beast::unit_test::Suite testFalseDry(features); testDirectStep(features); testBookStep(features); + testOfferOwnerMPTCreation(features); testTransferRate(features); testSelfPayment1(features); testSelfPayment2(features); diff --git a/src/test/app/OfferMPT_test.cpp b/src/test/app/OfferMPT_test.cpp index d03b1b8e93..e262954fdf 100644 --- a/src/test/app/OfferMPT_test.cpp +++ b/src/test/app/OfferMPT_test.cpp @@ -1,3 +1,5 @@ +#include +#include #include #include #include @@ -5,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -22,6 +25,7 @@ #include #include +#include #include #include #include @@ -35,6 +39,7 @@ #include #include #include +#include #include #include #include @@ -46,6 +51,7 @@ #include #include #include +#include #include #include #include @@ -609,6 +615,267 @@ public: testHelper2TokensMix(test); } + void + testMPTIssuerOfferUsesRemainingCapacity(FeatureBitset features) + { + testcase("MPT issuer offer dust removal uses remaining issuance capacity"); + + using namespace jtx; + + Account const issuer{"issuer"}; + Account const carol{"carol"}; + Account const bob{"bob"}; + + Env env{*this, features}; + env.fund(XRP(10'000), issuer, carol, bob); + env.close(); + + MPTTester const musd( + {.env = env, .issuer = issuer, .holders = {carol, bob}, .maxAmt = 101}); + + // The issuer offer is fully fundable when placed. Later issuance leaves + // only one MPT of remaining capacity, so this issuer-owned MPT offer + // must be clipped by owner funds just like a holder-funded offer. + auto const issuerOfferSeq = env.seq(issuer); + env(offer(issuer, drops(1), musd(100))); + env.close(); + + env(pay(issuer, carol, musd(100))); + env.close(); + BEAST_EXPECT(env.balance(issuer, musd) == musd(-100)); + BEAST_EXPECT(env.balance(carol, musd) == musd(100)); + + // Carol's same-quality offer provides the legitimately funded side of + // the crossing. Without the issuer-cap dust-removal check, Bob would + // receive Carol's 100 MPT plus one free self-issued MPT from issuer's + // stale offer while paying only Carol's one drop. + auto const carolOfferSeq = env.seq(carol); + env(offer(carol, drops(1), musd(100))); + env.close(); + + auto const issuerOffer = keylet::offer(issuer.id(), SeqProxy::rawSequence(issuerOfferSeq)); + auto const carolOffer = keylet::offer(carol.id(), SeqProxy::rawSequence(carolOfferSeq)); + BEAST_EXPECT(env.le(issuerOffer) != nullptr); + BEAST_EXPECT(env.le(carolOffer) != nullptr); + + env(offer(bob, musd(101), drops(2), tfImmediateOrCancel)); + env.close(); + + BEAST_EXPECT(env.le(issuerOffer) == nullptr); + BEAST_EXPECT(env.le(carolOffer) == nullptr); + env.require(offers(issuer, 0), offers(carol, 0), offers(bob, 0)); + BEAST_EXPECT(env.balance(issuer, musd) == musd(-100)); + BEAST_EXPECT(env.balance(carol, musd) == musd(0)); + BEAST_EXPECT(env.balance(bob, musd) == musd(100)); + } + + void + testPartiallyFundedMPTInputOfferZeroInput(FeatureBitset features) + { + using namespace jtx; + auto const alice = Account{"alice"}; + auto const bob = Account{"bob"}; + + { + testcase("Partially funded MPT/XRP input offer cannot be consumed for free"); + + Env env{*this, features}; + auto const gw = Account{"gw"}; + + env.fund(XRP(10'000), gw, alice, bob); + env.close(); + + MPTTester const usd({.env = env, .issuer = gw, .holders = {alice}}); + + auto const aliceOfferSeq = env.seq(alice); + env(offer(alice, usd(1), drops(1'000'000))); + env.close(); + + auto const targetBalance = reserve(env, 2) + drops(999'999); + auto const drain = env.balance(alice).value().xrp() - targetBalance.value().xrp() - + env.current()->fees().base; + env(pay(alice, gw, drops(drain))); + env.close(); + + auto const aliceXRPBefore = env.balance(alice); + auto const bobXRPBefore = env.balance(bob); + + env(pay(gw, bob, drops(1'000'000)), + Sendmax(usd(1)), + Path(~XRP), + Txflags(tfNoRippleDirect | tfPartialPayment), + Ter(tecPATH_DRY)); + env.close(); + + // alice's offer sells 1,000,000 drops for usd(1) but she can fund + // only 999,999. Filling the clipped remainder would require a + // fractional usd (MPT) input that rounds down to zero, so without + // the fix the taker could take the funded drops for free. + // shouldRmSmallIncreasedQOffer() now treats the MPT input as + // integral (like XRP) and removes the degraded offer, so the + // payment goes dry. The removal happens only inside the crossing: + // tecPATH_DRY discards everything but the fee, so the offer itself + // stays in the ledger, unconsumed. + BEAST_EXPECT( + env.le(keylet::offer(alice.id(), SeqProxy::rawSequence(aliceOfferSeq))) != nullptr); + BEAST_EXPECT(env.balance(alice) == aliceXRPBefore); + BEAST_EXPECT(env.balance(bob) == bobXRPBefore); + } + + { + testcase("Partially funded MPT/IOU input offer cannot be consumed for free"); + + Env env{*this, features}; + auto const mptIssuer = Account{"mptIssuer"}; + auto const iouIssuer = Account{"iouIssuer"}; + + env.fund(XRP(10'000), mptIssuer, iouIssuer, alice, bob); + env.close(); + + auto const eur = iouIssuer["EUR"]; + env.trust(eur(100), alice, bob); + env(pay(iouIssuer, alice, eur(0.5))); + env.close(); + + MPTTester const usd({.env = env, .issuer = mptIssuer, .holders = {alice}}); + + auto const aliceOfferSeq = env.seq(alice); + env(offer(alice, usd(1), eur(1))); + env.close(); + + auto const aliceEURBefore = env.balance(alice, eur); + auto const bobEURBefore = env.balance(bob, eur); + + env(pay(mptIssuer, bob, eur(1)), + Sendmax(usd(1)), + Path(~eur), + Txflags(tfNoRippleDirect | tfPartialPayment), + Ter(tecPATH_DRY)); + env.close(); + + // Same zero-input regression as the MPT/XRP case above, but with + // an IOU (eur) output leg: the fractional usd (MPT) input rounds + // to zero. The degraded offer is removed during crossing, the + // payment goes dry, and tecPATH_DRY leaves the offer in the ledger. + BEAST_EXPECT( + env.le(keylet::offer(alice.id(), SeqProxy::rawSequence(aliceOfferSeq))) != nullptr); + BEAST_EXPECT(env.balance(alice, eur) == aliceEURBefore); + BEAST_EXPECT(env.balance(bob, eur) == bobEURBefore); + } + + { + testcase("Partially funded MPT/MPT input offer cannot be consumed for free"); + + Env env{*this, features}; + auto const issuerA = Account{"issuerA"}; + auto const issuerB = Account{"issuerB"}; + + env.fund(XRP(10'000), issuerA, issuerB, alice, bob); + env.close(); + + MPTTester const usd({.env = env, .issuer = issuerA, .holders = {alice}}); + MPTTester const eur({.env = env, .issuer = issuerB, .holders = {alice, bob}}); + + env(pay(issuerB, alice, eur(999'999))); + env.close(); + + auto const aliceOfferSeq = env.seq(alice); + env(offer(alice, usd(1), eur(1'000'000))); + env.close(); + + auto const aliceEURBefore = eur.getBalance(alice); + auto const bobEURBefore = eur.getBalance(bob); + + env(pay(issuerA, bob, eur(1'000'000)), + Sendmax(usd(1)), + Path(~eur), + Txflags(tfNoRippleDirect | tfPartialPayment), + Ter(tecPATH_DRY)); + env.close(); + + // Same zero-input regression as above, but with both legs MPT: the + // fractional usd (MPT) input rounds to zero. The degraded offer is + // removed during crossing, the payment goes dry, and tecPATH_DRY + // leaves the offer in the ledger. + BEAST_EXPECT( + env.le(keylet::offer(alice.id(), SeqProxy::rawSequence(aliceOfferSeq))) != nullptr); + BEAST_EXPECT(env.balance(alice, eur) == eur(aliceEURBefore)); + BEAST_EXPECT(env.balance(bob, eur) == eur(bobEURBefore)); + } + + { + // The dry cases above never observe the degraded offer actually + // being removed, because tecPATH_DRY rolls the removal back. Here a + // second, fully funded offer lets the crossing succeed, so the + // removal persists: alice's degraded offer is deleted from the + // book (not taken for free) while carol's good offer fills. + testcase( + "Partially funded MPT input offer is removed, not consumed, " + "when a funded offer crosses"); + + Env env{*this, features}; + auto const gw = Account{"gw"}; + auto const carol = Account{"carol"}; + + env.fund(XRP(10'000), gw, alice, carol, bob); + env.close(); + + MPTTester const usd({.env = env, .issuer = gw, .holders = {alice, carol, bob}}); + + // alice's offer sells 1,000,000 drops for usd(1) but, as in the + // dry cases above, she can fund only 999,999 drops, so filling the + // clipped remainder would require a fractional usd (MPT) input that + // rounds down to zero. + auto const aliceOfferSeq = env.seq(alice); + env(offer(alice, usd(1), drops(1'000'000))); + env.close(); + + auto const targetBalance = reserve(env, 2) + drops(999'999); + auto const drain = env.balance(alice).value().xrp() - targetBalance.value().xrp() - + env.current()->fees().base; + env(pay(alice, gw, drops(drain))); + env.close(); + + // carol's same-quality offer is fully funded and provides the + // legitimate side of the crossing. + auto const carolOfferSeq = env.seq(carol); + env(offer(carol, usd(1), drops(1'000'000))); + env.close(); + + // bob needs usd to buy drops. + env(pay(gw, bob, usd(2))); + env.close(); + + auto const aliceOffer = keylet::offer(alice.id(), SeqProxy::rawSequence(aliceOfferSeq)); + auto const carolOffer = keylet::offer(carol.id(), SeqProxy::rawSequence(carolOfferSeq)); + BEAST_EXPECT(env.le(aliceOffer) != nullptr); + BEAST_EXPECT(env.le(carolOffer) != nullptr); + + auto const aliceXRPBefore = env.balance(alice); + auto const bobXRPBefore = env.balance(bob); + + // bob buys drops with usd, wanting more than carol alone supplies so + // the crossing also reaches alice's offer. carol's offer fills; + // alice's degraded offer is removed rather than taken for free, so + // bob receives only carol's 1,000,000 drops and pays only usd(1). + env(offer(bob, drops(2'000'000), usd(2), tfImmediateOrCancel)); + env.close(); + + BEAST_EXPECT(env.le(aliceOffer) == nullptr); + BEAST_EXPECT(env.le(carolOffer) == nullptr); + env.require(offers(alice, 0), offers(carol, 0), offers(bob, 0)); + + // alice's offer was removed, not consumed: her balances are + // unchanged and none of her funded 999'999 drops leaked to bob. + BEAST_EXPECT(env.balance(alice) == aliceXRPBefore); + BEAST_EXPECT(env.balance(alice, usd) == usd(0)); + BEAST_EXPECT(env.balance(carol, usd) == usd(1)); + BEAST_EXPECT(env.balance(bob, usd) == usd(1)); + BEAST_EXPECT( + env.balance(bob) == bobXRPBefore + drops(1'000'000) - env.current()->fees().base); + } + } + void testInsufficientReserve(FeatureBitset features) { @@ -947,6 +1214,161 @@ public: } } + void + testMPTAMMLimitQualityRounding(FeatureBitset features) + { + testcase("MPT AMM limitQuality checks rounded integral output"); + + using namespace jtx; + + Account const gw{"gateway"}; + Account const alice{"alice"}; + Account const bob{"bob"}; + + // IOC used to reject the AMM strand with tecKILLED. The continuous + // limitQuality target is about 32.88 MPT; rounding to nearest requested + // 33 MPT and made the realized AMM quality miss Bob's limit. The + // discrete fallback takes the largest satisfying integer output: 32. + { + Env env{*this, features}; + + env.fund(XRP(10'000), gw, alice, bob); + env.close(); + + MPTTester const btc( + {.env = env, + .issuer = gw, + .holders = {alice, bob}, + .pay = 100'000, + .flags = kMptDexFlags}); + AMM const amm(env, alice, XRP(100), btc(1'000)); + + auto const bobBTCBefore = btc.getBalance(bob); + auto const [xrpBefore, btcBefore, lpBefore] = amm.balances(); + + env(offer(bob, btc(100), drops(10'340'000)), Txflags(tfImmediateOrCancel)); + env.close(); + + auto const [xrpAfter, btcAfter, lpAfter] = amm.balances(); + BEAST_EXPECT(btc.getBalance(bob) == bobBTCBefore + 32); + BEAST_EXPECT(xrpAfter > xrpBefore); + BEAST_EXPECT(btcAfter < btcBefore); + BEAST_EXPECT(lpAfter == lpBefore); + BEAST_EXPECT(expectOffers(env, bob, 0)); + } + + // A standard OfferCreate at the same limit used to bypass the AMM and + // rest unchanged on the book. It should now take the largest + // satisfying 32-MPT AMM fill first, then leave only the remainder on + // the book. + { + Env env{*this, features}; + + env.fund(XRP(10'000), gw, alice, bob); + env.close(); + + MPTTester const btc( + {.env = env, + .issuer = gw, + .holders = {alice, bob}, + .pay = 100'000, + .flags = kMptDexFlags}); + AMM const amm(env, alice, XRP(100), btc(1'000)); + + auto const bobBTCBefore = btc.getBalance(bob); + auto const [xrpBefore, btcBefore, lpBefore] = amm.balances(); + + env(offer(bob, btc(100), drops(10'340'000))); + env.close(); + + auto const [xrpAfter, btcAfter, lpAfter] = amm.balances(); + BEAST_EXPECT(btc.getBalance(bob) == bobBTCBefore + 32); + BEAST_EXPECT(xrpAfter > xrpBefore); + BEAST_EXPECT(btcAfter < btcBefore); + BEAST_EXPECT(lpAfter == lpBefore); + BEAST_EXPECT(expectOffers(env, bob, 1)); + + auto const bobOffers = offersOnAccount(env, bob); + if (BEAST_EXPECT(bobOffers.size() == 1)) + { + BEAST_EXPECT((*bobOffers[0])[sfTakerPays] != btc(100)); + BEAST_EXPECT((*bobOffers[0])[sfTakerGets] != drops(10'340'000)); + } + } + + // Mirror the IOC case with the integral output flipped from MPT units + // to XRP drops. The same continuous target (~32.88) used to round up + // to 33 drops and miss limitQuality; the discrete fallback allows the + // largest satisfying 32-drop AMM fill. + { + Env env{*this, features}; + + env.fund(XRP(10'000), gw, alice, bob); + env.close(); + + MPTTester const btc( + {.env = env, + .issuer = gw, + .holders = {alice, bob}, + .pay = 200'000'000, + .flags = kMptDexFlags}); + AMM const amm(env, alice, drops(1'000), btc(100'000'000)); + + auto const bobXRPBefore = env.balance(bob, XRP); + auto const baseFee = env.current()->fees().base; + auto const [xrpBefore, btcBefore, lpBefore] = amm.balances(); + + env(offer(bob, drops(100), btc(10'340'000)), Txflags(tfImmediateOrCancel)); + env.close(); + + auto const [xrpAfter, btcAfter, lpAfter] = amm.balances(); + env.require(Balance(bob, bobXRPBefore + drops(32) - baseFee)); + BEAST_EXPECT(xrpAfter < xrpBefore); + BEAST_EXPECT(btcAfter > btcBefore); + BEAST_EXPECT(lpAfter == lpBefore); + BEAST_EXPECT(expectOffers(env, bob, 0)); + } + + // Mirror the standard OfferCreate case as well. It should consume the + // largest satisfying 32-drop AMM fill before leaving only the remainder + // on the book. + { + Env env{*this, features}; + + env.fund(XRP(10'000), gw, alice, bob); + env.close(); + + MPTTester const btc( + {.env = env, + .issuer = gw, + .holders = {alice, bob}, + .pay = 200'000'000, + .flags = kMptDexFlags}); + AMM const amm(env, alice, drops(1'000), btc(100'000'000)); + + auto const bobXRPBefore = env.balance(bob, XRP); + auto const baseFee = env.current()->fees().base; + auto const [xrpBefore, btcBefore, lpBefore] = amm.balances(); + + env(offer(bob, drops(100), btc(10'340'000))); + env.close(); + + auto const [xrpAfter, btcAfter, lpAfter] = amm.balances(); + env.require(Balance(bob, bobXRPBefore + drops(32) - baseFee)); + BEAST_EXPECT(xrpAfter < xrpBefore); + BEAST_EXPECT(btcAfter > btcBefore); + BEAST_EXPECT(lpAfter == lpBefore); + BEAST_EXPECT(expectOffers(env, bob, 1)); + + auto const bobOffers = offersOnAccount(env, bob); + if (BEAST_EXPECT(bobOffers.size() == 1)) + { + BEAST_EXPECT((*bobOffers[0])[sfTakerPays] != drops(100)); + BEAST_EXPECT((*bobOffers[0])[sfTakerGets] != btc(10'340'000)); + } + } + } + void testMalformed(FeatureBitset features) { @@ -2727,6 +3149,50 @@ public: using namespace jtx; auto const gw1 = Account("gateway1"); + { + auto const issuer = Account("issuer"); + auto const sender = Account("sender"); + auto const receiver = Account("receiver"); + auto const seller = Account("seller"); + auto const buyer = Account("buyer"); + + Env env{*this, features}; + env.fund(XRP(10'000), issuer, sender, receiver, seller, buyer); + env.close(); + + MPTTester mpt{ + {.env = env, + .issuer = issuer, + .holders = {sender, receiver, seller, buyer}, + .transferFee = 100}}; + MPT const token = mpt; + + mpt.pay(issuer, sender, 2'000); + mpt.pay(issuer, seller, 2'000); + + // A direct holder-to-holder payment of 999 MPT at a 0.1% fee + // requires 1000 from the sender and burns one MPT. + env(pay(sender, receiver, token(999)), Ter(tecPATH_PARTIAL)); + env.close(); + env(pay(sender, receiver, token(999)), Sendmax(token(1'000))); + env.close(); + + BEAST_EXPECT(mpt.getBalance(sender) == 1'000); + BEAST_EXPECT(mpt.getBalance(receiver) == 999); + BEAST_EXPECT(mpt.getBalance(issuer) == 3'999); + + // CLOB crossing should apply the same fee quantum. The offer + // owner pays ceil(999 * 1.001) = 1000, not floor(...) = 999. + env(offer(seller, XRP(999), token(999))); + env.close(); + env(offer(buyer, token(999), XRP(999))); + env.close(); + + BEAST_EXPECT(mpt.getBalance(seller) == 1'000); + BEAST_EXPECT(mpt.getBalance(buyer) == 999); + BEAST_EXPECT(mpt.getBalance(issuer) == 3'998); + } + auto test = [&](auto&& issue1, auto&& issue2) { Env env{*this, features}; @@ -3102,6 +3568,247 @@ public: } } + void + testTransferRateOverflowOffer(FeatureBitset features) + { + testcase("Transfer Rate Overflow Offer"); + + using namespace jtx; + + auto const issuer = Account("issuer"); + auto const taker = Account("taker"); + + { + Env env{*this, features}; + env.fund(XRP(10'000), issuer, taker); + env.close(); + + auto constexpr takerFunds = 2'000'000'000'000'000'000LL; + MPTTester const token{ + {.env = env, + .issuer = issuer, + .holders = {taker}, + .transferFee = 50'000, + .pay = takerFunds, + .maxAmt = kMaxMpTokenAmount}}; + + // Covers OfferCreate::flowCross() sendMax calculation. A large + // non-issuer MPT offer with a transfer fee used to overflow in + // multiplyRound() before the offer could be placed. + auto constexpr offerAmount = 1'230'000'000'000'000'000LL; + auto const takerSeq = env.seq(taker); + env(offer(taker, XRP(1), token(offerAmount))); + env.close(); + + BEAST_EXPECT( + env.le(keylet::offer(taker.id(), SeqProxy::rawSequence(takerSeq))) != nullptr); + BEAST_EXPECT(env.balance(taker, token) == token(takerFunds)); + } + + // Each scenario below targets a BookStep/OfferStream overflow path. + // The expected behavior is the same in all cases: remove the unusable + // book tip offer and let the taker's crossing offer remain rather than + // returning tecINTERNAL with the poison offer still on-ledger. + { + Env env{*this, features}; + env.fund(XRP(10'000), issuer, taker); + env.close(); + + MPTTester const token{ + {.env = env, .issuer = issuer, .holders = {taker}, .transferFee = 10'000}}; + + // Covers BookStep::forEachOffer() offer preparation, where + // ownerGives = mulRatio(ofrAmt.out, transferRateOut) overflowed + // for an oversized MPT output with a transfer fee. + std::int64_t const poisonAmount = 8'500'000'000'000'000'000LL; + auto const poisonSeq = env.seq(issuer); + env(offer(issuer, XRP(1), token(poisonAmount))); + env.close(); + + auto const poisonKeylet = keylet::offer(issuer.id(), SeqProxy::rawSequence(poisonSeq)); + BEAST_EXPECT(env.le(poisonKeylet) != nullptr); + + auto const takerSeq = env.seq(taker); + env(offer(taker, token(100), XRP(100))); + env.close(); + + BEAST_EXPECT(env.le(poisonKeylet) == nullptr); + BEAST_EXPECT( + env.le(keylet::offer(taker.id(), SeqProxy::rawSequence(takerSeq))) != nullptr); + } + + { + auto const gwA = Account("gatewayA"); + auto const gwB = Account("gatewayB"); + auto const alice = Account("alice"); + auto const mallory = Account("mallory"); + + Env env{*this, features}; + env.fund(XRP(10'000), gwA, gwB, alice, mallory); + env.close(); + + MPTTester const tokenA{ + {.env = env, .issuer = gwA, .holders = {alice, mallory}, .transferFee = 50'000}}; + + MPTTester const tokenB{{.env = env, .issuer = gwB, .holders = {alice, mallory}}}; + + env(pay(gwA, alice, tokenA(1'000))); + + // Covers BookStep::forEachOffer() offer preparation, where + // stpAmt.in = mulRatio(ofrAmt.in, transferRateIn) overflowed. + // The MPT/MPT amounts keep the offer quality reachable while + // applying tokenA's transfer rate overflows the input side. + std::int64_t const poisonPays = 6'148'914'691'236'517'205LL; + std::int64_t const poisonGets = 34'000'000'000'000'000LL; + env(pay(gwB, mallory, tokenB(poisonGets))); + + auto const poisonSeq = env.seq(mallory); + env(offer(mallory, tokenA(poisonPays), tokenB(poisonGets))); + env.close(); + + auto const poisonKeylet = keylet::offer(mallory.id(), SeqProxy::rawSequence(poisonSeq)); + BEAST_EXPECT(env.le(poisonKeylet) != nullptr); + + auto const aliceSeq = env.seq(alice); + env(offer(alice, tokenB(1), tokenA(100))); + env.close(); + + BEAST_EXPECT(env.le(poisonKeylet) == nullptr); + BEAST_EXPECT( + env.le(keylet::offer(alice.id(), SeqProxy::rawSequence(aliceSeq))) != nullptr); + } + + { + Env env{*this, features}; + env.fund(XRP(10'000), issuer, taker); + env.close(); + + MPTTester const token{ + {.env = env, .issuer = issuer, .holders = {taker}, .maxAmt = kMaxMpTokenAmount}}; + + // Give the taker exactly one MPT. If the old rounding overflow + // collapsed the required input to the minimum positive amount, the + // taker could afford the bad fill and the balance checks below + // would catch the economic gain. + env(pay(issuer, taker, token(1))); + env.close(); + + // Covers BookStep::revImp() output reduction. The issuer's offer + // is fully funded and has no transfer fee, so offer preparation + // succeeds. The taker asks for slightly less output, forcing + // limitStepOut() to reduce the offer; that strict reduction used + // to overflow and leave the poison offer on the book. + auto const funded = 1'844'674'407'370'955'162LL; + auto const offerOut = funded + 1; + + auto const poisonSeq = env.seq(issuer); + env(offer(issuer, XRP(1), token(offerOut))); + env.close(); + + auto const poisonKeylet = keylet::offer(issuer.id(), SeqProxy::rawSequence(poisonSeq)); + BEAST_EXPECT(env.le(poisonKeylet) != nullptr); + + auto const issuerXRPBefore = env.balance(issuer, XRP); + auto const takerXRPBefore = env.balance(taker, XRP); + auto const takerMPTBefore = env.balance(taker, token); + auto const fee = env.current()->fees().base; + + auto const takerSeq = env.seq(taker); + env(offer(taker, token(funded), XRP(1))); + env.close(); + + // The former overflow point must not turn into a near-free fill: + // the unusable offer is removed, the taker's offer remains, and no + // value changes hands beyond the taker's transaction fee. + BEAST_EXPECT(env.le(poisonKeylet) == nullptr); + BEAST_EXPECT( + env.le(keylet::offer(taker.id(), SeqProxy::rawSequence(takerSeq))) != nullptr); + BEAST_EXPECT(env.balance(issuer, XRP) == issuerXRPBefore); + BEAST_EXPECT(env.balance(taker, XRP) == takerXRPBefore - fee); + BEAST_EXPECT(env.balance(taker, token) == takerMPTBefore); + } + + { + auto const poisonMaker = Account("poisonMaker"); + + Env env{*this, features}; + env.fund(XRP(10'000), issuer, poisonMaker, taker); + env.close(); + + MPTTester const token{ + {.env = env, + .issuer = issuer, + .holders = {poisonMaker, taker}, + .maxAmt = kMaxMpTokenAmount}}; + + // Covers OfferStream::step() filtering. The offer is mostly + // funded, but reducing it to the actual owner funds inside + // shouldRmSmallIncreasedQOffer() used to overflow before BookStep + // saw the offer. + auto const funded = 1'844'674'407'370'955'162LL; + auto const offerOut = funded + 1; + env(pay(issuer, poisonMaker, token(funded))); + + auto const poisonSeq = env.seq(poisonMaker); + env(offer(poisonMaker, XRP(1), token(offerOut))); + env.close(); + + auto const poisonKeylet = + keylet::offer(poisonMaker.id(), SeqProxy::rawSequence(poisonSeq)); + BEAST_EXPECT(env.le(poisonKeylet) != nullptr); + + auto const takerSeq = env.seq(taker); + env(offer(taker, token(1), XRP(1))); + env.close(); + + BEAST_EXPECT(env.le(poisonKeylet) == nullptr); + BEAST_EXPECT( + env.le(keylet::offer(taker.id(), SeqProxy::rawSequence(takerSeq))) != nullptr); + BEAST_EXPECT(env.balance(poisonMaker, token) == token(funded)); + BEAST_EXPECT(env.balance(taker, token) == token(0)); + } + + { + // Same overflow scenario as the ownerGives case above, but run with + // trace-level logging so BookStep::forEachOffer's removeOffer() + // emits its "Removing offer with overflowing amount calculation" + // trace line. This exercises the JLOG body inside removeOffer, + // which is skipped when logging is above trace severity. + std::string logs; + { + Env env{ + *this, + envconfig(), + features, + std::make_unique(&logs), + beast::Severity::Trace}; + env.fund(XRP(10'000), issuer, taker); + env.close(); + + MPTTester const token{ + {.env = env, .issuer = issuer, .holders = {taker}, .transferFee = 10'000}}; + + std::int64_t const poisonAmount = 8'500'000'000'000'000'000LL; + auto const poisonSeq = env.seq(issuer); + env(offer(issuer, XRP(1), token(poisonAmount))); + env.close(); + + auto const poisonKeylet = + keylet::offer(issuer.id(), SeqProxy::rawSequence(poisonSeq)); + BEAST_EXPECT(env.le(poisonKeylet) != nullptr); + + auto const takerSeq = env.seq(taker); + env(offer(taker, token(100), XRP(100))); + env.close(); + + BEAST_EXPECT(env.le(poisonKeylet) == nullptr); + BEAST_EXPECT( + env.le(keylet::offer(taker.id(), SeqProxy::rawSequence(takerSeq))) != nullptr); + } + BEAST_EXPECT(logs.contains("Removing offer with overflowing amount calculation")); + } + } + void testSelfCrossOffer1(FeatureBitset features) { @@ -4920,6 +5627,7 @@ public: testSellOffer(features); testSellWithFillOrKill(features); testTransferRateOffer(features); + testTransferRateOverflowOffer(features); testSelfCrossOffer(features); testSelfIssueOffer(features); testDirectToDirectPath(features); @@ -4934,8 +5642,11 @@ public: testDeletedOfferIssuer(features); testTicketOffer(features); testTicketCancelOffer(features); + testMPTAMMLimitQualityRounding(features); testRmSmallIncreasedQOffersXRP(features); testRmSmallIncreasedQOffersMPT(features); + testMPTIssuerOfferUsesRemainingCapacity(features); + testPartiallyFundedMPTInputOfferZeroInput(features); testFillOrKill(features); testTickSize(features); testAutoCreateReserve(features); diff --git a/src/test/protocol/STAmount_test.cpp b/src/test/protocol/STAmount_test.cpp index f6c5a94752..c3a681cf01 100644 --- a/src/test/protocol/STAmount_test.cpp +++ b/src/test/protocol/STAmount_test.cpp @@ -1,16 +1,21 @@ #include +#include #include +#include #include #include #include #include #include +#include #include #include #include #include #include +#include +#include #include #include #include @@ -24,6 +29,7 @@ #include #include #include +#include namespace xrpl { @@ -990,6 +996,84 @@ public: } } + void + testMPTRateRounding() + { + testcase("MPT transfer rate rounding uses Number arithmetic"); + + MPTIssue const asset{makeMptID(1, AccountID(0x4985601))}; + Rate const transferRate{1'500'000'000}; + STAmount const largeAmount{asset, UINT64_C(1'230'000'000'000'000'000)}; + STAmount const scaledAmount{asset, UINT64_C(1'845'000'000'000'000'000)}; + + auto rules = [](bool const mptV2) { + // Rules keeps a reference to the presets set, so use static + // storage here rather than a local temporary. + static std::unordered_set> const kNoFeatures; + static std::unordered_set> const kMptV2Features{ + featureMPTokensV2}; + return Rules{mptV2 ? kMptV2Features : kNoFeatures}; + }; + + auto throwsOverflow = [&](auto&& f, bool expected = true) { + bool threw = false; + try + { + f(); + } + catch (std::overflow_error const&) + { + threw = true; + } + BEAST_EXPECT(threw == expected); + }; + + { + CurrentTransactionRulesGuard const rg(rules(false)); + + throwsOverflow([&] { (void)multiplyRound(largeAmount, transferRate, asset, true); }); + throwsOverflow([&] { (void)divideRound(scaledAmount, transferRate, asset, true); }); + } + + { + CurrentTransactionRulesGuard const rg(rules(true)); + + throwsOverflow( + [&] { (void)multiplyRound(largeAmount, transferRate, asset, true); }, false); + throwsOverflow( + [&] { (void)divideRound(scaledAmount, transferRate, asset, true); }, false); + } + + { + CurrentTransactionRulesGuard const rg(rules(true)); + STAmount const one{asset, 1}; + STAmount const two{asset, 2}; + + BEAST_EXPECT(multiplyRound(one, transferRate, asset, true) == two); + BEAST_EXPECT(multiplyRound(one, transferRate, asset, false) == one); + BEAST_EXPECT(divideRound(two, transferRate, asset, true) == two); + BEAST_EXPECT(divideRound(two, transferRate, asset, false) == one); + + BEAST_EXPECT(multiplyRound(largeAmount, transferRate, asset, true) == scaledAmount); + BEAST_EXPECT(divideRound(scaledAmount, transferRate, asset, true) == largeAmount); + } + + { + // mulRound with an integral (XRP) operand whose mantissa is below + // kMinValue exercises the legacy value-scaling loop that normalizes + // the mantissa before multiply. The MPTokensV2 Number path is + // not taken here because the target asset is an IOU. + Issue const usd{Currency(0x5553440000000000), AccountID(0x4985601)}; + STAmount const iouVal{usd, 5}; + STAmount const xrpVal{XRPAmount{7}}; // integral, mantissa < kMinValue + + auto const up = mulRound(iouVal, xrpVal, usd, /*roundUp*/ true); + auto const down = mulRound(iouVal, xrpVal, usd, /*roundUp*/ false); + BEAST_EXPECT(down.signum() > 0); + BEAST_EXPECT(up >= down); + } + } + void testCanSubtractXRP() { @@ -1267,6 +1351,7 @@ public: testCanAddXRP(); testCanAddIOU(); testCanAddMPT(); + testMPTRateRounding(); testCanSubtractXRP(); testCanSubtractIOU(); testCanSubtractMPT(); From 153b7839a758f7c7d79b08378161b2a04af73432 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:40:39 +0000 Subject: [PATCH 069/102] refactor: Replace `boost::filesystem` with `std::filesystem` across the codebase (#7012) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mvadari <8029314+mvadari@users.noreply.github.com> Co-authored-by: Mayukha Vadari Co-authored-by: Mayukha Vadari Co-authored-by: Ayaz Salikhov Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: mathbunnyru <12270691+mathbunnyru@users.noreply.github.com> --- include/xrpl/basics/Archive.h | 4 +- include/xrpl/basics/FileUtilities.h | 69 +++++++++++-- include/xrpl/basics/Log.h | 8 +- include/xrpl/beast/unit_test/suite.h | 4 +- include/xrpl/beast/utility/temp_dir.h | 71 -------------- include/xrpl/core/PerfLog.h | 7 +- include/xrpl/rdb/DatabaseCon.h | 11 +-- include/xrpl/rdb/RelationalDatabase.h | 1 - include/xrpl/server/State.h | 2 - .../libxrpl/nodestore/NodeStoreBench.h | 6 +- src/libxrpl/basics/Archive.cpp | 8 +- src/libxrpl/basics/FileUtilities.cpp | 96 +++++++++++++++---- src/libxrpl/basics/Log.cpp | 6 +- src/libxrpl/nodestore/backend/NuDBFactory.cpp | 17 ++-- .../nodestore/backend/RocksDBFactory.cpp | 8 +- src/libxrpl/rdb/SociDB.cpp | 8 +- src/libxrpl/server/Vacuum.cpp | 9 +- src/test/app/GRPCServerTLS_test.cpp | 10 +- src/test/app/LedgerLoad_test.cpp | 16 ++-- src/test/app/Manifest_test.cpp | 18 ++-- src/test/app/SHAMapStore_test.cpp | 5 +- src/test/app/ValidatorSite_test.cpp | 5 +- src/test/basics/PerfLog_test.cpp | 44 ++++----- src/test/core/Config_test.cpp | 24 ++--- src/test/core/SociDB_test.cpp | 23 +++-- src/test/unit_test/FileDirGuard.h | 13 ++- src/tests/libxrpl/basics/FileUtilities.cpp | 44 ++++----- src/tests/libxrpl/nodestore/Backend.cpp | 4 +- src/tests/libxrpl/nodestore/Database.cpp | 10 +- src/tests/libxrpl/nodestore/NuDBFactory.cpp | 28 +++--- src/xrpld/app/main/GRPCServer.cpp | 3 +- src/xrpld/app/misc/SHAMapStoreImp.cpp | 36 ++++--- src/xrpld/app/misc/ValidatorList.h | 5 +- src/xrpld/app/misc/detail/ValidatorList.cpp | 21 ++-- src/xrpld/app/rdb/backend/detail/Node.cpp | 13 +-- src/xrpld/core/Config.h | 11 +-- src/xrpld/core/detail/Config.cpp | 68 ++++++------- src/xrpld/perflog/detail/PerfLogImp.cpp | 17 ++-- 38 files changed, 383 insertions(+), 370 deletions(-) delete mode 100644 include/xrpl/beast/utility/temp_dir.h diff --git a/include/xrpl/basics/Archive.h b/include/xrpl/basics/Archive.h index 66d6a019af..67261352e9 100644 --- a/include/xrpl/basics/Archive.h +++ b/include/xrpl/basics/Archive.h @@ -1,6 +1,6 @@ #pragma once -#include +#include namespace xrpl { @@ -13,6 +13,6 @@ namespace xrpl { * @throws runtime_error */ void -extractTarLz4(boost::filesystem::path const& src, boost::filesystem::path const& dst); +extractTarLz4(std::filesystem::path const& src, std::filesystem::path const& dst); } // namespace xrpl diff --git a/include/xrpl/basics/FileUtilities.h b/include/xrpl/basics/FileUtilities.h index c7a427b8a9..ca3435be03 100644 --- a/include/xrpl/basics/FileUtilities.h +++ b/include/xrpl/basics/FileUtilities.h @@ -1,24 +1,79 @@ #pragma once -#include -#include - #include +#include #include #include +#include namespace xrpl { std::string getFileContents( - boost::system::error_code& ec, - boost::filesystem::path const& sourcePath, + std::error_code& ec, + std::filesystem::path const& sourcePath, std::optional maxSize = std::nullopt); void writeFileContents( - boost::system::error_code& ec, - boost::filesystem::path const& destPath, + std::error_code& ec, + std::filesystem::path const& destPath, std::string const& contents); +/** + * Generate a unique, non-existing path under @p base whose filename starts with + * @p prefix and ends with a random hex suffix. + * + * Attempts up to @p maxAttempts paths. Throws `std::runtime_error` if a unique + * path cannot be found or if the filesystem returns an error while checking for + * existence. + */ +std::filesystem::path +uniqueRandomPath( + std::filesystem::path const& base, + std::string const& prefix = "", + std::size_t maxAttempts = 100); + +/** + * RAII temporary directory. + * + * The directory and all its contents are deleted when + * the instance of `TempDir` is destroyed. + */ +class TempDir +{ + std::filesystem::path path_; + +public: +#if !GENERATING_DOCS + TempDir(TempDir const&) = delete; + TempDir& + operator=(TempDir const&) = delete; +#endif + + /** + * Construct a temporary directory. + */ + TempDir(); + + /** + * Destroy a temporary directory. + */ + ~TempDir(); + + /** + * Get the native path for the temporary directory. + */ + [[nodiscard]] std::string + path() const; + + /** + * Get the native path for a file. + * + * The file does not need to exist. + */ + [[nodiscard]] std::string + file(std::string const& name) const; +}; + } // namespace xrpl diff --git a/include/xrpl/basics/Log.h b/include/xrpl/basics/Log.h index 945dc1b4ec..3aceac5f4a 100644 --- a/include/xrpl/basics/Log.h +++ b/include/xrpl/basics/Log.h @@ -3,8 +3,8 @@ #include #include -#include +#include #include #include #include @@ -84,7 +84,7 @@ private: * @return `true` if the file was opened. */ bool - open(boost::filesystem::path const& path); + open(std::filesystem::path const& path); /** * Close and re-open the system file associated with the log @@ -133,7 +133,7 @@ private: private: std::unique_ptr stream_; - boost::filesystem::path path_; + std::filesystem::path path_; }; std::mutex mutable mutex_; @@ -152,7 +152,7 @@ public: virtual ~Logs() = default; bool - open(boost::filesystem::path const& pathToLogFile); + open(std::filesystem::path const& pathToLogFile); beast::Journal::Sink& get(std::string const& name); diff --git a/include/xrpl/beast/unit_test/suite.h b/include/xrpl/beast/unit_test/suite.h index a727e3fc77..2b06fb4e05 100644 --- a/include/xrpl/beast/unit_test/suite.h +++ b/include/xrpl/beast/unit_test/suite.h @@ -6,10 +6,10 @@ #include -#include #include #include +#include #include #include #include @@ -26,7 +26,7 @@ makeReason(String const& reason, char const* file, int line) std::string s(reason); if (!s.empty()) s.append(": "); - namespace fs = boost::filesystem; + namespace fs = std::filesystem; s.append(fs::path{file}.filename().string()); s.append("("); s.append(std::to_string(line)); diff --git a/include/xrpl/beast/utility/temp_dir.h b/include/xrpl/beast/utility/temp_dir.h deleted file mode 100644 index a0ff1e6940..0000000000 --- a/include/xrpl/beast/utility/temp_dir.h +++ /dev/null @@ -1,71 +0,0 @@ -#pragma once - -#include - -#include - -namespace beast { - -/** - * RAII temporary directory. - * - * The directory and all its contents are deleted when - * the instance of `temp_dir` is destroyed. - */ -class TempDir -{ - boost::filesystem::path path_; - -public: -#if !GENERATING_DOCS - TempDir(TempDir const&) = delete; - TempDir& - operator=(TempDir const&) = delete; -#endif - - /** - * Construct a temporary directory. - */ - TempDir() - { - auto const dir = boost::filesystem::temp_directory_path(); - do - { - path_ = dir / boost::filesystem::unique_path(); - } while (boost::filesystem::exists(path_)); - boost::filesystem::create_directory(path_); - } - - /** - * Destroy a temporary directory. - */ - ~TempDir() - { - // use non-throwing calls in the destructor - boost::system::error_code ec; - boost::filesystem::remove_all(path_, ec); - // TODO: warn/notify if ec set ? - } - - /** - * Get the native path for the temporary directory - */ - [[nodiscard]] std::string - path() const - { - return path_.string(); - } - - /** - * Get the native path for the a file. - * - * The file does not need to exist. - */ - [[nodiscard]] std::string - file(std::string const& name) const - { - return (path_ / name).string(); - } -}; - -} // namespace beast diff --git a/include/xrpl/core/PerfLog.h b/include/xrpl/core/PerfLog.h index f09665e291..dd78a8f9a6 100644 --- a/include/xrpl/core/PerfLog.h +++ b/include/xrpl/core/PerfLog.h @@ -4,10 +4,9 @@ #include #include -#include - #include #include +#include #include #include #include @@ -44,7 +43,7 @@ public: */ struct Setup { - boost::filesystem::path perfLog; + std::filesystem::path perfLog; // log_interval is in milliseconds to support faster testing. milliseconds logInterval{seconds(1)}; }; @@ -149,7 +148,7 @@ public: }; PerfLog::Setup -setupPerfLog(Section const& section, boost::filesystem::path const& configDir); +setupPerfLog(Section const& section, std::filesystem::path const& configDir); std::unique_ptr makePerfLog( diff --git a/include/xrpl/rdb/DatabaseCon.h b/include/xrpl/rdb/DatabaseCon.h index 90aed04337..5c20f65784 100644 --- a/include/xrpl/rdb/DatabaseCon.h +++ b/include/xrpl/rdb/DatabaseCon.h @@ -6,13 +6,12 @@ #include #include -#include - #include #include #include #include +#include #include #include #include @@ -80,7 +79,7 @@ public: StartUpType startUp = StartUpType::Normal; bool standAlone = false; - boost::filesystem::path dataDir; + std::filesystem::path dataDir; // Indicates whether or not to return the `globalPragma` // from commonPragma() bool useGlobalPragma = false; @@ -143,7 +142,7 @@ public: template DatabaseCon( - boost::filesystem::path const& dataDir, + std::filesystem::path const& dataDir, std::string const& dbName, std::array const& pragma, std::array const& initSQL, @@ -155,7 +154,7 @@ public: // Use this constructor to setup checkpointing template DatabaseCon( - boost::filesystem::path const& dataDir, + std::filesystem::path const& dataDir, std::string const& dbName, std::array const& pragma, std::array const& initSQL, @@ -190,7 +189,7 @@ private: template DatabaseCon( - boost::filesystem::path const& pPath, + std::filesystem::path const& pPath, std::vector const* commonPragma, std::array const& pragma, std::array const& initSQL, diff --git a/include/xrpl/rdb/RelationalDatabase.h b/include/xrpl/rdb/RelationalDatabase.h index e5784c7418..e858f578f8 100644 --- a/include/xrpl/rdb/RelationalDatabase.h +++ b/include/xrpl/rdb/RelationalDatabase.h @@ -14,7 +14,6 @@ #include #include -#include #include #include diff --git a/include/xrpl/server/State.h b/include/xrpl/server/State.h index 8590f6e18f..b79253c12c 100644 --- a/include/xrpl/server/State.h +++ b/include/xrpl/server/State.h @@ -4,8 +4,6 @@ #include #include -#include - #include namespace xrpl { diff --git a/src/benchmarks/libxrpl/nodestore/NodeStoreBench.h b/src/benchmarks/libxrpl/nodestore/NodeStoreBench.h index 6122dd2535..a90207f26a 100644 --- a/src/benchmarks/libxrpl/nodestore/NodeStoreBench.h +++ b/src/benchmarks/libxrpl/nodestore/NodeStoreBench.h @@ -2,10 +2,10 @@ #include #include +#include #include #include #include -#include #include #include #include @@ -227,7 +227,7 @@ sliceFixedBatches(Batch const& pool, std::size_t batchSize) */ struct BackendHarness { - beast::TempDir tempDir; ///< Declared first so it is destroyed last + TempDir tempDir; ///< Declared first so it is destroyed last DummyScheduler scheduler; beast::Journal journal{beast::Journal::getNullSink()}; std::unique_ptr backend; @@ -257,7 +257,7 @@ struct BackendHarness */ struct DatabaseHarness { - beast::TempDir tempDir; + TempDir tempDir; DummyScheduler scheduler; beast::Journal journal{beast::Journal::getNullSink()}; std::unique_ptr db; diff --git a/src/libxrpl/basics/Archive.cpp b/src/libxrpl/basics/Archive.cpp index bba144ed04..5ab0d88c1d 100644 --- a/src/libxrpl/basics/Archive.cpp +++ b/src/libxrpl/basics/Archive.cpp @@ -2,22 +2,20 @@ #include -#include -#include - #include #include #include +#include #include #include namespace xrpl { void -extractTarLz4(boost::filesystem::path const& src, boost::filesystem::path const& dst) +extractTarLz4(std::filesystem::path const& src, std::filesystem::path const& dst) { - if (!is_regular_file(src)) + if (!std::filesystem::is_regular_file(src)) Throw("Invalid source file"); using archive_ptr = std::unique_ptr; diff --git a/src/libxrpl/basics/FileUtilities.cpp b/src/libxrpl/basics/FileUtilities.cpp index 1a6e604724..bed2b756ac 100644 --- a/src/libxrpl/basics/FileUtilities.cpp +++ b/src/libxrpl/basics/FileUtilities.cpp @@ -1,29 +1,31 @@ #include -#include -#include -#include -#include -#include +#include #include #include +#include #include +#include #include +#include #include #include +#include +#include +#include #include +#include namespace xrpl { std::string getFileContents( - boost::system::error_code& ec, - boost::filesystem::path const& sourcePath, + std::error_code& ec, + std::filesystem::path const& sourcePath, std::optional maxSize) { - using namespace boost::filesystem; - using namespace boost::system::errc; + using namespace std::filesystem; path const fullPath{canonical(sourcePath, ec)}; if (ec) @@ -32,15 +34,15 @@ getFileContents( if (maxSize && (file_size(fullPath, ec) > *maxSize || ec)) { if (!ec) - ec = make_error_code(file_too_large); + ec = make_error_code(std::errc::file_too_large); return {}; } - std::ifstream fileStream(fullPath.string(), std::ios::in); + std::ifstream fileStream(fullPath, std::ios::in); if (!fileStream) { - ec = make_error_code(static_cast(errno)); + ec.assign(errno, std::generic_category()); return {}; } @@ -49,7 +51,7 @@ getFileContents( if (fileStream.bad()) { - ec = make_error_code(static_cast(errno)); + ec.assign(errno, std::generic_category()); return {}; } @@ -58,18 +60,15 @@ getFileContents( void writeFileContents( - boost::system::error_code& ec, - boost::filesystem::path const& destPath, + std::error_code& ec, + std::filesystem::path const& destPath, std::string const& contents) { - using namespace boost::filesystem; - using namespace boost::system::errc; - - std::ofstream fileStream(destPath.string(), std::ios::out | std::ios::trunc); + std::ofstream fileStream(destPath, std::ios::out | std::ios::trunc); if (!fileStream) { - ec = make_error_code(static_cast(errno)); + ec.assign(errno, std::generic_category()); return; } @@ -77,9 +76,64 @@ writeFileContents( if (fileStream.bad()) { - ec = make_error_code(static_cast(errno)); + ec.assign(errno, std::generic_category()); return; } } +std::filesystem::path +uniqueRandomPath( + std::filesystem::path const& base, + std::string const& prefix, + std::size_t maxAttempts) +{ + std::random_device rd; + for (std::size_t attempt = 0; attempt < maxAttempts; ++attempt) + { + std::ostringstream oss; + oss << prefix << std::hex << std::setfill('0') << std::setw(8) << rd() << std::setw(8) + << rd(); + auto candidate = base / oss.str(); + std::error_code ec; + bool const exists = std::filesystem::exists(candidate, ec); + if (ec) + { + Throw( + "Unable to check path '" + candidate.string() + "': " + ec.message()); + } + if (!exists) + return candidate; + } + Throw("Unable to generate a unique path under '" + base.string() + "'"); +} + +TempDir::TempDir() : path_(uniqueRandomPath(std::filesystem::temp_directory_path())) +{ + std::filesystem::create_directory(path_); +} + +TempDir::~TempDir() +{ + // use non-throwing calls in the destructor + std::error_code ec; + std::filesystem::remove_all(path_, ec); + if (ec) + { + std::cerr << "Unable to remove temporary directory '" << path_.string() + << "': " << ec.message() << '\n'; + } +} + +std::string +TempDir::path() const +{ + return path_.string(); +} + +std::string +TempDir::file(std::string const& name) const +{ + return (path_ / name).string(); +} + } // namespace xrpl diff --git a/src/libxrpl/basics/Log.cpp b/src/libxrpl/basics/Log.cpp index d1e54a515f..68525f5a65 100644 --- a/src/libxrpl/basics/Log.cpp +++ b/src/libxrpl/basics/Log.cpp @@ -5,10 +5,10 @@ #include #include -#include #include #include +#include #include #include #include @@ -54,7 +54,7 @@ Logs::File::isOpen() const noexcept } bool -Logs::File::open(boost::filesystem::path const& path) +Logs::File::open(std::filesystem::path const& path) { close(); @@ -114,7 +114,7 @@ Logs::Logs(beast::Severity thresh) : thresh_(thresh) // default severity } bool -Logs::open(boost::filesystem::path const& pathToLogFile) +Logs::open(std::filesystem::path const& pathToLogFile) { return file_.open(pathToLogFile); } diff --git a/src/libxrpl/nodestore/backend/NuDBFactory.cpp b/src/libxrpl/nodestore/backend/NuDBFactory.cpp index bbf37f3edf..98173858e8 100644 --- a/src/libxrpl/nodestore/backend/NuDBFactory.cpp +++ b/src/libxrpl/nodestore/backend/NuDBFactory.cpp @@ -16,8 +16,6 @@ #include #include -#include -#include #include #include @@ -36,12 +34,14 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include namespace xrpl::node_store { @@ -131,7 +131,7 @@ public: void open(bool createIfMissing, uint64_t appType, uint64_t uid, uint64_t salt) override { - using namespace boost::filesystem; + using namespace std::filesystem; if (db.is_open()) { // LCOV_EXCL_START @@ -194,11 +194,12 @@ public: if (deletePath) { - boost::filesystem::remove_all(name, ec); - if (ec) + std::error_code fsec; + std::filesystem::remove_all(name, fsec); + if (fsec) { - JLOG(j.fatal()) - << "Filesystem remove_all of " << name << " failed with: " << ec.message(); + JLOG(j.fatal()) << "Filesystem remove_all of " << name + << " failed with: " << fsec.message(); } } } @@ -352,7 +353,7 @@ private: static std::size_t parseBlockSize(std::string const& name, Section const& keyValues, beast::Journal journal) { - using namespace boost::filesystem; + using namespace std::filesystem; auto const folder = path(name); auto const kp = (folder / "nudb.key").string(); diff --git a/src/libxrpl/nodestore/backend/RocksDBFactory.cpp b/src/libxrpl/nodestore/backend/RocksDBFactory.cpp index 4b7a1171fe..6f00b762b2 100644 --- a/src/libxrpl/nodestore/backend/RocksDBFactory.cpp +++ b/src/libxrpl/nodestore/backend/RocksDBFactory.cpp @@ -19,9 +19,6 @@ #include #include -#include -#include - #include #include #include @@ -37,6 +34,7 @@ #include #include +#include #include #include #include @@ -262,8 +260,8 @@ public: db.reset(); if (deletePath_) { - boost::filesystem::path const dir = name; - boost::filesystem::remove_all(dir); + std::filesystem::path const dir = name; + std::filesystem::remove_all(dir); } } } diff --git a/src/libxrpl/rdb/SociDB.cpp b/src/libxrpl/rdb/SociDB.cpp index 2c3fb1bde1..84006acbe7 100644 --- a/src/libxrpl/rdb/SociDB.cpp +++ b/src/libxrpl/rdb/SociDB.cpp @@ -5,13 +5,11 @@ #include #include -#include -#include - #include #include #include +#include #include #include #include @@ -45,8 +43,8 @@ getSociSqliteInit(std::string const& name, std::string const& dir, std::string c Throw( "Sqlite databases must specify a dir and a name. Name: " + name + " Dir: " + dir); } - boost::filesystem::path file(dir); - if (is_directory(file)) + std::filesystem::path file(dir); + if (std::filesystem::is_directory(file)) file /= name + ext; return file.string(); } diff --git a/src/libxrpl/server/Vacuum.cpp b/src/libxrpl/server/Vacuum.cpp index 63d40af156..df768d509a 100644 --- a/src/libxrpl/server/Vacuum.cpp +++ b/src/libxrpl/server/Vacuum.cpp @@ -5,13 +5,12 @@ #include #include -#include -#include #include // IWYU pragma: keep #include #include +#include #include #include @@ -20,12 +19,12 @@ namespace xrpl { bool doVacuumDB(DatabaseCon::Setup const& setup, beast::Journal j) { - boost::filesystem::path const dbPath = setup.dataDir / kTxDbName; + std::filesystem::path const dbPath = setup.dataDir / kTxDbName; - uintmax_t const dbSize = file_size(dbPath); + uintmax_t const dbSize = std::filesystem::file_size(dbPath); XRPL_ASSERT(dbSize != static_cast(-1), "xrpl::doVacuumDB : file_size succeeded"); - if (auto available = space(dbPath.parent_path()).available; available < dbSize) + if (auto available = std::filesystem::space(dbPath.parent_path()).available; available < dbSize) { std::cerr << "The database filesystem must have at least as " "much free space as the size of " diff --git a/src/test/app/GRPCServerTLS_test.cpp b/src/test/app/GRPCServerTLS_test.cpp index a48986d004..58ccf33959 100644 --- a/src/test/app/GRPCServerTLS_test.cpp +++ b/src/test/app/GRPCServerTLS_test.cpp @@ -1,13 +1,12 @@ #include #include +#include #include #include #include #include -#include - #include #include #include @@ -17,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -254,10 +254,8 @@ public: TemporaryTLSCertificates() { - auto tmpDir = std::filesystem::temp_directory_path(); - auto uniqueDirName = - boost::filesystem::unique_path(std::string(kCertsDirPrefix) + "%%%%%%%%"); - tempDir_ = tmpDir / uniqueDirName.string(); + tempDir_ = xrpl::uniqueRandomPath( + std::filesystem::temp_directory_path(), std::string(kCertsDirPrefix)); std::filesystem::create_directories(tempDir_); writeFile(tempDir_ / kCaCertFilename, kCaCertContent); diff --git a/src/test/app/LedgerLoad_test.cpp b/src/test/app/LedgerLoad_test.cpp index ee3bfe5192..8fb10c1088 100644 --- a/src/test/app/LedgerLoad_test.cpp +++ b/src/test/app/LedgerLoad_test.cpp @@ -7,10 +7,10 @@ #include +#include #include #include #include -#include #include #include #include @@ -18,16 +18,16 @@ #include #include -#include -#include #include +#include #include #include #include #include #include #include +#include namespace xrpl { @@ -61,7 +61,7 @@ class LedgerLoad_test : public beast::unit_test::Suite }; SetupData - setupLedger(beast::TempDir const& td) + setupLedger(TempDir const& td) { using namespace test::jtx; SetupData retval = {.dbPath = td.path()}; @@ -139,7 +139,7 @@ class LedgerLoad_test : public beast::unit_test::Suite { testcase("Load ledger: Bad Files"); using namespace test::jtx; - using namespace boost::filesystem; + using namespace std::filesystem; // empty path except([&] { @@ -161,8 +161,8 @@ class LedgerLoad_test : public beast::unit_test::Suite }); // make a corrupted version of the ledger file (last 10 bytes removed). - boost::system::error_code ec; - auto ledgerFileCorrupt = boost::filesystem::path{sd.dbPath} / "ledgerdata_bad.json"; + std::error_code ec; + auto ledgerFileCorrupt = std::filesystem::path{sd.dbPath} / "ledgerdata_bad.json"; copy_file(sd.ledgerFile, ledgerFileCorrupt, copy_options::overwrite_existing, ec); if (!BEAST_EXPECTS(!ec, ec.message())) return; @@ -330,7 +330,7 @@ public: void run() override { - beast::TempDir const td; + TempDir const td; auto sd = setupLedger(td); // test cases diff --git a/src/test/app/Manifest_test.cpp b/src/test/app/Manifest_test.cpp index ef2043a22c..14d176b45f 100644 --- a/src/test/app/Manifest_test.cpp +++ b/src/test/app/Manifest_test.cpp @@ -22,14 +22,12 @@ #include #include -#include -#include - #include #include #include #include #include +#include #include #include #include @@ -56,18 +54,18 @@ private: } static void - cleanupDatabaseDir(boost::filesystem::path const& dbPath) + cleanupDatabaseDir(std::filesystem::path const& dbPath) { - using namespace boost::filesystem; + using namespace std::filesystem; if (!exists(dbPath) || !is_directory(dbPath) || !is_empty(dbPath)) return; remove(dbPath); } static void - setupDatabaseDir(boost::filesystem::path const& dbPath) + setupDatabaseDir(std::filesystem::path const& dbPath) { - using namespace boost::filesystem; + using namespace std::filesystem; if (!exists(dbPath)) { create_directory(dbPath); @@ -80,10 +78,10 @@ private: Throw("Cannot create directory: " + dbPath.string()); } } - static boost::filesystem::path + static std::filesystem::path getDatabasePath() { - return boost::filesystem::current_path() / "manifest_test_databases"; + return std::filesystem::current_path() / "manifest_test_databases"; } public: @@ -351,7 +349,7 @@ public: BEAST_EXPECT(loaded.revoked(pk)); } } - boost::filesystem::remove(getDatabasePath() / boost::filesystem::path(dbName)); + std::filesystem::remove(getDatabasePath() / std::filesystem::path(dbName)); } void diff --git a/src/test/app/SHAMapStore_test.cpp b/src/test/app/SHAMapStore_test.cpp index 6ee7442d23..82019affba 100644 --- a/src/test/app/SHAMapStore_test.cpp +++ b/src/test/app/SHAMapStore_test.cpp @@ -23,10 +23,9 @@ #include #include -#include - #include #include +#include #include #include #include @@ -493,7 +492,7 @@ public: makeBackendRotating(jtx::Env& env, NodeStoreScheduler& scheduler, std::string path) { Section section{env.app().config().section(Sections::kNodeDatabase)}; - boost::filesystem::path newPath; + std::filesystem::path newPath; if (!BEAST_EXPECT(path.size())) return {}; diff --git a/src/test/app/ValidatorSite_test.cpp b/src/test/app/ValidatorSite_test.cpp index 8400f2d794..8373efe85b 100644 --- a/src/test/app/ValidatorSite_test.cpp +++ b/src/test/app/ValidatorSite_test.cpp @@ -15,13 +15,12 @@ #include #include -#include -#include #include #include #include +#include #include #include #include @@ -704,7 +703,7 @@ public: .effectiveOverlap = detail::kDefaultEffectiveOverlap, .expectedRefreshMin = 60 * 24}}); // max of 24 hours } - using namespace boost::filesystem; + using namespace std::filesystem; for (auto const& file : directory_iterator(good.subdir())) { remove_all(file); diff --git a/src/test/basics/PerfLog_test.cpp b/src/test/basics/PerfLog_test.cpp index 24ea971515..f7679dc488 100644 --- a/src/test/basics/PerfLog_test.cpp +++ b/src/test/basics/PerfLog_test.cpp @@ -15,14 +15,10 @@ #include #include -#include -#include -#include -#include - #include #include #include +#include #include #include #include @@ -31,6 +27,7 @@ #include #include #include +#include #include #include #include @@ -43,7 +40,7 @@ class PerfLog_test : public beast::unit_test::Suite { enum class WithFile : bool { No = false, Yes = true }; - using path = boost::filesystem::path; + using path = std::filesystem::path; // We're only using Env for its Journal. That Journal gives better // coverage in unit tests. @@ -66,14 +63,14 @@ class PerfLog_test : public beast::unit_test::Suite // The error code is intentionally ignored: if the path doesn't // exist (the common case on a clean runner) remove_all returns // an error, and that's fine — there's nothing to clean up. - using namespace boost::filesystem; - boost::system::error_code ec; + using namespace std::filesystem; + std::error_code ec; remove_all(logDir(), ec); } ~Fixture() { - using namespace boost::filesystem; + using namespace std::filesystem; auto const dir{logDir()}; auto const file{logFile()}; @@ -96,7 +93,7 @@ class PerfLog_test : public beast::unit_test::Suite static path logDir() { - using namespace boost::filesystem; + using namespace std::filesystem; return temp_directory_path() / "perf_log_test_dir"; } @@ -129,7 +126,7 @@ class PerfLog_test : public beast::unit_test::Suite static void wait() { - using namespace boost::filesystem; + using namespace std::filesystem; auto const path = logFile(); if (!exists(path)) @@ -201,7 +198,7 @@ public: void testFileCreation() { - using namespace boost::filesystem; + using namespace std::filesystem; { // Verify a PerfLog creates its file when constructed. @@ -250,28 +247,30 @@ public: // Put a write protected file where PerfLog wants to write its // file. Make sure that PerfLog tries to shutdown the server // since it can't open its file. + using std::filesystem::perms; + Fixture fixture{env_.app(), j_}; if (!BEAST_EXPECT(!exists(fixture.logDir()))) return; // Construct and write protect a file to prevent PerfLog // from creating its file. - boost::system::error_code ec; - boost::filesystem::create_directories(fixture.logDir(), ec); + std::error_code ec; + std::filesystem::create_directories(fixture.logDir(), ec); if (!BEAST_EXPECT(!ec)) return; - auto fileWriteable = [](boost::filesystem::path const& p) -> bool { - return std::ofstream{p.c_str(), std::ios::out | std::ios::app}.is_open(); + auto fileWriteable = [](std::filesystem::path const& p) -> bool { + return std::ofstream{p, std::ios::out | std::ios::app}.is_open(); }; if (!BEAST_EXPECT(fileWriteable(fixture.logFile()))) return; - boost::filesystem::permissions( + std::filesystem::permissions( fixture.logFile(), - perms::remove_perms | perms::owner_write | perms::others_write | - perms::group_write); + perms::owner_write | perms::others_write | perms::group_write, + std::filesystem::perm_options::remove); // If the test is running as root, then the write protect may have // no effect. Make sure write protect worked before proceeding. @@ -295,9 +294,10 @@ public: perfLog->stop(); // Fix file permissions so the file can be cleaned up. - boost::filesystem::permissions( + std::filesystem::permissions( fixture.logFile(), - perms::add_perms | perms::owner_write | perms::others_write | perms::group_write); + perms::owner_write | perms::others_write | perms::group_write, + std::filesystem::perm_options::add); } } @@ -962,7 +962,7 @@ public: // We can't fully test rotate because unit tests must run on Windows, // and Windows doesn't (may not?) support rotate. But at least call // the interface and see that it doesn't crash. - using namespace boost::filesystem; + using namespace std::filesystem; Fixture fixture{env_.app(), j_}; BEAST_EXPECT(!exists(fixture.logDir())); diff --git a/src/test/core/Config_test.cpp b/src/test/core/Config_test.cpp index ac5471fd3c..dec6393010 100644 --- a/src/test/core/Config_test.cpp +++ b/src/test/core/Config_test.cpp @@ -3,14 +3,13 @@ #include +#include #include -#include #include #include #include // IWYU pragma: keep #include -#include #include // IWYU pragma: keep #include #include @@ -20,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -179,7 +179,7 @@ public: [[nodiscard]] bool dataDirExists() const { - return boost::filesystem::is_directory(dataDir_); + return std::filesystem::is_directory(dataDir_); } [[nodiscard]] bool @@ -192,7 +192,7 @@ public: { try { - using namespace boost::filesystem; + using namespace std::filesystem; if (rmDataDir_) rmDir(dataDir_); } @@ -273,7 +273,7 @@ public: class Config_test final : public TestSuite { private: - using path = boost::filesystem::path; + using path = std::filesystem::path; public: void @@ -309,7 +309,7 @@ port_wss_admin { testcase("config_file"); - using namespace boost::filesystem; + using namespace std::filesystem; auto const cwd = current_path(); // Test both config file names. @@ -319,7 +319,7 @@ port_wss_admin for (auto const& configFile : configFiles) { // Use a temporary directory for testing. - beast::TempDir const td; + TempDir const td; current_path(td.path()); path const f = td.file(std::string{configFile}); std::ofstream o(f.string()); @@ -341,13 +341,13 @@ port_wss_admin { // Point the current working directory to a temporary directory, so // we don't pick up an actual config file from the repository root. - beast::TempDir const td; + TempDir const td; current_path(td.path()); // The XDG config directory is set: the config file must be in a // subdirectory named after the system. { - beast::TempDir const tc; + TempDir const tc; // Set the HOME and XDG_CONFIG_HOME environment variables. The // HOME variable is not used when XDG_CONFIG_HOME is set, but @@ -381,7 +381,7 @@ port_wss_admin // The XDG config directory is not set: the config file must be in a // subdirectory named .config followed by the system name. { - beast::TempDir const tc; + TempDir const tc; // Set only the HOME environment variable. char const* h = getenv("HOME"); @@ -425,7 +425,7 @@ port_wss_admin { testcase("database_path"); - using namespace boost::filesystem; + using namespace std::filesystem; { boost::format cc("[database_path]\n%1%\n"); @@ -601,7 +601,7 @@ main { testcase("validators_file"); - using namespace boost::filesystem; + using namespace std::filesystem; { // load should throw for missing specified validators file boost::format cc("[validators_file]\n%1%\n"); diff --git a/src/test/core/SociDB_test.cpp b/src/test/core/SociDB_test.cpp index 7a57641b64..a7bb8e71bc 100644 --- a/src/test/core/SociDB_test.cpp +++ b/src/test/core/SociDB_test.cpp @@ -6,8 +6,6 @@ #include #include -#include -#include #include // IWYU pragma: keep #include // IWYU pragma: keep @@ -19,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -31,7 +30,7 @@ class SociDB_test final : public TestSuite { private: static void - setupSQLiteConfig(BasicConfig& config, boost::filesystem::path const& dbPath) + setupSQLiteConfig(BasicConfig& config, std::filesystem::path const& dbPath) { config.overwrite(Sections::kSqdb, Keys::kBackend, "sqlite"); auto value = dbPath.string(); @@ -40,18 +39,18 @@ private: } static void - cleanupDatabaseDir(boost::filesystem::path const& dbPath) + cleanupDatabaseDir(std::filesystem::path const& dbPath) { - using namespace boost::filesystem; + using namespace std::filesystem; if (!exists(dbPath) || !is_directory(dbPath) || !is_empty(dbPath)) return; remove(dbPath); } static void - setupDatabaseDir(boost::filesystem::path const& dbPath) + setupDatabaseDir(std::filesystem::path const& dbPath) { - using namespace boost::filesystem; + using namespace std::filesystem; if (!exists(dbPath)) { create_directory(dbPath); @@ -64,10 +63,10 @@ private: Throw("Cannot create directory: " + dbPath.string()); } } - static boost::filesystem::path + static std::filesystem::path getDatabasePath() { - return boost::filesystem::current_path() / "socidb_test_databases"; + return std::filesystem::current_path() / "socidb_test_databases"; } public: @@ -157,7 +156,7 @@ public: checkValues(s); } { - namespace bfs = boost::filesystem; + namespace bfs = std::filesystem; // Remove the database bfs::path const dbPath(sc.connectionString()); if (bfs::is_regular_file(dbPath)) @@ -231,7 +230,7 @@ public: // boost::tuple. DO NOT USE soci row! } { - namespace bfs = boost::filesystem; + namespace bfs = std::filesystem; // Remove the database bfs::path const dbPath(sc.connectionString()); if (bfs::is_regular_file(dbPath)) @@ -283,7 +282,7 @@ public: s << "SELECT LedgerSeq FROM Ledgers;", soci::into(ledgersLS); BEAST_EXPECT(ledgersLS.size() == numRows); } - namespace bfs = boost::filesystem; + namespace bfs = std::filesystem; // Remove the database bfs::path const dbPath(sc.connectionString()); if (bfs::is_regular_file(dbPath)) diff --git a/src/test/unit_test/FileDirGuard.h b/src/test/unit_test/FileDirGuard.h index b583f821a4..2e6b3fd179 100644 --- a/src/test/unit_test/FileDirGuard.h +++ b/src/test/unit_test/FileDirGuard.h @@ -3,9 +3,8 @@ #include #include -#include - #include +#include #include #include #include @@ -20,7 +19,7 @@ namespace xrpl::detail { class DirGuard { protected: - using path = boost::filesystem::path; + using path = std::filesystem::path; private: path subDir_; @@ -47,7 +46,7 @@ public: DirGuard(beast::unit_test::Suite& test, path subDir, bool useCounter = true) : subDir_(std::move(subDir)), test_(test) { - using namespace boost::filesystem; + using namespace std::filesystem; static auto kSubDirCounter = 0; if (useCounter) @@ -73,7 +72,7 @@ public: { try { - using namespace boost::filesystem; + using namespace std::filesystem; if (rmSubDir_) rmDir(subDir_); @@ -130,7 +129,7 @@ public: { try { - using namespace boost::filesystem; + using namespace std::filesystem; if (exists(file_)) { remove(file_); @@ -160,7 +159,7 @@ public: [[nodiscard]] bool fileExists() const { - return boost::filesystem::exists(file_); + return std::filesystem::exists(file_); } }; diff --git a/src/tests/libxrpl/basics/FileUtilities.cpp b/src/tests/libxrpl/basics/FileUtilities.cpp index cd24abd696..5cf2b72709 100644 --- a/src/tests/libxrpl/basics/FileUtilities.cpp +++ b/src/tests/libxrpl/basics/FileUtilities.cpp @@ -2,16 +2,14 @@ #include -#include -#include -#include -#include - #include +#include #include +#include #include #include +#include namespace xrpl { @@ -20,15 +18,14 @@ namespace { class TempFile { public: - explicit TempFile(boost::filesystem::path file, std::string const& contents) - : dir_( - boost::filesystem::temp_directory_path() / - boost::filesystem::unique_path("xrpl-file-utilities-%%%%-%%%%-%%%%")) - , file_(dir_ / file) + explicit TempFile(std::string const& file, std::string const& contents) + : file_( + uniqueRandomPath(std::filesystem::temp_directory_path(), "xrpl-file-utilities-") / + file) { - boost::filesystem::create_directory(dir_); + std::filesystem::create_directory(file_.parent_path()); - std::ofstream output(file_.string()); + std::ofstream output(file_); if (!output) throw std::runtime_error("Unable to create temporary test file"); @@ -37,33 +34,36 @@ public: ~TempFile() { - boost::system::error_code ec; - boost::filesystem::remove(file_, ec); - boost::filesystem::remove(dir_, ec); + // use non-throwing calls in the destructor + std::error_code ec; + auto const dir = file_.parent_path(); + std::filesystem::remove_all(dir, ec); + if (ec) + { + std::cerr << "Unable to remove temporary directory '" << dir.string() + << "': " << ec.message() << '\n'; + } } - [[nodiscard]] boost::filesystem::path const& + [[nodiscard]] std::filesystem::path const& file() const { return file_; } private: - boost::filesystem::path dir_; - boost::filesystem::path file_; + std::filesystem::path file_; }; } // namespace TEST(FileUtilitiesTest, get_file_contents) { - using namespace boost::system; - constexpr char const* kExpectedContents = "This file is very short. That's all we need."; TempFile const file("test_file", "This is temporary text that should get overwritten"); - error_code ec; + std::error_code ec; auto const path = file.file(); writeFileContents(ec, path, kExpectedContents); @@ -86,7 +86,7 @@ TEST(FileUtilitiesTest, get_file_contents) { // Test with small max auto const bad = getFileContents(ec, path, 16); - EXPECT_TRUE(ec && ec.value() == boost::system::errc::file_too_large); + EXPECT_TRUE(ec && ec.value() == static_cast(std::errc::file_too_large)); EXPECT_TRUE(bad.empty()); } } diff --git a/src/tests/libxrpl/nodestore/Backend.cpp b/src/tests/libxrpl/nodestore/Backend.cpp index eb78851429..3bd36ced8d 100644 --- a/src/tests/libxrpl/nodestore/Backend.cpp +++ b/src/tests/libxrpl/nodestore/Backend.cpp @@ -1,8 +1,8 @@ #include #include +#include #include -#include #include #include #include @@ -84,7 +84,7 @@ protected: } DummyScheduler scheduler_; - beast::TempDir const tempDir_; + TempDir const tempDir_; beast::Journal const journal_{TestSink::instance()}; Section params_; Batch batch_; diff --git a/src/tests/libxrpl/nodestore/Database.cpp b/src/tests/libxrpl/nodestore/Database.cpp index 82012ed347..a3f7340f62 100644 --- a/src/tests/libxrpl/nodestore/Database.cpp +++ b/src/tests/libxrpl/nodestore/Database.cpp @@ -1,8 +1,8 @@ #include #include +#include #include -#include #include #include #include @@ -81,7 +81,7 @@ protected: } DummyScheduler scheduler_; - beast::TempDir const nodeDb_; + TempDir const nodeDb_; beast::Journal const journal_{TestSink::instance()}; Section nodeParams_; Batch batch_; @@ -157,7 +157,7 @@ INSTANTIATE_TEST_SUITE_P( TEST(NodeStoreDatabase, memory_earliest_seq) { DummyScheduler scheduler; - beast::TempDir const nodeDb; + TempDir const nodeDb; Section nodeParams; nodeParams.set("type", "memory"); nodeParams.set("path", nodeDb.path()); @@ -204,7 +204,7 @@ TEST_P(DatabaseImportTest, same_backend) DummyScheduler scheduler; beast::Journal const journal(TestSink::instance()); - beast::TempDir const srcDir; + TempDir const srcDir; Section srcParams; srcParams.set("type", type); srcParams.set("path", srcDir.path()); @@ -222,7 +222,7 @@ TEST_P(DatabaseImportTest, same_backend) // re-open source and import into a fresh destination auto src = Manager::instance().makeDatabase(megabytes(4), scheduler, 2, srcParams, journal); - beast::TempDir const destDir; + TempDir const destDir; Section destParams; destParams.set("type", type); destParams.set("path", destDir.path()); diff --git a/src/tests/libxrpl/nodestore/NuDBFactory.cpp b/src/tests/libxrpl/nodestore/NuDBFactory.cpp index c126984630..7240f08256 100644 --- a/src/tests/libxrpl/nodestore/NuDBFactory.cpp +++ b/src/tests/libxrpl/nodestore/NuDBFactory.cpp @@ -1,6 +1,6 @@ #include +#include #include -#include #include #include #include @@ -58,7 +58,7 @@ runRoundTrip(Section const& params, std::size_t expectedBlocksize) TEST(NuDBFactory, default_block_size) { - beast::TempDir const tempDir; + TempDir const tempDir; auto const params = makeSection(tempDir.path()); ASSERT_NO_FATAL_FAILURE(runRoundTrip(params, 4096)); } @@ -69,14 +69,14 @@ TEST(NuDBFactory, valid_block_sizes) for (auto const size : kValidSizes) { SCOPED_TRACE("size=" + std::to_string(size)); - beast::TempDir const tempDir; + TempDir const tempDir; auto const params = makeSection(tempDir.path(), std::to_string(size)); ASSERT_NO_FATAL_FAILURE(runRoundTrip(params, size)); } // empty value is ignored by config parser; default (4096) is used { - beast::TempDir const tempDir; + TempDir const tempDir; auto const params = makeSection(tempDir.path(), ""); ASSERT_NO_FATAL_FAILURE(runRoundTrip(params, 4096)); } @@ -101,7 +101,7 @@ TEST(NuDBFactory, invalid_block_sizes) for (auto const& size : kInvalidSizes) { SCOPED_TRACE("size='" + size + "'"); - beast::TempDir const tempDir; + TempDir const tempDir; auto const params = makeSection(tempDir.path(), size); EXPECT_THROW(runRoundTrip(params, 4096), std::exception); } @@ -111,7 +111,7 @@ TEST(NuDBFactory, invalid_block_sizes) for (auto const& size : kWhitespaceSizes) { SCOPED_TRACE("size='" + size + "'"); - beast::TempDir const tempDir; + TempDir const tempDir; auto const params = makeSection(tempDir.path(), size); EXPECT_THROW(runRoundTrip(params, 4096), std::exception); } @@ -121,7 +121,7 @@ TEST(NuDBFactory, log_messages) { // valid custom block size emits info log { - beast::TempDir const tempDir; + TempDir const tempDir; auto const params = makeSection(tempDir.path(), "8192"); test::CaptureSink sink(beast::Severity::Info); beast::Journal const journal(sink); @@ -135,7 +135,7 @@ TEST(NuDBFactory, log_messages) // invalid block size throws with informative message { - beast::TempDir const tempDir; + TempDir const tempDir; auto const params = makeSection(tempDir.path(), "5000"); test::CaptureSink sink(beast::Severity::Warning); beast::Journal const journal(sink); @@ -156,7 +156,7 @@ TEST(NuDBFactory, log_messages) // non-numeric value throws { - beast::TempDir const tempDir; + TempDir const tempDir; auto const params = makeSection(tempDir.path(), "invalid"); test::CaptureSink sink(beast::Severity::Warning); beast::Journal const journal(sink); @@ -191,7 +191,7 @@ TEST(NuDBFactory, power_of_two_validation) for (auto const& [size, shouldWork] : kCASES) { SCOPED_TRACE("size=" + size + " shouldWork=" + (shouldWork ? "true" : "false")); - beast::TempDir const tempDir; + TempDir const tempDir; auto const params = makeSection(tempDir.path(), size); test::CaptureSink sink(beast::Severity::Warning); beast::Journal const journal(sink); @@ -216,7 +216,7 @@ TEST(NuDBFactory, power_of_two_validation) TEST(NuDBFactory, both_constructor_variants) { - beast::TempDir const tempDir; + TempDir const tempDir; auto const params = makeSection(tempDir.path(), "16384"); DummyScheduler scheduler; beast::Journal const journal(TestSink::instance()); @@ -235,7 +235,7 @@ TEST(NuDBFactory, configuration_parsing) { // basic valid format emits success log { - beast::TempDir const tempDir; + TempDir const tempDir; auto const params = makeSection(tempDir.path(), "8192"); test::CaptureSink sink(beast::Severity::Info); beast::Journal const journal(sink); @@ -250,7 +250,7 @@ TEST(NuDBFactory, configuration_parsing) for (auto const& format : kWhitespaceFormats) { SCOPED_TRACE("format='" + format + "'"); - beast::TempDir const tempDir; + TempDir const tempDir; auto const params = makeSection(tempDir.path(), format); test::CaptureSink sink(beast::Severity::Debug); beast::Journal const journal(sink); @@ -265,7 +265,7 @@ TEST(NuDBFactory, data_persistence) for (auto const& size : kBlockSizes) { SCOPED_TRACE("size=" + size); - beast::TempDir const tempDir; + TempDir const tempDir; auto const params = makeSection(tempDir.path(), size); DummyScheduler scheduler; beast::Journal const journal(TestSink::instance()); diff --git a/src/xrpld/app/main/GRPCServer.cpp b/src/xrpld/app/main/GRPCServer.cpp index fc4a9794bd..c1ea5e874b 100644 --- a/src/xrpld/app/main/GRPCServer.cpp +++ b/src/xrpld/app/main/GRPCServer.cpp @@ -49,6 +49,7 @@ #include #include #include +#include #include #include @@ -615,7 +616,7 @@ GRPCServerImpl::createServerCredentials() try { - boost::system::error_code ec; + std::error_code ec; grpc::SslServerCredentialsOptions sslOpts; grpc::SslServerCredentialsOptions::PemKeyCertPair keyCertPair; diff --git a/src/xrpld/app/misc/SHAMapStoreImp.cpp b/src/xrpld/app/misc/SHAMapStoreImp.cpp index e41837d206..9e3f1ac52b 100644 --- a/src/xrpld/app/misc/SHAMapStoreImp.cpp +++ b/src/xrpld/app/misc/SHAMapStoreImp.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -27,12 +28,10 @@ #include #include -#include -#include -#include #include #include +#include #include #include #include @@ -426,10 +425,10 @@ SHAMapStoreImp::dbPaths() if (boost::iequals(get(section, Keys::kType), "memory")) return; - boost::filesystem::path dbPath = get(section, Keys::kPath); - if (boost::filesystem::exists(dbPath)) + std::filesystem::path dbPath = get(section, Keys::kPath); + if (std::filesystem::exists(dbPath)) { - if (!boost::filesystem::is_directory(dbPath)) + if (!std::filesystem::is_directory(dbPath)) { journal_.error() << "node db path must be a directory. " << dbPath.string(); Throw("node db path must be a directory."); @@ -437,7 +436,7 @@ SHAMapStoreImp::dbPaths() } else { - boost::filesystem::create_directories(dbPath); + std::filesystem::create_directories(dbPath); } SavedState state = stateDb_.getState(); @@ -448,8 +447,8 @@ SHAMapStoreImp::dbPaths() return false; // Check if configured "path" matches stored directory path - using namespace boost::filesystem; - auto const stored{path(sPath)}; + using namespace std::filesystem; + auto const stored{std::filesystem::path(sPath)}; if (stored.parent_path() == dbPath) return false; @@ -467,9 +466,9 @@ SHAMapStoreImp::dbPaths() bool writableDbExists = false; bool archiveDbExists = false; - std::vector pathsToDelete; - for (boost::filesystem::directory_iterator it(dbPath); - it != boost::filesystem::directory_iterator(); + std::vector pathsToDelete; + for (std::filesystem::directory_iterator it(dbPath); + it != std::filesystem::directory_iterator(); ++it) { if (state.writableDb == it->path().string()) @@ -490,7 +489,7 @@ SHAMapStoreImp::dbPaths() (!archiveDbExists && !state.archiveDb.empty()) || (writableDbExists != archiveDbExists) || state.writableDb.empty() != state.archiveDb.empty()) { - boost::filesystem::path stateDbPathName = app_.config().legacy(Sections::kDatabasePath); + std::filesystem::path stateDbPathName = app_.config().legacy(Sections::kDatabasePath); stateDbPathName /= dbName_; stateDbPathName += "*"; @@ -512,15 +511,15 @@ SHAMapStoreImp::dbPaths() } // The necessary directories exist. Now, remove any others. - for (boost::filesystem::path const& p : pathsToDelete) - boost::filesystem::remove_all(p); + for (std::filesystem::path const& p : pathsToDelete) + std::filesystem::remove_all(p); } std::unique_ptr SHAMapStoreImp::makeBackendRotating(std::string path) { Section section{app_.config().section(Sections::kNodeDatabase)}; - boost::filesystem::path newPath; + std::filesystem::path newPath; if (!path.empty()) { @@ -528,10 +527,7 @@ SHAMapStoreImp::makeBackendRotating(std::string path) } else { - boost::filesystem::path p = get(section, Keys::kPath); - p /= dbPrefix_; - p += ".%%%%"; - newPath = boost::filesystem::unique_path(p); + newPath = uniqueRandomPath(get(section, Keys::kPath), dbPrefix_ + "."); } section.set(Keys::kPath, newPath.string()); diff --git a/src/xrpld/app/misc/ValidatorList.h b/src/xrpld/app/misc/ValidatorList.h index 3f9039eab8..abec6cf4e0 100644 --- a/src/xrpld/app/misc/ValidatorList.h +++ b/src/xrpld/app/misc/ValidatorList.h @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -238,7 +239,7 @@ class ValidatorList ManifestCache& validatorManifests_; ManifestCache& publisherManifests_; TimeKeeper& timeKeeper_; - boost::filesystem::path const dataPath_; + std::filesystem::path const dataPath_; beast::Journal const j_; std::shared_mutex mutable mutex_; using scoped_lock = std::scoped_lock; @@ -866,7 +867,7 @@ private: /** * Get the filename used for caching UNLs */ - boost::filesystem::path + std::filesystem::path getCacheFileName(scoped_lock const&, PublicKey const& pubKey) const; /** diff --git a/src/xrpld/app/misc/detail/ValidatorList.cpp b/src/xrpld/app/misc/detail/ValidatorList.cpp index e355cfacab..0ada8ed55f 100644 --- a/src/xrpld/app/misc/detail/ValidatorList.cpp +++ b/src/xrpld/app/misc/detail/ValidatorList.cpp @@ -29,12 +29,8 @@ #include #include -#include #include #include -#include -#include -#include #include @@ -43,6 +39,7 @@ #include #include #include +#include #include #include #include @@ -54,6 +51,7 @@ #include #include #include +#include #include #include @@ -288,7 +286,7 @@ ValidatorList::load( return true; } -boost::filesystem::path +std::filesystem::path ValidatorList::getCacheFileName(ValidatorList::scoped_lock const&, PublicKey const& pubKey) const { return dataPath_ / (kFilePrefix + strHex(pubKey)); @@ -372,9 +370,9 @@ ValidatorList::cacheValidatorFile(ValidatorList::scoped_lock const& lock, Public if (dataPath_.empty()) return; - boost::filesystem::path const filename = getCacheFileName(lock, pubKey); + std::filesystem::path const filename = getCacheFileName(lock, pubKey); - boost::system::error_code ec; + std::error_code ec; json::Value value = buildFileData(strHex(pubKey), publisherLists_.at(pubKey), j_); // xrpld should be the only process writing to this file, so @@ -1295,8 +1293,7 @@ std::vector ValidatorList::loadLists() { using namespace std::string_literals; - using namespace boost::filesystem; - using namespace boost::system::errc; + using namespace std::filesystem; std::scoped_lock const lock{mutex_}; @@ -1304,12 +1301,12 @@ ValidatorList::loadLists() sites.reserve(publisherLists_.size()); for (auto const& [pubKey, publisherCollection] : publisherLists_) { - boost::system::error_code ec; + std::error_code ec; if (publisherCollection.status == PublisherStatus::Available) continue; - boost::filesystem::path const filename = getCacheFileName(lock, pubKey); + std::filesystem::path const filename = getCacheFileName(lock, pubKey); auto const fullPath{canonical(filename, ec)}; if (ec) @@ -1320,7 +1317,7 @@ ValidatorList::loadLists() { // Treat an empty file as a missing file, because // nobody else is going to write it. - ec = make_error_code(no_such_file_or_directory); + ec = make_error_code(std::errc::no_such_file_or_directory); } if (ec) continue; diff --git a/src/xrpld/app/rdb/backend/detail/Node.cpp b/src/xrpld/app/rdb/backend/detail/Node.cpp index b2f14c71ea..ff57087ec5 100644 --- a/src/xrpld/app/rdb/backend/detail/Node.cpp +++ b/src/xrpld/app/rdb/backend/detail/Node.cpp @@ -40,7 +40,6 @@ #include #include -#include #include #include // IWYU pragma: keep #include @@ -58,6 +57,7 @@ #include #include #include +#include #include #include #include @@ -66,6 +66,7 @@ #include #include #include +#include #include #include #include @@ -1393,8 +1394,8 @@ getTransaction( bool dbHasSpace(soci::session& session, Config const& config, beast::Journal j) { - boost::filesystem::space_info const space = - boost::filesystem::space(config.legacy(Sections::kDatabasePath)); + std::filesystem::space_info const space = + std::filesystem::space(config.legacy(Sections::kDatabasePath)); if (space.available < megabytes(512)) { @@ -1405,9 +1406,9 @@ dbHasSpace(soci::session& session, Config const& config, beast::Journal j) if (config.useTxTables()) { DatabaseCon::Setup const dbSetup = setupDatabaseCon(config); - boost::filesystem::path const dbPath = dbSetup.dataDir / kTxDbName; - boost::system::error_code ec; - std::optional dbSize = boost::filesystem::file_size(dbPath, ec); + std::filesystem::path const dbPath = dbSetup.dataDir / kTxDbName; + std::error_code ec; + std::optional dbSize = std::filesystem::file_size(dbPath, ec); if (ec) { JLOG(j.error()) << "Error checking transaction db file size: " << ec.message(); diff --git a/src/xrpld/core/Config.h b/src/xrpld/core/Config.h index ac28b6e224..2dea8f3597 100644 --- a/src/xrpld/core/Config.h +++ b/src/xrpld/core/Config.h @@ -11,11 +11,10 @@ #include #include -#include // VFALCO FIX: This include should not be here - #include #include #include +#include #include #include #include @@ -97,17 +96,17 @@ public: /** * Returns the full path and filename of the debug log file. */ - [[nodiscard]] boost::filesystem::path + [[nodiscard]] std::filesystem::path getDebugLogFile() const; private: - boost::filesystem::path configFile_; + std::filesystem::path configFile_; public: - boost::filesystem::path configDir; + std::filesystem::path configDir; private: - boost::filesystem::path debugLogfile_; + std::filesystem::path debugLogfile_; void load(); diff --git a/src/xrpld/core/detail/Config.cpp b/src/xrpld/core/detail/Config.cpp index f263fb49ab..efe4ab1cc9 100644 --- a/src/xrpld/core/detail/Config.cpp +++ b/src/xrpld/core/detail/Config.cpp @@ -20,21 +20,20 @@ #include #include #include -#include -#include +#include #include #include #include #include // IWYU pragma: keep #include #include -#include #include #include #include #include #include +#include #include #include #include @@ -44,6 +43,7 @@ #include #include #include +#include #include #include #include @@ -312,13 +312,13 @@ Config::setup(std::string const& strConf, bool bQuiet, bool bSilent, bool bStand // directory, use the current working directory as the // config directory and that with "db" as the data // directory. - boost::filesystem::path dataDir; + std::filesystem::path dataDir; if (!strConf.empty()) { // --conf= : everything is relative that file. configFile_ = strConf; - configDir = boost::filesystem::absolute(configFile_); + configDir = std::filesystem::absolute(configFile_); configDir.remove_filename(); dataDir = configDir / kDatabaseDirName; } @@ -329,13 +329,13 @@ Config::setup(std::string const& strConf, bool bQuiet, bool bSilent, bool bStand // Check if either of the config files exist in the current working // directory, in which case the databases will be stored in a // subdirectory. - configDir = boost::filesystem::current_path(); + configDir = std::filesystem::current_path(); dataDir = configDir / kDatabaseDirName; configFile_ = configDir / kConfigFileName; - if (boost::filesystem::exists(configFile_)) + if (std::filesystem::exists(configFile_)) break; configFile_ = configDir / kConfigLegacyName; - if (boost::filesystem::exists(configFile_)) + if (std::filesystem::exists(configFile_)) break; // Check if the home directory is set, and optionally the XDG config @@ -362,10 +362,10 @@ Config::setup(std::string const& strConf, bool bQuiet, bool bSilent, bool bStand dataDir = strXdgDataHome + "/" + systemName(); configDir = strXdgConfigHome + "/" + systemName(); configFile_ = configDir / kConfigFileName; - if (boost::filesystem::exists(configFile_)) + if (std::filesystem::exists(configFile_)) break; configFile_ = configDir / kConfigLegacyName; - if (boost::filesystem::exists(configFile_)) + if (std::filesystem::exists(configFile_)) break; } @@ -373,7 +373,7 @@ Config::setup(std::string const& strConf, bool bQuiet, bool bSilent, bool bStand dataDir = "/var/lib/" + systemName(); configDir = "/etc/" + systemName(); configFile_ = configDir / kConfigFileName; - if (boost::filesystem::exists(configFile_)) + if (std::filesystem::exists(configFile_)) break; configFile_ = configDir / kConfigLegacyName; } while (false); @@ -386,7 +386,7 @@ Config::setup(std::string const& strConf, bool bQuiet, bool bSilent, bool bStand std::string const dbPath(legacy(Sections::kDatabasePath)); if (!dbPath.empty()) { - dataDir = boost::filesystem::path(dbPath); + dataDir = std::filesystem::path(dbPath); } else if (runStandalone_) { @@ -396,13 +396,13 @@ Config::setup(std::string const& strConf, bool bQuiet, bool bSilent, bool bStand if (!dataDir.empty()) { - boost::system::error_code ec; - boost::filesystem::create_directories(dataDir, ec); + std::error_code ec; + std::filesystem::create_directories(dataDir, ec); if (ec) Throw(boost::str(boost::format("Can not create %s") % dataDir)); - legacy(Sections::kDatabasePath, boost::filesystem::absolute(dataDir).string()); + legacy(Sections::kDatabasePath, std::filesystem::absolute(dataDir).string()); } HTTPClient::initializeSSLContext(this->sslVerifyDir, this->sslVerifyFile, this->sslVerify, j_); @@ -454,7 +454,7 @@ Config::load() if (!quiet_) std::cerr << "Loading: " << configFile_ << "\n"; - boost::system::error_code ec; + std::error_code ec; auto const fileContents = getFileContents(ec, configFile_); if (ec) @@ -507,8 +507,8 @@ Config::loadFromString(std::string const& fileContents) std::string dbPath; if (getSingleSection(secConfig, Sections::kDatabasePath, dbPath, j_)) { - boost::filesystem::path const p(dbPath); - legacy(Sections::kDatabasePath, boost::filesystem::absolute(p).string()); + std::filesystem::path const p(dbPath); + legacy(Sections::kDatabasePath, std::filesystem::absolute(p).string()); } } @@ -1010,7 +1010,7 @@ Config::loadFromString(std::string const& fileContents) // If no path was specified, then look for validators.txt // in the same directory as the config file, but don't complain // if we can't find it. - boost::filesystem::path validatorsFile; + std::filesystem::path validatorsFile; if (getSingleSection(secConfig, Sections::kValidatorsFile, strTemp, j_)) { @@ -1025,7 +1025,7 @@ Config::loadFromString(std::string const& fileContents) if (!validatorsFile.is_absolute() && !configDir.empty()) validatorsFile = configDir / validatorsFile; - if (!boost::filesystem::exists(validatorsFile)) + if (!std::filesystem::exists(validatorsFile)) { Throw( std::string("The file specified in [") + Sections::kValidatorsFile + @@ -1034,8 +1034,8 @@ Config::loadFromString(std::string const& fileContents) validatorsFile.string()); } else if ( - !boost::filesystem::is_regular_file(validatorsFile) && - !boost::filesystem::is_symlink(validatorsFile)) + !std::filesystem::is_regular_file(validatorsFile) && + !std::filesystem::is_symlink(validatorsFile)) { Throw( std::string("Invalid file specified in [") + Sections::kValidatorsFile + @@ -1048,20 +1048,20 @@ Config::loadFromString(std::string const& fileContents) if (!validatorsFile.empty()) { - if (!boost::filesystem::exists(validatorsFile) || - (!boost::filesystem::is_regular_file(validatorsFile) && - !boost::filesystem::is_symlink(validatorsFile))) + if (!std::filesystem::exists(validatorsFile) || + (!std::filesystem::is_regular_file(validatorsFile) && + !std::filesystem::is_symlink(validatorsFile))) { validatorsFile.clear(); } } } - if (!validatorsFile.empty() && boost::filesystem::exists(validatorsFile) && - (boost::filesystem::is_regular_file(validatorsFile) || - boost::filesystem::is_symlink(validatorsFile))) + if (!validatorsFile.empty() && std::filesystem::exists(validatorsFile) && + (std::filesystem::is_regular_file(validatorsFile) || + std::filesystem::is_symlink(validatorsFile))) { - boost::system::error_code ec; + std::error_code ec; auto const data = getFileContents(ec, validatorsFile); if (ec) { @@ -1194,7 +1194,7 @@ Config::loadFromString(std::string const& fileContents) } } -boost::filesystem::path +std::filesystem::path Config::getDebugLogFile() const { auto logFile = debugLogfile_; @@ -1203,17 +1203,17 @@ Config::getDebugLogFile() const { // Unless an absolute path for the log file is specified, the // path is relative to the config file directory. - logFile = boost::filesystem::absolute(logFile, configDir); + logFile = std::filesystem::absolute(configDir / logFile); } if (!logFile.empty()) { auto logDir = logFile.parent_path(); - if (!boost::filesystem::is_directory(logDir)) + if (!std::filesystem::is_directory(logDir)) { - boost::system::error_code ec; - boost::filesystem::create_directories(logDir, ec); + std::error_code ec; + std::filesystem::create_directories(logDir, ec); // If we fail, we warn but continue so that the calling code can // decide how to handle this situation. diff --git a/src/xrpld/perflog/detail/PerfLogImp.cpp b/src/xrpld/perflog/detail/PerfLogImp.cpp index 3aa7e38ea2..2777e0dcdb 100644 --- a/src/xrpld/perflog/detail/PerfLogImp.cpp +++ b/src/xrpld/perflog/detail/PerfLogImp.cpp @@ -17,11 +17,9 @@ #include #include -#include -#include - #include #include +#include #include #include #include @@ -29,6 +27,7 @@ #include #include #include +#include #include #include #include @@ -220,10 +219,10 @@ PerfLogImp::openLog() logFile_.close(); auto logDir = setup_.perfLog.parent_path(); - if (!boost::filesystem::is_directory(logDir)) + if (!std::filesystem::is_directory(logDir)) { - boost::system::error_code ec; - boost::filesystem::create_directories(logDir, ec); + std::error_code ec; + std::filesystem::create_directories(logDir, ec); if (ec) { JLOG(j_.fatal()) << "Unable to create performance log " @@ -478,17 +477,17 @@ PerfLogImp::stop() //----------------------------------------------------------------------------- PerfLog::Setup -setupPerfLog(Section const& section, boost::filesystem::path const& configDir) +setupPerfLog(Section const& section, std::filesystem::path const& configDir) { PerfLog::Setup setup; std::string perfLog; set(perfLog, "perf_log", section); if (!perfLog.empty()) { - setup.perfLog = boost::filesystem::path(perfLog); + setup.perfLog = std::filesystem::path(perfLog); if (setup.perfLog.is_relative()) { - setup.perfLog = boost::filesystem::absolute(setup.perfLog, configDir); + setup.perfLog = std::filesystem::absolute(configDir / setup.perfLog); } } From 1281c7a222f34eeded150323d45061284df76077 Mon Sep 17 00:00:00 2001 From: Timur Yalymov <36795566+tyalymov@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:41:30 +0000 Subject: [PATCH 070/102] refactor: Drop unnecessary associateAsset calls from loan delete paths (#7986) Co-authored-by: Cursor --- src/libxrpl/tx/transactors/lending/LoanBrokerDelete.cpp | 3 --- src/libxrpl/tx/transactors/lending/LoanDelete.cpp | 3 --- 2 files changed, 6 deletions(-) diff --git a/src/libxrpl/tx/transactors/lending/LoanBrokerDelete.cpp b/src/libxrpl/tx/transactors/lending/LoanBrokerDelete.cpp index b36977d225..433d77806a 100644 --- a/src/libxrpl/tx/transactors/lending/LoanBrokerDelete.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanBrokerDelete.cpp @@ -12,7 +12,6 @@ #include #include #include -#include #include #include #include @@ -198,8 +197,6 @@ LoanBrokerDelete::doApply() view().erase(broker); - associateAsset(*broker, vaultAsset); - return tesSUCCESS; } diff --git a/src/libxrpl/tx/transactors/lending/LoanDelete.cpp b/src/libxrpl/tx/transactors/lending/LoanDelete.cpp index 1a77489b4b..bc8e974d10 100644 --- a/src/libxrpl/tx/transactors/lending/LoanDelete.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanDelete.cpp @@ -130,9 +130,6 @@ LoanDelete::doApply() // Decrement the borrower's owner count decreaseOwnerCountForObject(view, borrowerSle, loanSle, 1, j_); - // These associations shouldn't do anything, but do them just to be safe - associateAsset(*loanSle, vaultAsset); - associateAsset(*brokerSle, vaultAsset); associateAsset(*vaultSle, vaultAsset); return tesSUCCESS; From af36890c1113955894dc441721e928ab3072434a Mon Sep 17 00:00:00 2001 From: Timur Yalymov <36795566+tyalymov@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:42:02 +0000 Subject: [PATCH 071/102] test: Verify private-vault DEX permissions survive domain loss (#7937) --- src/test/app/Vault_test.cpp | 188 ++++++++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) diff --git a/src/test/app/Vault_test.cpp b/src/test/app/Vault_test.cpp index 70527f570d..6b6c4eb875 100644 --- a/src/test/app/Vault_test.cpp +++ b/src/test/app/Vault_test.cpp @@ -3017,6 +3017,192 @@ class Vault_test : public beast::unit_test::Suite } } + void + testDomainLossAfterAcquisition() + { + using namespace test::jtx; + + testcase("private vault share transfer after depositor loses domain"); + + // The "Private Vault - Access Control Rules" spec requires that a holder who + // loses Layer 2 (Permissioned Domain membership) after acquiring shares be + // blocked from sending them onward, by P2P transfer or DEX offer, the same + // way a brand-new never-authorized holder is blocked. Only withdrawal to + // self is meant to stay open. + // + // For a domain-gated share MPToken, requireAuth()'s escape hatch for + // holders who already have an MPToken (MPTokenHelpers.cpp) only applies to + // the classic explicit-issuer-authorization flag, which + // enforceMPTokenAuthorization documents as "meaningless" for + // domain-authorized holders and never sets. So a stale MPToken does not + // carry authorization forward once the account's domain credential is + // gone, and both actions below are correctly blocked. + + Env env{*this, testableAmendments()}; + Account const issuer{"issuer"}; + Account const owner{"owner"}; + Account const depositor{"depositor"}; + Account const bob{"bob"}; + Account const pdOwner{"pdOwner"}; + Account const credIssuer{"credIssuer"}; + std::string const credType = "credential"; + Vault const vault{env}; + env.fund(XRP(1000), issuer, owner, depositor, bob, pdOwner, credIssuer); + env.close(); + + PrettyAsset const asset = issuer["IOU"]; + env.trust(asset(1000), owner); + env(pay(issuer, owner, asset(500))); + env.trust(asset(1000), depositor); + env(pay(issuer, depositor, asset(500))); + env.trust(asset(1000), bob); + env(pay(issuer, bob, asset(500))); + env.close(); + + // Transferable shares (no tfVaultShareNonTransferable): sections 3.3/3.4 of + // the spec (DEX trading / P2P transfer) only apply to transferable shares. + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset, .flags = tfVaultPrivate}); + env(tx); + env.close(); + + pdomain::Credentials const credentials{{.issuer = credIssuer, .credType = credType}}; + env(pdomain::setTx(pdOwner, credentials)); + auto const domainId = [&]() { + auto tx = env.tx()->getJson(JsonOptions::Values::None); + return pdomain::getNewDomain(env.meta()); + }(); + { + auto domainTx = vault.set({.owner = owner, .id = keylet.key}); + domainTx[sfDomainID] = to_string(domainId); + env(domainTx); + env.close(); + } + + // Both depositor and bob acquire domain membership and deposit, so each + // ends up with an authorized share MPToken. + env(credentials::create(depositor, credIssuer, credType)); + env(credentials::accept(depositor, credIssuer, credType)); + env(credentials::create(bob, credIssuer, credType)); + env(credentials::accept(bob, credIssuer, credType)); + env.close(); + + env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)})); + env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = asset(100)})); + env.close(); + + auto const shares = [&env, keylet = keylet, this]() -> PrettyAsset { + auto const sle = env.le(keylet); + BEAST_EXPECT(sle != nullptr); + return MPTIssue(sle->at(sfShareMPTID)); + }(); + + // Depositor loses Layer 2: their Permissioned Domain credential is revoked. + auto const credKeylet = credentials::keylet(depositor, credIssuer, credType); + env(credentials::deleteCred(credIssuer, depositor, credIssuer, credType)); + env.close(); + BEAST_EXPECT(env.le(credKeylet) == nullptr); + + // Sanity check, mirrors testWithDomainCheck's "not authorized yet" case: a + // brand-new depositor with no MPToken yet is still correctly blocked. The + // gap below is specific to holders who already hold shares. + { + Account const charlie{"charlie"}; + env.fund(XRP(1000), charlie); + env.close(); + auto depTx = + vault.deposit({.depositor = charlie, .id = keylet.key, .amount = asset(1)}); + env(depTx, Ter{tecNO_AUTH}); + } + + // P2P transfer: spec section 3.4 requires this blocked once Layer 2 is + // lost, and it is. + env(pay(depositor, bob, shares(1)), Ter{tecNO_AUTH}); + env.close(); + + // DEX/CLOB: spec section 3.3 requires the seller leg blocked the same way. + // The offer can't even be created: preclaim treats the seller as + // unfunded once their share balance reads as zero for auth purposes. + env(offer(depositor, XRP(1), shares(1)), Ter{tecUNFUNDED_OFFER}); + env.close(); + BEAST_EXPECT(expectOffers(env, depositor, 0)); + } + + void + testDomainCheckBuyerSideOffer() + { + using namespace test::jtx; + + testcase("private vault share purchase via DEX requires buyer domain membership"); + + // The "Private Vault - Access Control Rules" spec requires the buyer leg + // of a DEX trade in private-vault shares to hold Layer 1 and Layer 2 as + // well, not just the seller. + + Env env{*this, testableAmendments()}; + Account const issuer{"issuer"}; + Account const owner{"owner"}; + Account const bob{"bob"}; + Account const charlie{"charlie"}; + Account const pdOwner{"pdOwner"}; + Account const credIssuer{"credIssuer"}; + std::string const credType = "credential"; + Vault const vault{env}; + env.fund(XRP(1000), issuer, owner, bob, charlie, pdOwner, credIssuer); + env.close(); + + PrettyAsset const asset = issuer["IOU"]; + env.trust(asset(1000), owner); + env(pay(issuer, owner, asset(500))); + env.trust(asset(1000), bob); + env(pay(issuer, bob, asset(500))); + env.close(); + + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset, .flags = tfVaultPrivate}); + env(tx); + env.close(); + + pdomain::Credentials const credentials{{.issuer = credIssuer, .credType = credType}}; + env(pdomain::setTx(pdOwner, credentials)); + auto const domainId = [&]() { + auto tx = env.tx()->getJson(JsonOptions::Values::None); + return pdomain::getNewDomain(env.meta()); + }(); + { + auto domainTx = vault.set({.owner = owner, .id = keylet.key}); + domainTx[sfDomainID] = to_string(domainId); + env(domainTx); + env.close(); + } + + // Only bob joins the domain and deposits; charlie never does. + env(credentials::create(bob, credIssuer, credType)); + env(credentials::accept(bob, credIssuer, credType)); + env.close(); + env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = asset(100)})); + env.close(); + + auto const shares = [&env, keylet = keylet, this]() -> PrettyAsset { + auto const sle = env.le(keylet); + BEAST_EXPECT(sle != nullptr); + return MPTIssue(sle->at(sfShareMPTID)); + }(); + + // Bob (domain member, holds shares) rests a sell offer. + env(offer(bob, XRP(1), shares(1))); + env.close(); + BEAST_EXPECT(expectOffers(env, bob, 1)); + + // Charlie never held the domain credential. Buying shares via a + // crossing offer must be blocked the same way a direct MPTokenAuthorize + // + pay attempt already is (see testWithDomainChecXRP's "cannot pay + // shares to 3rd party"): checkAcceptAsset() rejects the offer outright + // in preclaim, before any funding check is even reached. + env(offer(charlie, shares(1), XRP(1)), Ter{tecNO_AUTH}); + env.close(); + BEAST_EXPECT(expectOffers(env, bob, 1)); + BEAST_EXPECT(expectOffers(env, charlie, 0)); + } + void testWithDomainChecXRP() { @@ -8396,6 +8582,8 @@ public: testWithMPT(); testWithIOU(); testWithDomainCheck(); + testDomainLossAfterAcquisition(); + testDomainCheckBuyerSideOffer(); testWithDomainChecXRP(); testNonTransferableShares(); testFailedPseudoAccount(); From 91360c5126ef4456dbca860d7a69f9e75b546c0e Mon Sep 17 00:00:00 2001 From: Vito Tumas <5780819+Tapanito@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:42:55 +0000 Subject: [PATCH 072/102] test: Fix LoanBatch broker cover rates and schedule overflow (#7967) --- src/test/app/lending/LoanMisc_test.cpp | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/test/app/lending/LoanMisc_test.cpp b/src/test/app/lending/LoanMisc_test.cpp index 2cb4f38ecf..c5a7d54311 100644 --- a/src/test/app/lending/LoanMisc_test.cpp +++ b/src/test/app/lending/LoanMisc_test.cpp @@ -473,14 +473,21 @@ protected: TenthBips16 const managementFeeRate{managementFeeRateDist_(engine_)}; auto const serviceFee = serviceFeeDist_(engine_); TenthBips32 interest{interestRateDist_(engine_)}; - auto const payTotal = paymentTotalDist_(engine_); + auto payTotal = paymentTotalDist_(engine_); auto const payInterval = paymentIntervalDist_(engine_); + // The end of the last payment's grace period must fit in a 32-bit + // ripple-epoch timestamp, or LoanSet fails with tecKILLED. Cap the + // schedule well below that horizon (2e9 seconds is roughly 63 years, + // leaving ample headroom over the ledger start date). + constexpr std::uint32_t kMaxScheduleSeconds = 2'000'000'000; + payTotal = std::min(payTotal, static_cast(kMaxScheduleSeconds / payInterval)); BrokerParameters const brokerParams{ .vaultDeposit = principalRequest * 10, .debtMax = 0, .coverRateMin = TenthBips32{0}, - .managementFeeRate = managementFeeRate}; + .managementFeeRate = managementFeeRate, + .coverRateLiquidation = TenthBips32{0}}; LoanParameters const loanParams{ .account = lender, .counter = borrower, From 946827b9bd554eab36645c0bccf65bc46a22a986 Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Wed, 12 Aug 2026 14:03:37 +0000 Subject: [PATCH 073/102] build: Respect lld linker if it gets auto-selected (#8011) --- cmake/XrplCompiler.cmake | 66 ++++++++++++++++++++++++---------------- 1 file changed, 40 insertions(+), 26 deletions(-) diff --git a/cmake/XrplCompiler.cmake b/cmake/XrplCompiler.cmake index 21566add01..2b46739d97 100644 --- a/cmake/XrplCompiler.cmake +++ b/cmake/XrplCompiler.cmake @@ -188,32 +188,6 @@ else() endif() endif() -# Linker warnings are errors where we control the toolchain and the dependencies: CI and the Nix dev shell. -# On non-Nix macOS we suppress the deployment target warning: an old Conan profile may not pin os.version. -if(is_macos OR is_linux) - if(is_ci OR is_nix_compiler) - if(is_macos) - set(fatal_warnings_flag "-Wl,-fatal_warnings") - else() - set(fatal_warnings_flag "-Wl,--fatal-warnings") - endif() - message( - STATUS - "Treating all linker warnings as errors (${fatal_warnings_flag})" - ) - target_link_options(common INTERFACE "${fatal_warnings_flag}") - unset(fatal_warnings_flag) - elseif(is_macos) - set(silence_flag "-Wl,-deployment_target_mismatches,suppress") - message( - STATUS - "Silencing macOS deployment target mismatch warnings (${silence_flag})" - ) - target_link_options(common INTERFACE "${silence_flag}") - unset(silence_flag) - endif() -endif() - # Antithesis instrumentation will only be built and deployed using machines running Linux. if(voidstar) if(NOT CMAKE_BUILD_TYPE STREQUAL "Debug") @@ -292,10 +266,50 @@ elseif(use_lld) ) if("${LD_VERSION}" MATCHES "LLD") target_link_libraries(common INTERFACE -fuse-ld=lld) + # remembered for the linker flag probe below + set(fuse_ld_flag "-fuse-ld=lld") endif() unset(LD_VERSION) endif() +# Linker warnings are errors where we control the toolchain and the dependencies: CI and the Nix dev shell. +# On non-Nix macOS we suppress the deployment target warning: an old Conan profile may not pin os.version. +# Only the new Apple linker understands the flag, so probe the actual linker (lld may be selected above). +if(is_macos OR is_linux) + if(is_ci OR is_nix_compiler) + if(is_macos) + set(fatal_warnings_flag "-Wl,-fatal_warnings") + else() + set(fatal_warnings_flag "-Wl,--fatal-warnings") + endif() + message( + STATUS + "Treating all linker warnings as errors (${fatal_warnings_flag})" + ) + target_link_options(common INTERFACE "${fatal_warnings_flag}") + unset(fatal_warnings_flag) + elseif(is_macos) + set(silence_flag "-Wl,-deployment_target_mismatches,suppress") + set(probe_flags ${fuse_ld_flag} "${silence_flag}") + include(CheckLinkerFlag) + check_linker_flag( + CXX + "${probe_flags}" + have_deployment_target_mismatches + ) + if(have_deployment_target_mismatches) + message( + STATUS + "Silencing macOS deployment target mismatch warnings (${silence_flag})" + ) + target_link_options(common INTERFACE "${silence_flag}") + endif() + unset(probe_flags) + unset(silence_flag) + endif() +endif() +unset(fuse_ld_flag) + if(assert) foreach(var_ CMAKE_C_FLAGS_RELEASE CMAKE_CXX_FLAGS_RELEASE) string(REGEX REPLACE "[-/]DNDEBUG" "" ${var_} "${${var_}}") From 8e9b1791c5eed272e28232b953fda6ac9500a2b3 Mon Sep 17 00:00:00 2001 From: Jingchen Date: Wed, 12 Aug 2026 17:07:43 +0000 Subject: [PATCH 074/102] feat: Add a new closed ended vault to extend SAV (#7921) Co-authored-by: Vito Tumas <5780819+Tapanito@users.noreply.github.com> --- include/xrpl/ledger/View.h | 12 +- include/xrpl/ledger/helpers/VaultHelpers.h | 81 ++ include/xrpl/protocol/Protocol.h | 31 + .../xrpl/protocol/detail/ledger_entries.macro | 3 + include/xrpl/protocol/detail/sfields.macro | 3 + .../xrpl/protocol/detail/transactions.macro | 3 + .../protocol_autogen/ledger_entries/Vault.h | 105 ++ .../transactions/VaultCreate.h | 111 ++ include/xrpl/tx/invariants/LoanInvariant.h | 2 + include/xrpl/tx/invariants/VaultInvariant.h | 24 + src/libxrpl/ledger/View.cpp | 12 +- src/libxrpl/ledger/helpers/VaultHelpers.cpp | 72 ++ src/libxrpl/tx/invariants/InvariantCheck.cpp | 60 +- src/libxrpl/tx/invariants/LoanInvariant.cpp | 36 +- src/libxrpl/tx/invariants/VaultInvariant.cpp | 104 ++ .../tx/transactors/lending/LoanSet.cpp | 32 +- .../tx/transactors/vault/VaultCreate.cpp | 42 + .../tx/transactors/vault/VaultDeposit.cpp | 12 + .../tx/transactors/vault/VaultWithdraw.cpp | 10 + src/test/app/Invariants_test.cpp | 358 +++++- src/test/app/Vault_test.cpp | 1112 +++++++++++++++++ src/test/app/lending/LoanSet_test.cpp | 126 ++ src/test/app/lending/LoanTestBase.h | 56 +- src/test/app/lending/LoanValidation_test.cpp | 13 +- src/test/jtx/impl/vault.cpp | 6 + src/test/jtx/vault.h | 6 + .../ledger_entries/VaultTests.cpp | 81 ++ .../transactions/VaultCreateTests.cpp | 63 + 28 files changed, 2521 insertions(+), 55 deletions(-) diff --git a/include/xrpl/ledger/View.h b/include/xrpl/ledger/View.h index 768e518008..e8b4a932d0 100644 --- a/include/xrpl/ledger/View.h +++ b/include/xrpl/ledger/View.h @@ -35,6 +35,11 @@ enum class SkipEntry : bool { No = false, Yes }; // //------------------------------------------------------------------------------ +/** + * Whether an expiration check should be inclusive or exclusive. + */ +enum class ExpiryComparison { Inclusive, Exclusive }; + /** * Determines whether the given expiration time has passed. * @@ -54,11 +59,16 @@ enum class SkipEntry : bool { No = false, Yes }; * * @param view The ledger whose parent time is used as the clock. * @param exp The optional expiration time we want to check. + * @param comparison Whether the boundary is inclusive (`now >= exp`, the + * default) or exclusive (`now > exp`). * * @return `true` if `exp` is in the past; `false` otherwise. */ [[nodiscard]] bool -hasExpired(ReadView const& view, std::optional const& exp); +hasExpired( + ReadView const& view, + std::optional const& exp, + ExpiryComparison comparison = ExpiryComparison::Inclusive); // Note, depth parameter is used to limit the recursion depth [[nodiscard]] bool diff --git a/include/xrpl/ledger/helpers/VaultHelpers.h b/include/xrpl/ledger/helpers/VaultHelpers.h index 5681cc57e8..acbf2c3ac0 100644 --- a/include/xrpl/ledger/helpers/VaultHelpers.h +++ b/include/xrpl/ledger/helpers/VaultHelpers.h @@ -6,10 +6,13 @@ #include #include +#include #include namespace xrpl { +class STTx; + /** * From the perspective of a vault, return the number of shares to give * depositor when they offer a fixed amount of assets. Note, since shares are @@ -123,4 +126,82 @@ isSoleShareholder(ReadView const& view, AccountID const& account, SLE::const_ref [[nodiscard]] VaultVersion getVaultVersion(SLE::const_ref vault); +/** + * Resolves the VaultKind of a vault SLE. Returns VaultKind::ClosedEnded when + * sfVaultKind is present and equal to that value; anything else (including an + * absent field or an unrecognised value) is treated as VaultKind::OpenEnded. + * + * @param vault The vault SLE. + */ +[[nodiscard]] VaultKind +getVaultKind(SLE::const_ref vault); + +/** + * Reads sfVaultKind from a transaction. An absent field resolves to + * VaultKind::OpenEnded (matching the on-ledger default); any unrecognised + * value is also treated as VaultKind::OpenEnded, mirroring the SLE overload. + * Callers that need to reject out-of-range values (e.g. preflight) should + * gate on isValidVaultKind() first. + * + * @param tx The transaction. + */ +[[nodiscard]] VaultKind +getVaultKind(STTx const& tx); + +/** + * Returns true iff sfVaultKind is either absent from @p tx or is present and + * equal to a recognised VaultKind enumerator. Intended for use in preflight + * to reject malformed transactions before decoding with getVaultKind(). + * + * @param tx The transaction. + */ +[[nodiscard]] bool +isValidVaultKind(STTx const& tx); + +/** + * Returns true iff the (SubscriptionDate, RedemptionDate) gap of a + * closed-ended vault satisfies + * kMinInvestmentPeriod <= (red - sub) < kMaxInvestmentPeriod. The arithmetic + * is performed in std::int64_t so that @p sub near UINT32_MAX does not + * overflow. Shared by VaultCreate::preflight and the ValidVault invariant. + * + * @param sub The value of sfSubscriptionDate. + * @param red The value of sfRedemptionDate. + */ +[[nodiscard]] bool +isValidClosedEndedGap(std::uint32_t sub, std::uint32_t red); + +/** + * Returns the current lifecycle phase of a vault. Open-ended + * vaults are always NoPhase. For closed-ended vaults the phase is derived + * from the parent ledger close time and the vault's immutable + * SubscriptionDate and RedemptionDate. + * + * @param view The ledger view whose parent close time is used as the clock. + * @param vault The vault SLE. + */ +[[nodiscard]] VaultPhase +getVaultPhase(ReadView const& view, SLE::const_ref vault); + +/** + * Raw-fields overload of getVaultPhase. Derives the phase from an already + * decomposed vault snapshot: an absent or non-ClosedEnded @p vaultKind + * resolves to VaultPhase::NoPhase; otherwise the phase is computed from + * @p subscriptionDate and @p redemptionDate against the view's parent + * close time using the same boundary semantics as the SLE overload + * (Subscription is inclusive of now == SubscriptionDate; Investment starts + * strictly after). + * + * @param view The ledger view whose parent close time is used as the clock. + * @param vaultKind The value of sfVaultKind, or nullopt if absent. + * @param subscriptionDate The value of sfSubscriptionDate, or nullopt if absent. + * @param redemptionDate The value of sfRedemptionDate, or nullopt if absent. + */ +[[nodiscard]] VaultPhase +getVaultPhase( + ReadView const& view, + std::optional vaultKind, + std::optional subscriptionDate, + std::optional redemptionDate); + } // namespace xrpl diff --git a/include/xrpl/protocol/Protocol.h b/include/xrpl/protocol/Protocol.h index 567f66d339..345baef853 100644 --- a/include/xrpl/protocol/Protocol.h +++ b/include/xrpl/protocol/Protocol.h @@ -9,6 +9,7 @@ #include #include +#include #include #include @@ -327,6 +328,36 @@ enum class VaultVersion : uint8_t { CashBasis, }; +/** + * Vault kind. Distinguishes closed-ended vaults from the default open-ended + * kind. Persisted as sfVaultKind (UINT8); absent means OpenEnded. + */ +enum class VaultKind : std::uint8_t { + OpenEnded = 0, + ClosedEnded = 1, +}; + +/** + * Lifecycle phase of a vault. Open-ended vaults are always NoPhase; the other + * three values are the phases of a closed-ended vault. + */ +enum class VaultPhase : std::uint8_t { + NoPhase = 0, + Subscription, + Investment, + Redemption, +}; + +/** + * Bounds on the length of a closed-ended vault's Investment phase + * (RedemptionDate - SubscriptionDate). At vault creation the gap must satisfy + * kMinInvestmentPeriod <= gap < kMaxInvestmentPeriod. + */ +constexpr std::uint32_t kMinInvestmentPeriod = + std::chrono::seconds{std::chrono::minutes{1}}.count(); +// This is 946708560 seconds which 30 x 365.2425 days (the average length of a Gregorian year). +constexpr std::uint32_t kMaxInvestmentPeriod = std::chrono::seconds{std::chrono::years{30}}.count(); + /** * Maximum recursion depth for vault shares being put as an asset inside * another vault; counted from 0 diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index ffcd025f01..f166473d7f 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -506,6 +506,9 @@ LEDGER_ENTRY(ltVAULT, 0x0084, Vault, vault, ({ {sfWithdrawalPolicy, SoeRequired}, {sfScale, SoeDefault}, {sfLEVersion, SoeDefault}, + {sfVaultKind, SoeDefault}, + {sfSubscriptionDate, SoeOptional}, + {sfRedemptionDate, SoeOptional}, // no SharesTotal ever (use MPTIssuance.sfOutstandingAmount) // no PermissionedDomainID ever (use MPTIssuance.sfDomainID) })) diff --git a/include/xrpl/protocol/detail/sfields.macro b/include/xrpl/protocol/detail/sfields.macro index c323e3a496..ec05804253 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -27,6 +27,7 @@ TYPED_SFIELD(sfUNLModifyDisabling, UINT8, 17) TYPED_SFIELD(sfWasLockingChainSend, UINT8, 19) TYPED_SFIELD(sfWithdrawalPolicy, UINT8, 20) TYPED_SFIELD(sfContractResult, UINT8, 21) +TYPED_SFIELD(sfVaultKind, UINT8, 22) // 16-bit integers (common) TYPED_SFIELD(sfLedgerEntryType, UINT16, 1, SField::kSmdNever) @@ -116,6 +117,8 @@ TYPED_SFIELD(sfSponsoringOwnerCount, UINT32, 71) TYPED_SFIELD(sfSponsoringAccountCount, UINT32, 72) TYPED_SFIELD(sfRemainingOwnerCount, UINT32, 73) TYPED_SFIELD(sfSponsorFlags, UINT32, 74) +TYPED_SFIELD(sfSubscriptionDate, UINT32, 75) +TYPED_SFIELD(sfRedemptionDate, UINT32, 76) // 64-bit integers (common) TYPED_SFIELD(sfIndexNext, UINT64, 1) diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index 1f9603dbae..f8676d3b63 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -862,6 +862,9 @@ TRANSACTION(ttVAULT_CREATE, 65, VaultCreate, {sfWithdrawalPolicy, SoeOptional}, {sfData, SoeOptional}, {sfScale, SoeOptional}, + {sfVaultKind, SoeOptional}, + {sfSubscriptionDate, SoeOptional}, + {sfRedemptionDate, SoeOptional}, })) /** This transaction updates a single asset vault. */ diff --git a/include/xrpl/protocol_autogen/ledger_entries/Vault.h b/include/xrpl/protocol_autogen/ledger_entries/Vault.h index a6ab54cb0a..389ffb4c46 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/Vault.h +++ b/include/xrpl/protocol_autogen/ledger_entries/Vault.h @@ -311,6 +311,78 @@ public: { return this->sle_->isFieldPresent(sfLEVersion); } + + /** + * @brief Get sfVaultKind (SoeDefault) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getVaultKind() const + { + if (hasVaultKind()) + return this->sle_->at(sfVaultKind); + return std::nullopt; + } + + /** + * @brief Check if sfVaultKind is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasVaultKind() const + { + return this->sle_->isFieldPresent(sfVaultKind); + } + + /** + * @brief Get sfSubscriptionDate (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getSubscriptionDate() const + { + if (hasSubscriptionDate()) + return this->sle_->at(sfSubscriptionDate); + return std::nullopt; + } + + /** + * @brief Check if sfSubscriptionDate is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasSubscriptionDate() const + { + return this->sle_->isFieldPresent(sfSubscriptionDate); + } + + /** + * @brief Get sfRedemptionDate (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getRedemptionDate() const + { + if (hasRedemptionDate()) + return this->sle_->at(sfRedemptionDate); + return std::nullopt; + } + + /** + * @brief Check if sfRedemptionDate is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasRedemptionDate() const + { + return this->sle_->isFieldPresent(sfRedemptionDate); + } }; /** @@ -543,6 +615,39 @@ public: return *this; } + /** + * @brief Set sfVaultKind (SoeDefault) + * @return Reference to this builder for method chaining. + */ + VaultBuilder& + setVaultKind(std::decay_t const& value) + { + object_[sfVaultKind] = value; + return *this; + } + + /** + * @brief Set sfSubscriptionDate (SoeOptional) + * @return Reference to this builder for method chaining. + */ + VaultBuilder& + setSubscriptionDate(std::decay_t const& value) + { + object_[sfSubscriptionDate] = value; + return *this; + } + + /** + * @brief Set sfRedemptionDate (SoeOptional) + * @return Reference to this builder for method chaining. + */ + VaultBuilder& + setRedemptionDate(std::decay_t const& value) + { + object_[sfRedemptionDate] = value; + return *this; + } + /** * @brief Build and return the completed Vault wrapper. * @param index The ledger entry index. diff --git a/include/xrpl/protocol_autogen/transactions/VaultCreate.h b/include/xrpl/protocol_autogen/transactions/VaultCreate.h index b7e1527754..e206925e02 100644 --- a/include/xrpl/protocol_autogen/transactions/VaultCreate.h +++ b/include/xrpl/protocol_autogen/transactions/VaultCreate.h @@ -214,6 +214,84 @@ public: { return this->tx_->isFieldPresent(sfScale); } + + /** + * @brief Get sfVaultKind (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getVaultKind() const + { + if (hasVaultKind()) + { + return this->tx_->at(sfVaultKind); + } + return std::nullopt; + } + + /** + * @brief Check if sfVaultKind is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasVaultKind() const + { + return this->tx_->isFieldPresent(sfVaultKind); + } + + /** + * @brief Get sfSubscriptionDate (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getSubscriptionDate() const + { + if (hasSubscriptionDate()) + { + return this->tx_->at(sfSubscriptionDate); + } + return std::nullopt; + } + + /** + * @brief Check if sfSubscriptionDate is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasSubscriptionDate() const + { + return this->tx_->isFieldPresent(sfSubscriptionDate); + } + + /** + * @brief Get sfRedemptionDate (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getRedemptionDate() const + { + if (hasRedemptionDate()) + { + return this->tx_->at(sfRedemptionDate); + } + return std::nullopt; + } + + /** + * @brief Check if sfRedemptionDate is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasRedemptionDate() const + { + return this->tx_->isFieldPresent(sfRedemptionDate); + } }; /** @@ -338,6 +416,39 @@ public: return *this; } + /** + * @brief Set sfVaultKind (SoeOptional) + * @return Reference to this builder for method chaining. + */ + VaultCreateBuilder& + setVaultKind(std::decay_t const& value) + { + object_[sfVaultKind] = value; + return *this; + } + + /** + * @brief Set sfSubscriptionDate (SoeOptional) + * @return Reference to this builder for method chaining. + */ + VaultCreateBuilder& + setSubscriptionDate(std::decay_t const& value) + { + object_[sfSubscriptionDate] = value; + return *this; + } + + /** + * @brief Set sfRedemptionDate (SoeOptional) + * @return Reference to this builder for method chaining. + */ + VaultCreateBuilder& + setRedemptionDate(std::decay_t const& value) + { + object_[sfRedemptionDate] = value; + return *this; + } + /** * @brief Build and return the VaultCreate wrapper. * @param publicKey The public key for signing. diff --git a/include/xrpl/tx/invariants/LoanInvariant.h b/include/xrpl/tx/invariants/LoanInvariant.h index 0648881423..fc72b8d420 100644 --- a/include/xrpl/tx/invariants/LoanInvariant.h +++ b/include/xrpl/tx/invariants/LoanInvariant.h @@ -16,6 +16,8 @@ namespace xrpl { * @brief Invariants: Loans are internally consistent * * 1. If `Loan.PaymentRemaining = 0` then `Loan.PrincipalOutstanding = 0` + * 2. A newly-created Loan against a closed-ended vault must satisfy + * `StartDate + PaymentInterval * PaymentRemaining < Vault.RedemptionDate`. * */ class ValidLoan diff --git a/include/xrpl/tx/invariants/VaultInvariant.h b/include/xrpl/tx/invariants/VaultInvariant.h index 136c6c4a25..2ba42f0ab4 100644 --- a/include/xrpl/tx/invariants/VaultInvariant.h +++ b/include/xrpl/tx/invariants/VaultInvariant.h @@ -38,7 +38,17 @@ namespace xrpl { * - vault set must not alter the vault assets or shares balance * - no vault transaction can change loss unrealized (it's updated by loan * transactions) + * - a created closed-ended vault must satisfy + * MIN_INVESTMENT_PERIOD <= RedemptionDate - SubscriptionDate < + * MAX_INVESTMENT_PERIOD + * - vault deposit may only succeed when the vault phase is NoPhase or + * Subscription + * - vault withdrawal may not succeed when the vault phase is Investment + * - closed-ended loan origination (ttLOAN_SET) may only succeed when the + * vault phase is Investment * + * Immutability of VaultKind, SubscriptionDate and RedemptionDate is enforced + * by NoModifiedUnmodifiableFields (see InvariantCheck.cpp). */ class ValidVault { @@ -55,6 +65,9 @@ class ValidVault Number assetsAvailable = 0; Number assetsMaximum = 0; Number lossUnrealized = 0; + std::optional vaultKind; + std::optional subscriptionDate; + std::optional redemptionDate; Vault static make(SLE const&); }; @@ -153,6 +166,17 @@ private: [[nodiscard]] static bool isVaultEmpty(Vault const& vault); + /** + * @brief Invariant check for @c ttLOAN_SET. + * + * For a closed-ended vault, a loan may only be originated while the vault is in the Investment + * phase (strictly past @c SubscriptionDate and before @c RedemptionDate). Open-ended vaults (@c + * NoPhase) are unaffected. The complementary maturity bound (final payment strictly precedes @c + * RedemptionDate) is enforced by @c ValidLoan. + */ + [[nodiscard]] bool + finalizeLoanSet(ReadView const& view, beast::Journal const& j) const; + public: // Compute the coarsest scale required to represent all numbers [[nodiscard]] static std::int32_t diff --git a/src/libxrpl/ledger/View.cpp b/src/libxrpl/ledger/View.cpp index 8116f4f641..2dd70e2950 100644 --- a/src/libxrpl/ledger/View.cpp +++ b/src/libxrpl/ledger/View.cpp @@ -45,12 +45,20 @@ namespace xrpl { //------------------------------------------------------------------------------ bool -hasExpired(ReadView const& view, std::optional const& exp) +hasExpired( + ReadView const& view, + std::optional const& exp, + ExpiryComparison comparison) { using d = NetClock::duration; using tp = NetClock::time_point; - return exp && (view.parentCloseTime() >= tp{d{*exp}}); + if (!exp) + return false; + auto const boundary = tp{d{*exp}}; + return comparison == ExpiryComparison::Inclusive // + ? view.parentCloseTime() >= boundary + : view.parentCloseTime() > boundary; } bool diff --git a/src/libxrpl/ledger/helpers/VaultHelpers.cpp b/src/libxrpl/ledger/helpers/VaultHelpers.cpp index 78f64d2077..67e0262e14 100644 --- a/src/libxrpl/ledger/helpers/VaultHelpers.cpp +++ b/src/libxrpl/ledger/helpers/VaultHelpers.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include // IWYU pragma: keep @@ -11,6 +12,7 @@ #include #include #include // IWYU pragma: keep +#include #include #include @@ -157,4 +159,74 @@ getVaultVersion(SLE::const_ref vault) return static_cast(version); } +namespace { + +[[nodiscard]] VaultKind +decodeVaultKind(std::optional vaultKind) +{ + if (vaultKind && *vaultKind == std::to_underlying(VaultKind::ClosedEnded)) + return VaultKind::ClosedEnded; + return VaultKind::OpenEnded; +} + +} // namespace + +[[nodiscard]] VaultKind +getVaultKind(SLE::const_ref vault) +{ + XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::getVaultKind : valid Vault sle"); + return decodeVaultKind(vault->at(~sfVaultKind)); +} + +[[nodiscard]] VaultKind +getVaultKind(STTx const& tx) +{ + return decodeVaultKind(tx[~sfVaultKind]); +} + +[[nodiscard]] bool +isValidVaultKind(STTx const& tx) +{ + auto const kindField = tx[~sfVaultKind]; + if (!kindField) + return true; + return *kindField == std::to_underlying(VaultKind::OpenEnded) || + *kindField == std::to_underlying(VaultKind::ClosedEnded); +} + +[[nodiscard]] bool +isValidClosedEndedGap(std::uint32_t sub, std::uint32_t red) +{ + auto const s = static_cast(sub); + auto const r = static_cast(red); + return r >= s + kMinInvestmentPeriod && r < s + kMaxInvestmentPeriod; +} + +[[nodiscard]] VaultPhase +getVaultPhase(ReadView const& view, SLE::const_ref vault) +{ + XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::getVaultPhase : valid Vault sle"); + return getVaultPhase( + view, (*vault)[~sfVaultKind], (*vault)[~sfSubscriptionDate], (*vault)[~sfRedemptionDate]); +} + +[[nodiscard]] VaultPhase +getVaultPhase( + ReadView const& view, + std::optional vaultKind, + std::optional subscriptionDate, + std::optional redemptionDate) +{ + if (!vaultKind || *vaultKind != std::to_underlying(VaultKind::ClosedEnded)) + return VaultPhase::NoPhase; + + // Subscription includes now == SubscriptionDate; Investment starts + // strictly after SubscriptionDate. + if (!hasExpired(view, subscriptionDate, ExpiryComparison::Exclusive)) + return VaultPhase::Subscription; + if (!hasExpired(view, redemptionDate)) + return VaultPhase::Investment; + return VaultPhase::Redemption; +} + } // namespace xrpl diff --git a/src/libxrpl/tx/invariants/InvariantCheck.cpp b/src/libxrpl/tx/invariants/InvariantCheck.cpp index 9b997e06dd..369206d9e6 100644 --- a/src/libxrpl/tx/invariants/InvariantCheck.cpp +++ b/src/libxrpl/tx/invariants/InvariantCheck.cpp @@ -1126,20 +1126,17 @@ NoModifiedUnmodifiableFields::finalize( auto const& before = slePair.first; auto const& after = slePair.second; auto const type = after->getType(); - bool bad = false; - [[maybe_unused]] bool enforce = false; + // featureLendingProtocol gates enforcement, not detection: changes are + // always logged, but the transaction is only failed once the amendment + // is enabled. Type-specific field lists may add their own gates (see + // ltVAULT). + bool const enforce = view.rules().enabled(featureLendingProtocol); + bool bad = kFieldChanged(before, after, sfLedgerEntryType) || + kFieldChanged(before, after, sfLedgerIndex); switch (type) { case ltLOAN_BROKER: - /* - * We check this invariant regardless of lending protocol - * amendment status, allowing for detection and logging of - * potential issues even when the amendment is disabled. - */ - enforce = view.rules().enabled(featureLendingProtocol); - bad = kFieldChanged(before, after, sfLedgerEntryType) || - kFieldChanged(before, after, sfLedgerIndex) || - kFieldChanged(before, after, sfSequence) || + bad = bad || kFieldChanged(before, after, sfSequence) || kFieldChanged(before, after, sfOwnerNode) || kFieldChanged(before, after, sfVaultNode) || kFieldChanged(before, after, sfVaultID) || @@ -1150,15 +1147,7 @@ NoModifiedUnmodifiableFields::finalize( kFieldChanged(before, after, sfCoverRateLiquidation); break; case ltLOAN: - /* - * We check this invariant regardless of lending protocol - * amendment status, allowing for detection and logging of - * potential issues even when the amendment is disabled. - */ - enforce = view.rules().enabled(featureLendingProtocol); - bad = kFieldChanged(before, after, sfLedgerEntryType) || - kFieldChanged(before, after, sfLedgerIndex) || - kFieldChanged(before, after, sfSequence) || + bad = bad || kFieldChanged(before, after, sfSequence) || kFieldChanged(before, after, sfOwnerNode) || kFieldChanged(before, after, sfLoanBrokerNode) || kFieldChanged(before, after, sfLoanBrokerID) || @@ -1177,19 +1166,28 @@ NoModifiedUnmodifiableFields::finalize( kFieldChanged(before, after, sfGracePeriod) || kFieldChanged(before, after, sfLoanScale); break; - default: + case ltVAULT: /* - * We check this invariant regardless of lending protocol - * amendment status, allowing for detection and logging of - * potential issues even when the amendment is disabled. - * - * We use the lending protocol as a gate, even though - * all transactions are affected because that's when it - * was added. + * sfAccount, sfAsset and sfShareMPTID are already + * captured by VaultInvariant. The additional fields + * below are introduced by featureLendingProtocolV1_1 + * and only exist on V1_1 vaults. */ - enforce = view.rules().enabled(featureLendingProtocol); - bad = kFieldChanged(before, after, sfLedgerEntryType) || - kFieldChanged(before, after, sfLedgerIndex); + if (view.rules().enabled(featureLendingProtocolV1_1)) + { + bad = bad || kFieldChanged(before, after, sfVaultKind) || + kFieldChanged(before, after, sfSubscriptionDate) || + kFieldChanged(before, after, sfRedemptionDate) || + kFieldChanged(before, after, sfSequence) || + kFieldChanged(before, after, sfOwnerNode) || + kFieldChanged(before, after, sfOwner) || + kFieldChanged(before, after, sfWithdrawalPolicy) || + kFieldChanged(before, after, sfScale) || + kFieldChanged(before, after, sfLEVersion); + } + break; + default: + break; } XRPL_ASSERT( !bad || enforce, diff --git a/src/libxrpl/tx/invariants/LoanInvariant.cpp b/src/libxrpl/tx/invariants/LoanInvariant.cpp index ce9a7c6e03..7b96790570 100644 --- a/src/libxrpl/tx/invariants/LoanInvariant.cpp +++ b/src/libxrpl/tx/invariants/LoanInvariant.cpp @@ -4,7 +4,10 @@ #include #include #include +#include +#include #include +#include #include #include #include // IWYU pragma: keep @@ -12,6 +15,8 @@ #include #include +#include + namespace xrpl { void @@ -26,7 +31,7 @@ ValidLoan::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after bool ValidLoan::finalize( STTx const& tx, - TER const, + TER const result, XRPAmount const, ReadView const& view, beast::Journal const& j) @@ -36,6 +41,35 @@ ValidLoan::finalize( for (auto const& [before, after] : loans_) { + // A closed-ended vault must not accept a loan whose final scheduled payment falls on or + // after the vault's RedemptionDate. This mirrors the LoanSet::preclaim gate and only fires + // on loan creation; once the loan exists, its StartDate / PaymentInterval are immutable and + // PaymentRemaining only decreases, so the bound is preserved. + if (!before && isTesSuccess(result)) + { + auto const broker = view.read(keylet::loanBroker(after->at(sfLoanBrokerID))); + if (broker) + { + auto const vault = view.read(keylet::vault(broker->at(sfVaultID))); + // We don't check for LendingProtocolV1_1 amendment because a ClosedEnded Vault will + // not exist without the amendment enabled + if (vault && getVaultKind(vault) == VaultKind::ClosedEnded) + { + std::uint32_t const startDate = after->at(sfStartDate); + std::uint32_t const interval = after->at(sfPaymentInterval); + std::uint32_t const remaining = after->at(sfPaymentRemaining); + std::uint32_t const redemption = vault->at(sfRedemptionDate); + if (std::uint64_t{startDate} + (std::uint64_t{interval} * remaining) >= + redemption) + { + JLOG(j.fatal()) << "Invariant failed: closed-ended loan final payment " + "must precede RedemptionDate"; + return false; + } + } + } + } + // https://github.com/Tapanito/XRPL-Standards/blob/xls-66-lending-protocol/XLS-0066d-lending-protocol/README.md#3223-invariants // If `Loan.PaymentRemaining = 0` then the loan MUST be fully paid off if (after->at(sfPaymentRemaining) == 0 && diff --git a/src/libxrpl/tx/invariants/VaultInvariant.cpp b/src/libxrpl/tx/invariants/VaultInvariant.cpp index c577fdf356..dc6021beb5 100644 --- a/src/libxrpl/tx/invariants/VaultInvariant.cpp +++ b/src/libxrpl/tx/invariants/VaultInvariant.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -24,11 +25,27 @@ #include #include #include +#include #include #include namespace xrpl { +namespace { + +/* + * True iff the recorded sfVaultKind identifies a closed-ended vault. + * Centralizes the presence + enum-value check used by the phase-gate + * invariants below. + */ +[[nodiscard]] bool +isClosedEnded(std::optional const& vaultKind) +{ + return vaultKind && *vaultKind == std::to_underlying(VaultKind::ClosedEnded); +} + +} // namespace + ValidVault::Vault ValidVault::Vault::make(SLE const& from) { @@ -44,6 +61,9 @@ ValidVault::Vault::make(SLE const& from) self.assetsAvailable = from.at(sfAssetsAvailable); self.assetsMaximum = from.at(sfAssetsMaximum); self.lossUnrealized = from.at(sfLossUnrealized); + self.vaultKind = from[~sfVaultKind]; + self.subscriptionDate = from[~sfSubscriptionDate]; + self.redemptionDate = from[~sfRedemptionDate]; return self; } @@ -254,6 +274,37 @@ ValidVault::isVaultEmpty(Vault const& vault) return vault.assetsAvailable == 0 && vault.assetsTotal == 0; } +bool +ValidVault::finalizeLoanSet(ReadView const& view, beast::Journal const& j) const +{ + if (afterVault_.empty()) + { + // LCOV_EXCL_START + UNREACHABLE("xrpl::ValidVault::finalizeLoanSet : vault exists"); + return false; + // LCOV_EXCL_STOP + } + + auto const& afterVault = afterVault_[0]; + + // Loan origination against a closed-ended vault is only permitted while the vault is in the + // Investment phase - strictly past SubscriptionDate and before RedemptionDate. Open-ended + // vaults have NoPhase and are unaffected. + auto const phase = getVaultPhase( + view, afterVault.vaultKind, afterVault.subscriptionDate, afterVault.redemptionDate); + if (phase == VaultPhase::NoPhase) + return true; + + if (phase != VaultPhase::Investment) + { + JLOG(j.fatal()) << // + "Invariant failed: loan origination only allowed in Investment phase"; + return false; + } + + return true; +} + std::int32_t ValidVault::computeVaultMinScale(DeltaInfo const& vaultDelta, Rules const& rules) const { @@ -520,6 +571,9 @@ ValidVault::finalize( result = false; } + // Immutability of VaultKind, SubscriptionDate and RedemptionDate is enforced by + // NoModifiedUnmodifiableFields in InvariantCheck.cpp. + auto const beforeShares = [&]() -> std::optional { if (beforeVault_.empty()) return std::nullopt; @@ -606,6 +660,26 @@ ValidVault::finalize( result = false; } + if (isClosedEnded(afterVault.vaultKind)) + { + if (!afterVault.subscriptionDate || !afterVault.redemptionDate) + { + JLOG(j.fatal()) // + << "Invariant failed: closed-ended vault must have SubscriptionDate " + "and RedemptionDate"; + result = false; + } + else if (!isValidClosedEndedGap( + *afterVault.subscriptionDate, *afterVault.redemptionDate)) + { + JLOG(j.fatal()) // + << "Invariant failed: closed-ended vault RedemptionDate - " + "SubscriptionDate must be within [MIN_INVESTMENT_PERIOD, " + "MAX_INVESTMENT_PERIOD)"; + result = false; + } + } + return result; } case ttVAULT_SET: { @@ -666,6 +740,21 @@ ValidVault::finalize( !beforeVault_.empty(), "xrpl::ValidVault::finalize : deposit updated a vault"); auto const& beforeVault = beforeVault_[0]; + // Deposit is only allowed while the vault is in NoPhase or + // Subscription. + auto const depositPhase = getVaultPhase( + view, + afterVault.vaultKind, + afterVault.subscriptionDate, + afterVault.redemptionDate); + if (depositPhase != VaultPhase::NoPhase && depositPhase != VaultPhase::Subscription) + { + JLOG(j.fatal()) << // + "Invariant failed: deposit only allowed in " + "Subscription or NoPhase"; + result = false; + } + auto const maybeVaultDeltaAssets = deltaAssets(afterVault.pseudoId); if (!maybeVaultDeltaAssets) { @@ -804,6 +893,20 @@ ValidVault::finalize( "xrpl::ValidVault::finalize : withdrawal updated a vault"); auto const& beforeVault = beforeVault_[0]; + // Withdrawal from a closed-ended vault is not allowed during the Investment phase + // (strictly past SubscriptionDate, before RedemptionDate). + if (getVaultPhase( + view, + afterVault.vaultKind, + afterVault.subscriptionDate, + afterVault.redemptionDate) == VaultPhase::Investment) + { + JLOG(j.fatal()) << // + "Invariant failed: withdrawal not allowed during " + "Investment phase"; + result = false; + } + auto const maybeVaultDeltaAssets = deltaAssets(afterVault.pseudoId); if (!maybeVaultDeltaAssets) { @@ -1052,6 +1155,7 @@ ValidVault::finalize( } case ttLOAN_SET: + return finalizeLoanSet(view, j); case ttLOAN_MANAGE: case ttLOAN_PAY: return true; diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index 6533a47916..2def3d2eb2 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -225,6 +226,8 @@ TER LoanSet::preclaim(PreclaimContext const& ctx) { auto const& tx = ctx.tx; + auto const interval = ctx.tx.at(~sfPaymentInterval).value_or(kDefaultPaymentInterval); + auto const total = ctx.tx.at(~sfPaymentTotal).value_or(kDefaultPaymentTotal); { // Check for numeric overflow of the schedule before we load any @@ -238,9 +241,6 @@ LoanSet::preclaim(PreclaimContext const& ctx) static_assert(kMaxTime == 4'294'967'295); auto const timeAvailable = kMaxTime - getStartDate(ctx.view); - - auto const interval = ctx.tx.at(~sfPaymentInterval).value_or(kDefaultPaymentInterval); - auto const total = ctx.tx.at(~sfPaymentTotal).value_or(kDefaultPaymentTotal); auto const grace = ctx.tx.at(~sfGracePeriod).value_or(kDefaultGracePeriod); // The grace period can't be larger than the interval. Check it first, @@ -310,6 +310,32 @@ LoanSet::preclaim(PreclaimContext const& ctx) return tefBAD_LEDGER; // LCOV_EXCL_LINE } + if (ctx.view.rules().enabled(featureLendingProtocolV1_1)) + { + auto const phase = getVaultPhase(ctx.view, vault); + if (phase == VaultPhase::Subscription) + { + JLOG(ctx.j.warn()) << "Vault is still in the subscription phase."; + return tecTOO_SOON; + } + if (phase == VaultPhase::Redemption) + { + JLOG(ctx.j.warn()) << "Vault has entered the redemption phase."; + return tecEXPIRED; + } + if (phase == VaultPhase::Investment) + { + auto const finalPayment = + std::uint64_t{getStartDate(ctx.view)} + (std::uint64_t{interval} * total); + if (finalPayment >= vault->at(sfRedemptionDate)) + { + JLOG(ctx.j.warn()) << "Final loan payment date is on or after " + "the vault's redemption date."; + return tecNO_PERMISSION; + } + } + } + if (vault->at(sfAssetsMaximum) != 0 && vault->at(sfAssetsTotal) >= vault->at(sfAssetsMaximum)) { JLOG(ctx.j.warn()) << "Vault at maximum assets limit. Can't add another loan."; diff --git a/src/libxrpl/tx/transactors/vault/VaultCreate.cpp b/src/libxrpl/tx/transactors/vault/VaultCreate.cpp index f74a27c39b..7ade4ed5ab 100644 --- a/src/libxrpl/tx/transactors/vault/VaultCreate.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultCreate.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -43,6 +44,11 @@ VaultCreate::checkExtraFeatures(PreflightContext const& ctx) if (ctx.tx.isFieldPresent(sfDomainID) && !ctx.rules.enabled(featurePermissionedDomains)) return false; + if (!ctx.rules.enabled(featureLendingProtocolV1_1) && + (ctx.tx.isFieldPresent(sfVaultKind) || ctx.tx.isFieldPresent(sfSubscriptionDate) || + ctx.tx.isFieldPresent(sfRedemptionDate))) + return false; + return true; } @@ -99,6 +105,22 @@ VaultCreate::preflight(PreflightContext const& ctx) return temMALFORMED; } + if (!isValidVaultKind(ctx.tx)) + return temMALFORMED; + auto const kind = getVaultKind(ctx.tx); + auto const hasSubscription = ctx.tx.isFieldPresent(sfSubscriptionDate); + auto const hasRedemption = ctx.tx.isFieldPresent(sfRedemptionDate); + auto const isClosedEnded = kind == VaultKind::ClosedEnded; + if (!isClosedEnded && (hasSubscription || hasRedemption)) + return temMALFORMED; + if (isClosedEnded) + { + if (!hasSubscription || !hasRedemption) + return temMALFORMED; + if (!isValidClosedEndedGap(ctx.tx[sfSubscriptionDate], ctx.tx[sfRedemptionDate])) + return temMALFORMED; + } + return tesSUCCESS; } @@ -136,6 +158,16 @@ VaultCreate::preclaim(PreclaimContext const& ctx) accountId == beast::kZero) return terADDRESS_COLLISION; + // preflight enforces red >= sub + kMinInvestmentPeriod for closed-ended + // vaults, so a past RedemptionDate always implies a strictly-earlier, + // equally-past SubscriptionDate. The RedemptionDate arm below is therefore + // defensive: it cannot be the sole cause of tecEXPIRED. It is kept to + // preserve the invariant locally in case the preflight gap check is ever + // weakened. + if (hasExpired(ctx.view, ctx.tx[~sfSubscriptionDate]) || + hasExpired(ctx.view, ctx.tx[~sfRedemptionDate])) + return tecEXPIRED; + return tesSUCCESS; } @@ -242,7 +274,17 @@ VaultCreate::doApply() if (scale != 0u) vault->at(sfScale) = scale; if (view().rules().enabled(featureLendingProtocolV1_1)) + { vault->at(sfLEVersion) = std::to_underlying(VaultVersion::CashBasis); + + auto const kind = getVaultKind(tx); + vault->at(sfVaultKind) = std::to_underlying(kind); + if (kind == VaultKind::ClosedEnded) + { + vault->at(sfSubscriptionDate) = tx[sfSubscriptionDate]; + vault->at(sfRedemptionDate) = tx[sfRedemptionDate]; + } + } view().insert(vault); // Explicitly create MPToken for the vault owner diff --git a/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp b/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp index aa9cfc8537..a3c0a94eb5 100644 --- a/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -71,6 +72,17 @@ VaultDeposit::preclaim(PreclaimContext const& ctx) if (!vault) return tecNO_ENTRY; + if (ctx.view.rules().enabled(featureLendingProtocolV1_1)) + { + auto const phase = getVaultPhase(ctx.view, vault); + if (phase == VaultPhase::Investment || phase == VaultPhase::Redemption) + { + JLOG(ctx.j.debug()) << "VaultDeposit: vault deposit is not allowed in the investment " + "or redemption phase."; + return tecEXPIRED; + } + } + auto const& account = ctx.tx[sfAccount]; auto const amount = ctx.tx[sfAmount]; auto const vaultAsset = vault->at(sfAsset); diff --git a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp index 353b72c30d..7b5bb1ea94 100644 --- a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp @@ -73,6 +73,16 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx) if (!vault) return tecNO_ENTRY; + if (ctx.view.rules().enabled(featureLendingProtocolV1_1)) + { + if (getVaultPhase(ctx.view, vault) == VaultPhase::Investment) + { + JLOG(ctx.j.debug()) + << "VaultWithdraw: vault withdrawal is not allowed in the investment phase."; + return tecTOO_SOON; + } + } + auto const amount = ctx.tx[sfAmount]; auto const vaultAsset = vault->at(sfAsset); auto const vaultShare = vault->at(sfShareMPTID); diff --git a/src/test/app/Invariants_test.cpp b/src/test/app/Invariants_test.cpp index ed09b7b660..6878b2b5d0 100644 --- a/src/test/app/Invariants_test.cpp +++ b/src/test/app/Invariants_test.cpp @@ -16,6 +16,7 @@ #include #include +#include #include #include #include @@ -65,6 +66,7 @@ #include #include #include +#include #include #include #include @@ -135,7 +137,8 @@ class Invariants_test : public beast::unit_test::Suite STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}}, std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, Preclose const& preclose = {}, - TxAccount setTxAccount = TxAccount::None) + TxAccount setTxAccount = TxAccount::None, + std::source_location const& loc = std::source_location::current()) { doInvariantCheck( makeEnv(defaultAmendments()), @@ -145,7 +148,8 @@ class Invariants_test : public beast::unit_test::Suite tx, ters, preclose, - setTxAccount); + setTxAccount, + loc); } void @@ -157,7 +161,8 @@ class Invariants_test : public beast::unit_test::Suite STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}}, std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, Preclose const& preclose = {}, - TxAccount setTxAccount = TxAccount::None) + TxAccount setTxAccount = TxAccount::None, + std::source_location const& loc = std::source_location::current()) { using namespace test::jtx; @@ -171,7 +176,7 @@ class Invariants_test : public beast::unit_test::Suite if (setTxAccount != TxAccount::None) tx.setAccountID(sfAccount, setTxAccount == TxAccount::A1 ? a1.id() : a2.id()); - doInvariantCheck(std::move(env), a1, a2, expectLogs, precheck, fee, tx, ters); + doInvariantCheck(std::move(env), a1, a2, expectLogs, precheck, fee, tx, ters, loc); } void @@ -184,7 +189,8 @@ class Invariants_test : public beast::unit_test::Suite Precheck const& precheck, XRPAmount fee = XRPAmount{}, STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}}, - std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED}) + std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + std::source_location const& loc = std::source_location::current()) { using namespace test::jtx; @@ -211,23 +217,27 @@ class Invariants_test : public beast::unit_test::Suite for (TER const& terExpect : ters) { terActual = transactor->checkInvariants(terActual, fee); - BEAST_EXPECTS( + expect( terExpect == terActual, - "expected: " + transToken(terExpect) + " got: " + transToken(terActual)); + "expected: " + transToken(terExpect) + " got: " + transToken(terActual), + loc.file_name(), + loc.line()); auto const messages = sink.messages().str(); if (!isTesSuccess(terActual)) { - BEAST_EXPECTS( + expect( messages.starts_with("Invariant failed:") || messages.starts_with("Transaction caused an exception"), - messages); + messages, + loc.file_name(), + loc.line()); } // std::cerr << messages << '\n'; for (auto const& m : expectLogs) { - BEAST_EXPECTS(messages.contains(m), m); + expect(messages.contains(m), m, loc.file_name(), loc.line()); } } } @@ -2475,6 +2485,54 @@ class Invariants_test : public beast::unit_test::Suite // TODO: Loan Object + // VaultKind, SubscriptionDate and RedemptionDate are immutable once set at creation. + // Enforced by NoModifiedUnmodifiableFields on ltVAULT via kFieldChanged. + Keylet closedEndedVaultKeylet = keylet::amendments(); + Preclose const createClosedEndedVault = [&, this]( + Account const& a, Account const&, Env& env) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto const red = sub + kMinInvestmentPeriod + 1'000'000; + Vault const vault{env}; + auto [tx, keylet] = vault.create( + {.owner = a, + .asset = xrpIssue(), + .vaultKind = std::to_underlying(VaultKind::ClosedEnded), + .subscriptionDate = sub, + .redemptionDate = red}); + env(tx); + closedEndedVaultKeylet = keylet; + return BEAST_EXPECT(env.le(closedEndedVaultKeylet)); + }; + + { + // Each mutation must keep the vault otherwise valid so that only the immutability check + // fires. Shifting both dates by the same offset preserves the gap; bumping sfVaultKind + // stays within the recognised range. + auto const mods = std::to_array>({ + [](SLE::pointer& sle) { sle->at(sfVaultKind) += 1; }, + [](SLE::pointer& sle) { sle->at(sfSubscriptionDate) += 1; }, + [](SLE::pointer& sle) { sle->at(sfRedemptionDate) += 1; }, + }); + + for (auto const& mod : mods) + { + doInvariantCheck( + {{"changed an unchangeable field"}}, + [&](Account const&, Account const&, ApplyContext& ac) { + auto sle = ac.view().peek(closedEndedVaultKeylet); + if (!sle) + return false; + mod(sle); + ac.view().update(sle); + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_SET, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + createClosedEndedVault); + } + } + { auto const mods = std::to_array>({ [](SLE::pointer& sle) { sle->at(sfLedgerEntryType) += 1; }, @@ -4367,6 +4425,286 @@ class Invariants_test : public beast::unit_test::Suite }}, {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, precloseMpt); + + // ───────────────────────────────────────────────────────────── + // Closed-ended vault invariants added in ValidVault::finalize (create must supply both + // dates and satisfy the redemption-buffer gap), deposit only in Subscription / NoPhase, + // withdraw not in Investment, loan origination only in Investment. + + using d = NetClock::duration; + using tp = NetClock::time_point; + + auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded); + + // Vault keylet captured by precloseClosedEnded so precheck does not have to rederive it + // from ac.view().seq(), which depends on how many env.close() calls preclose issued. + Keylet closedEndedKeylet = keylet::amendments(); + + // Preclose that creates a closed-ended vault (in Subscription), optionally seeds it with + // three deposits (so a1/a2/a3 hold a share MPToken that kAdjust can then adjust), and + // optionally advances parent close time past SubscriptionDate. A negative @p advanceBySub + // leaves the vault in Subscription. + auto const precloseClosedEnded = [&](std::int32_t advanceBySub, bool doDeposit) { + return [&, advanceBySub, doDeposit]( + Account const& a1, Account const& a2, Env& env) -> bool { + env.fund(XRP(1000), a3, a4); + auto const sub = env.now().time_since_epoch().count() + 60; + auto const red = sub + kMinInvestmentPeriod + 1'000'000; + Vault const vault{env}; + auto [tx, keylet] = vault.create( + {.owner = a1, + .asset = xrpIssue(), + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = red}); + env(tx); + closedEndedKeylet = keylet; + if (doDeposit) + { + env(vault.deposit({.depositor = a1, .id = keylet.key, .amount = XRP(10)})); + env(vault.deposit({.depositor = a2, .id = keylet.key, .amount = XRP(10)})); + env(vault.deposit({.depositor = a3, .id = keylet.key, .amount = XRP(10)})); + } + if (advanceBySub >= 0) + env.close(tp{d{sub + advanceBySub}}); + return true; + }; + }; + + // Manually insert a bare closed-ended vault (+ pseudo-account + share MPTokenIssuance) + // directly into the view, bypassing the transactor path. Used to synthesize ttVAULT_CREATE + // states no legitimate transactor would produce. + auto const insertBareClosedEndedVault = + [closedEnded]( + ApplyContext& ac, + Account const& owner, + std::optional subscriptionDate, + std::optional redemptionDate) -> bool { + auto const sequence = ac.view().seq(); + auto const vaultKeylet = keylet::vault(owner.id(), SeqProxy::rawSequence(sequence)); + auto sleVault = std::make_shared(vaultKeylet); + auto const vaultPage = ac.view().dirInsert( + keylet::ownerDir(owner.id()), sleVault->key(), describeOwnerDir(owner.id())); + if (!vaultPage) + return false; + sleVault->setFieldU64(sfOwnerNode, *vaultPage); + + auto const pseudoId = pseudoAccountAddress(ac.view(), vaultKeylet.key); + auto sleAccount = std::make_shared(keylet::account(pseudoId)); + sleAccount->setAccountID(sfAccount, pseudoId); + sleAccount->setFieldAmount(sfBalance, STAmount{}); + sleAccount->setFieldU32(sfSequence, 0); + sleAccount->setFieldU32(sfFlags, lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth); + sleAccount->setFieldH256(sfVaultID, vaultKeylet.key); + ac.view().insert(sleAccount); + + auto const sharesMptId = makeMptID(sequence, pseudoId); + auto const sharesKeylet = keylet::mptokenIssuance(sharesMptId); + auto sleShares = std::make_shared(sharesKeylet); + auto const sharesPage = ac.view().dirInsert( + keylet::ownerDir(pseudoId), sharesKeylet, describeOwnerDir(pseudoId)); + if (!sharesPage) + return false; + sleShares->setFieldU64(sfOwnerNode, *sharesPage); + sleShares->at(sfFlags) = 0; + sleShares->at(sfIssuer) = pseudoId; + sleShares->at(sfOutstandingAmount) = 0; + sleShares->at(sfSequence) = sequence; + + sleVault->at(sfAccount) = pseudoId; + sleVault->at(sfFlags) = 0; + sleVault->at(sfSequence) = sequence; + sleVault->at(sfOwner) = owner.id(); + sleVault->setFieldIssue(sfAsset, STIssue{sfAsset, Asset{xrpIssue()}}); + sleVault->at(sfAssetsTotal) = Number(0); + sleVault->at(sfAssetsAvailable) = Number(0); + sleVault->at(sfLossUnrealized) = Number(0); + sleVault->at(sfShareMPTID) = sharesMptId; + sleVault->at(sfWithdrawalPolicy) = kVaultStrategyFirstComeFirstServe; + sleVault->at(sfVaultKind) = closedEnded; + if (subscriptionDate) + sleVault->at(sfSubscriptionDate) = *subscriptionDate; + if (redemptionDate) + sleVault->at(sfRedemptionDate) = *redemptionDate; + + ac.view().insert(sleVault); + ac.view().insert(sleShares); + return true; + }; + + testcase << "Vault create closed-ended"; + + // A fresh closed-ended vault must carry both SubscriptionDate and RedemptionDate. + doInvariantCheck( + {"closed-ended vault must have SubscriptionDate and RedemptionDate"}, + [&](Account const& a1, Account const&, ApplyContext& ac) { + return insertBareClosedEndedVault(ac, a1, std::nullopt, std::nullopt); + }, + XRPAmount{}, + STTx{ttVAULT_CREATE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + + // Gap smaller than MIN_INVESTMENT_PERIOD but with RedemptionDate > SubscriptionDate; + // exercises the sub-minimum branch of the gap check. + doInvariantCheck( + {"closed-ended vault RedemptionDate - SubscriptionDate must be " + "within [MIN_INVESTMENT_PERIOD, MAX_INVESTMENT_PERIOD)"}, + [&](Account const& a1, Account const&, ApplyContext& ac) { + std::uint32_t const sub = 1'000'000'000; + std::uint32_t const red = sub + kMinInvestmentPeriod - 1; + return insertBareClosedEndedVault(ac, a1, sub, red); + }, + XRPAmount{}, + STTx{ttVAULT_CREATE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + + // RedemptionDate strictly before SubscriptionDate; the signed int64 gap is negative and + // is caught by the sub-minimum branch of the gap check. + doInvariantCheck( + {"closed-ended vault RedemptionDate - SubscriptionDate must be " + "within [MIN_INVESTMENT_PERIOD, MAX_INVESTMENT_PERIOD)"}, + [&](Account const& a1, Account const&, ApplyContext& ac) { + std::uint32_t const sub = 1'000'000'000; + std::uint32_t const red = sub - 1; + return insertBareClosedEndedVault(ac, a1, sub, red); + }, + XRPAmount{}, + STTx{ttVAULT_CREATE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + + // Gap exactly MAX_INVESTMENT_PERIOD is out of range (bound is half-open on the right). + doInvariantCheck( + {"closed-ended vault RedemptionDate - SubscriptionDate must be " + "within [MIN_INVESTMENT_PERIOD, MAX_INVESTMENT_PERIOD)"}, + [&](Account const& a1, Account const&, ApplyContext& ac) { + std::uint32_t const sub = 1'000'000'000; + std::uint32_t const red = sub + kMaxInvestmentPeriod; + return insertBareClosedEndedVault(ac, a1, sub, red); + }, + XRPAmount{}, + STTx{ttVAULT_CREATE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + + testcase << "Vault deposit closed-ended"; + + // A deposit into a closed-ended vault that has advanced past SubscriptionDate. kArgs + // simulates an otherwise valid deposit shape so only the phase invariant fires. + doInvariantCheck( + {"deposit only allowed in Subscription or NoPhase"}, + [&](Account const&, Account const& a2, ApplyContext& ac) { + return kAdjust( + ac.view(), closedEndedKeylet, kArgs(a2.id(), 10, [](Adjustments&) {})); + }, + XRPAmount{}, + STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseClosedEnded(/*advanceBySub=*/1, /*doDeposit=*/true), + TxAccount::A2); + + testcase << "Vault withdrawal closed-ended"; + + // A withdrawal from a closed-ended vault in the Investment phase. + doInvariantCheck( + {"withdrawal not allowed during Investment phase"}, + [&](Account const&, Account const& a2, ApplyContext& ac) { + return kAdjust( + ac.view(), closedEndedKeylet, kArgs(a2.id(), -10, [](Adjustments&) {})); + }, + XRPAmount{}, + STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseClosedEnded(/*advanceBySub=*/1, /*doDeposit=*/true), + TxAccount::A2); + + testcase << "Vault loan set"; + + // ttLOAN_SET against a closed-ended vault that is not in Investment. finalizeLoanSet fires + // on any vault mutation; touching the vault SLE with no field change is sufficient. + doInvariantCheck( + {"loan origination only allowed in Investment phase"}, + [&](Account const&, Account const&, ApplyContext& ac) { + auto sleVault = ac.view().peek(closedEndedKeylet); + if (!sleVault) + return false; + ac.view().update(sleVault); + return true; + }, + XRPAmount{}, + STTx{ttLOAN_SET, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseClosedEnded(/*advanceBySub=*/-1, /*doDeposit=*/false)); + + testcase << "Vault loan set - closed-ended final payment past " + "RedemptionDate"; + + // A newly-created loan against a closed-ended vault must satisfy StartDate + + // PaymentInterval * PaymentRemaining < RedemptionDate. LoanSet::preclaim enforces the same + // bound; this test synthesises an invalid loan directly in the ApplyView so the invariant + // catches it even when preclaim is bypassed. + Keylet closedEndedBrokerKeylet = keylet::amendments(); + std::uint32_t closedEndedRed = 0; + doInvariantCheck( + {"closed-ended loan final payment must precede RedemptionDate"}, + [&](Account const& a1, Account const&, ApplyContext& ac) { + // Touch the vault so ValidVault::finalizeLoanSet sees an + // entry in afterVault_; the vault is in Investment, so + // finalizeLoanSet itself passes. + auto sleVault = ac.view().peek(closedEndedKeylet); + if (!sleVault) + return false; + ac.view().update(sleVault); + + // Read the broker's next loan sequence to build the loan + // keylet the same way LoanSet::doApply would. + auto sleBroker = ac.view().peek(closedEndedBrokerKeylet); + if (!sleBroker) + return false; + std::uint32_t const loanSeq = sleBroker->at(sfLoanSequence); + + // Synthesize a Loan whose final scheduled payment lands + // exactly at RedemptionDate: StartDate = red, interval = 60, + // remaining = 1 => red + 60 >= red. + auto sleLoan = std::make_shared( + keylet::loan(closedEndedBrokerKeylet.key, SeqProxy::rawSequence(loanSeq))); + sleLoan->at(sfLoanBrokerID) = closedEndedBrokerKeylet.key; + sleLoan->at(sfLoanSequence) = loanSeq; + sleLoan->at(sfBorrower) = a1.id(); + sleLoan->at(sfStartDate) = closedEndedRed; + sleLoan->at(sfPaymentInterval) = 60; + sleLoan->at(sfPaymentRemaining) = 1; + sleLoan->at(sfTotalValueOutstanding) = Number(100); + sleLoan->at(sfPeriodicPayment) = Number(1); + ac.view().insert(sleLoan); + return true; + }, + XRPAmount{}, + STTx{ttLOAN_SET, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + [&](Account const& a1, Account const&, Env& env) -> bool { + auto const sub = env.now().time_since_epoch().count() + 60; + auto const red = sub + kMinInvestmentPeriod + 1'000'000; + closedEndedRed = red; + + Vault const vault{env}; + auto [tx, keylet] = vault.create( + {.owner = a1, + .asset = xrpIssue(), + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = red}); + env(tx); + closedEndedKeylet = keylet; + + // Create the loan broker; LoanBrokerSet has no phase gate. + closedEndedBrokerKeylet = + keylet::loanBroker(a1.id(), SeqProxy::rawSequence(env.seq(a1))); + env(loan_broker::set(a1, keylet.key)); + + // Advance parent close time into Investment so + // ValidVault::finalizeLoanSet is satisfied. + env.close(tp{d{sub + 1}}); + return true; + }); } void diff --git a/src/test/app/Vault_test.cpp b/src/test/app/Vault_test.cpp index 6b6c4eb875..34ac40fb54 100644 --- a/src/test/app/Vault_test.cpp +++ b/src/test/app/Vault_test.cpp @@ -28,6 +28,7 @@ #include #include +#include #include #include #include @@ -41,11 +42,13 @@ #include #include #include +#include #include #include #include #include #include +#include #include #include #include @@ -63,10 +66,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -82,6 +87,75 @@ class Vault_test : public beast::unit_test::Suite return {STAmount{asset.raw(), 1ul, 0, true, STAmount::Unchecked{}}, ""}; }; + /** + * Get the current ledger's close time resolution. + * @param env The test environment. + */ + static NetClock::duration + getLedgerTimeResolution(test::jtx::Env& env) + { + return env.current()->header().closeTimeResolution; + } + + void + closeToTime( + test::jtx::Env& env, + NetClock::time_point time, + std::source_location const& loc = std::source_location::current()) + { + using namespace std::chrono_literals; + env.close(time - env.closed()->header().closeTimeResolution + 1s); + expect( + env.closed()->header().closeTime == time, + std::format( + "current ledger time {} is not equal to the target ledger time {}", + env.closed()->header().closeTime.time_since_epoch(), + time.time_since_epoch()), + loc.file_name(), + loc.line()); + } + + using d = NetClock::duration; + using tp = NetClock::time_point; + + // Vault holds an Env& so no default initializer is possible; the + // struct is always aggregate-initialized by makeClosedEndedVault. + // NOLINTBEGIN(cppcoreguidelines-pro-type-member-init) + struct ClosedEndedSetup + { + test::jtx::Vault vault; + Keylet keylet; + std::uint32_t sub = 0; + std::uint32_t red = 0; + }; + // NOLINTEND(cppcoreguidelines-pro-type-member-init) + + // Submit a VaultCreate for a closed-ended vault with SubscriptionDate at + // env.now() + subOffset and RedemptionDate at SubscriptionDate + gap, then + // close the ledger. Returns the Vault helper, the vault's keylet and the + // resolved sub/red timestamps. + static ClosedEndedSetup + makeClosedEndedVault( + test::jtx::Env& env, + test::jtx::Account const& owner, + Asset const& asset, + std::uint32_t subOffset, + std::uint32_t gap) + { + auto const sub = env.now().time_since_epoch().count() + subOffset; + auto const red = sub + gap; + test::jtx::Vault const vault{env}; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = std::to_underlying(VaultKind::ClosedEnded), + .subscriptionDate = sub, + .redemptionDate = red}); + env(tx); + env.close(); + return {.vault = vault, .keylet = keylet, .sub = sub, .red = red}; + } + void testSequences() { @@ -1107,6 +1181,949 @@ class Vault_test : public beast::unit_test::Suite }); } + // VaultCreate malformation and happy paths for closed-ended vaults, plus the + // featureLendingProtocolV1_1 gate. + void + testVaultCreateClosedEnded() + { + testcase("closed-ended VaultCreate"); + using namespace test::jtx; + + auto const withEnv = [this](FeatureBitset features, auto&& body) { + Env env{*this, features}; + Account const owner{"owner"}; + env.fund(XRP(1000), owner); + env.close(); + Vault vault{env}; + body(env, owner, vault); + }; + + Asset const asset = xrpIssue(); + auto const minPeriod = kMinInvestmentPeriod; + auto const maxPeriod = kMaxInvestmentPeriod; + auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded); + + // Gate: the three new fields require featureLendingProtocolV1_1. + withEnv( + testableAmendments() - featureLendingProtocolV1_1, + [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = sub + minPeriod}); + env(tx, Ter{temDISABLED}); + }); + + /* + * Valid closed-ended creation with a comfortably interior gap (well above + * MIN_INVESTMENT_PERIOD and well below MAX_INVESTMENT_PERIOD). + */ + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto const red = sub + 86400; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = red}); + env(tx); + env.close(); + auto const sle = env.le(keylet); + if (BEAST_EXPECT(sle)) + { + BEAST_EXPECT(sle->at(sfVaultKind) == closedEnded); + BEAST_EXPECT(sle->at(sfSubscriptionDate) == sub); + BEAST_EXPECT(sle->at(sfRedemptionDate) == red); + } + }); + + // ClosedEnded missing one of SubscriptionDate / RedemptionDate => temMALFORMED. + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .redemptionDate = sub + minPeriod}); + env(tx, Ter{temMALFORMED}); + }); + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub}); + env(tx, Ter{temMALFORMED}); + }); + + /* + * SubscriptionDate not strictly after parent close time (preclaim, state-dependent - + * returns tecEXPIRED). This is the only reachable path to tecEXPIRED in VaultCreate; see + * the note below the next case. Note: there is no separate "expired RedemptionDate" test + * case here. preflight enforces red >= sub + kMinInvestmentPeriod, so any past + * RedemptionDate implies a strictly-earlier, equally-past SubscriptionDate; the + * SubscriptionDate check above short-circuits first. The RedemptionDate arm of the + * hasExpired check in VaultCreate::preclaim is defensive and unreachable as the sole cause + * of tecEXPIRED. + */ + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const nowSec = env.now().time_since_epoch().count(); + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = nowSec, + .redemptionDate = nowSec + minPeriod}); + env(tx, Ter{tecEXPIRED}); + }); + + /* + * Gap smaller than MIN_INVESTMENT_PERIOD => temMALFORMED. Includes the SubscriptionDate >= + * RedemptionDate degenerate cases: the red == sub boundary and the strictly-reversed red < + * sub case, the latter yielding a negative signed int64 gap that is caught by the + * sub-minimum branch of the gap check. + */ + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = sub + minPeriod - 1}); + env(tx, Ter{temMALFORMED}); + }); + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = sub}); + env(tx, Ter{temMALFORMED}); + }); + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = sub - 1}); + env(tx, Ter{temMALFORMED}); + }); + + // Gap equal to MAX_INVESTMENT_PERIOD => temMALFORMED (bound is half-open on the right). + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = sub + maxPeriod}); + env(tx, Ter{temMALFORMED}); + }); + + // Gap strictly greater than MAX_INVESTMENT_PERIOD => temMALFORMED. Same code path as + // gap == MAX_INVESTMENT_PERIOD above, but covers the "gap >= MAX" bullet fully. + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = sub + maxPeriod + 1}); + env(tx, Ter{temMALFORMED}); + }); + + // Happy path: gap exactly equal to MIN_INVESTMENT_PERIOD is accepted (lower bound is + // inclusive). + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto const red = sub + minPeriod; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = red}); + env(tx); + env.close(); + auto const sle = env.le(keylet); + if (BEAST_EXPECT(sle)) + { + BEAST_EXPECT(sle->at(sfRedemptionDate) == red); + } + }); + + // Happy path: gap one second less than MAX_INVESTMENT_PERIOD is + // accepted (upper bound is exclusive). + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto const red = sub + maxPeriod - 1; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = red}); + env(tx); + env.close(); + auto const sle = env.le(keylet); + if (BEAST_EXPECT(sle)) + { + BEAST_EXPECT(sle->at(sfRedemptionDate) == red); + } + }); + + // OpenEnded (absent/0) with SubscriptionDate or RedemptionDate present + // => temMALFORMED. + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto [tx, keylet] = + vault.create({.owner = owner, .asset = asset, .subscriptionDate = sub}); + env(tx, Ter{temMALFORMED}); + }); + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto [tx, keylet] = + vault.create({.owner = owner, .asset = asset, .redemptionDate = sub + minPeriod}); + env(tx, Ter{temMALFORMED}); + }); + + // Unrecognised VaultKind => temMALFORMED. + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = static_cast(closedEnded + 1)}); + env(tx, Ter{temMALFORMED}); + }); + + // Happy path: open-ended vault (no new fields present) is unaffected. + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + env(tx); + env.close(); + auto const sle = env.le(keylet); + if (BEAST_EXPECT(sle)) + { + BEAST_EXPECT(!sle->isFieldPresent(sfVaultKind)); + BEAST_EXPECT(!sle->isFieldPresent(sfSubscriptionDate)); + BEAST_EXPECT(!sle->isFieldPresent(sfRedemptionDate)); + } + }); + + // Happy path: explicit `VaultKind = 0` (OpenEnded) behaves the same + // as absent. Per spec, absent and OpenEnded are equivalent. + withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) { + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = std::to_underlying(VaultKind::OpenEnded)}); + env(tx); + env.close(); + auto const sle = env.le(keylet); + if (BEAST_EXPECT(sle)) + { + // OpenEnded is sfVaultKind's default; SoeDefault fields + // aren't serialized when they hold the default value. + BEAST_EXPECT(!sle->isFieldPresent(sfVaultKind)); + BEAST_EXPECT(!sle->isFieldPresent(sfSubscriptionDate)); + BEAST_EXPECT(!sle->isFieldPresent(sfRedemptionDate)); + } + }); + } + + // Phase derivation across the SubscriptionDate / RedemptionDate boundaries, including the now + // == SubscriptionDate case (which must still resolve to Subscription). + void + testVaultPhaseDerivation() + { + testcase("closed-ended phase derivation"); + using namespace test::jtx; + + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + Account const depositor{"depositor"}; + env.fund(XRP(1000), owner, depositor); + env.close(); + + Asset const asset = xrpIssue(); + auto const [vault, keylet, sub, red] = + makeClosedEndedVault(env, owner, asset, 60u, kMinInvestmentPeriod); + + // Pre-seed shares during Subscription so the depositor has capital to + // withdraw at the Redemption boundary below. + env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = XRP(10).value()})); + env.close(); + + auto const deposit = + [&](TER expected, std::source_location const& loc = std::source_location::current()) { + env( + WithSourceLocation{ + vault.deposit( + {.depositor = depositor, .id = keylet.key, .amount = XRP(1).value()}), + loc}, + Ter{expected}); + }; + auto const withdraw = + [&](TER expected, std::source_location const& loc = std::source_location::current()) { + env( + WithSourceLocation{ + vault.withdraw( + {.depositor = depositor, .id = keylet.key, .amount = XRP(1).value()}), + loc}, + Ter{expected}); + }; + + auto const runTest = [&](TER expectedDeposit, + TER expectedWithdraw, + std::source_location const& loc = + std::source_location::current()) { + deposit(expectedDeposit, loc); + withdraw(expectedWithdraw, loc); + }; + + // Assert both deposit and withdraw return codes at each point so the + // active phase is uniquely identified: + // Subscription: deposit tesSUCCESS, withdraw tesSUCCESS + // Investment: deposit tecEXPIRED, withdraw tecTOO_SOON + // Redemption: deposit tecEXPIRED, withdraw tesSUCCESS + + // Ledger time comfortably before SubscriptionDate: Subscription. + runTest(tesSUCCESS, tesSUCCESS); + + // Boundary: parent close time exactly at SubscriptionDate must still + // be Subscription. + closeToTime(env, tp{d{sub}}); + runTest(tesSUCCESS, tesSUCCESS); + + // One second past SubscriptionDate: Investment. + closeToTime(env, tp{d{sub}} + getLedgerTimeResolution(env)); + runTest(tecEXPIRED, tecTOO_SOON); + + // Any point strictly before RedemptionDate remains Investment. + closeToTime(env, tp{d{red}} - getLedgerTimeResolution(env)); + runTest(tecEXPIRED, tecTOO_SOON); + + // Boundary: parent close time == RedemptionDate is Redemption (per + // spec table: now >= RedemptionDate). Deposits are rejected but + // withdrawals succeed. + closeToTime(env, tp{d{red}}); + runTest(tecEXPIRED, tesSUCCESS); + env.close(); + } + + // Open-ended vaults are always in VaultPhase::NoPhase, regardless of the ledger clock or any + // dates present on the vault. + void + testVaultPhaseDerivationOpenEnded() + { + testcase("open-ended phase derivation"); + using namespace test::jtx; + + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + env.fund(XRP(1000), owner); + env.close(); + + Asset const asset = xrpIssue(); + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + env(tx); + env.close(); + + auto const checkPhaseAt = [&](NetClock::time_point at) { + closeToTime(env, at); + auto const sle = env.le(keylet); + if (!BEAST_EXPECT(sle)) + return; + BEAST_EXPECT(getVaultPhase(*env.current(), sle) == VaultPhase::NoPhase); + }; + + // Advance the clock through a wide range of ledger times: an open-ended vault's phase + // must be NoPhase at every one of them, because the derivation short-circuits on + // VaultKind::OpenEnded before it looks at any dates. + auto const ledgerTime = tp{d{30}} + env.closed()->header().closeTimeResolution; + checkPhaseAt(ledgerTime); + checkPhaseAt(ledgerTime + std::chrono::seconds{kMinInvestmentPeriod}); + checkPhaseAt( + ledgerTime + std::chrono::seconds{kMaxInvestmentPeriod} - + env.closed()->header().closeTimeResolution); + } + + // VaultDeposit is allowed only during Subscription (or NoPhase). Rejected during Investment and + // Redemption. + void + testVaultDepositClosedEnded() + { + testcase("closed-ended VaultDeposit phase gating"); + using namespace test::jtx; + + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + Account const depositor{"depositor"}; + env.fund(XRP(1000), owner, depositor); + env.close(); + + Asset const asset = xrpIssue(); + auto const [vault, keylet, sub, red] = + makeClosedEndedVault(env, owner, asset, 60u, kMinInvestmentPeriod); + + auto const deposit = + [&](TER expected, std::source_location const& loc = std::source_location::current()) { + env( + WithSourceLocation{ + vault.deposit( + {.depositor = depositor, .id = keylet.key, .amount = XRP(1).value()}), + loc}, + Ter{expected}); + env.close(); + }; + + // Subscription: allowed. + deposit(tesSUCCESS); + + // Investment: rejected. + env.close(tp{d{sub + 1}}); + deposit(tecEXPIRED); + + // Redemption: rejected. + env.close(tp{d{red}}); + deposit(tecEXPIRED); + } + + // VaultWithdraw is allowed in Subscription and Redemption; rejected in Investment. The + // AssetsAvailable cap continues to apply and is exercised in Redemption against a vault with + // capital deployed as an outstanding loan. + void + testVaultWithdrawClosedEnded() + { + testcase("closed-ended VaultWithdraw phase gating"); + using namespace test::jtx; + using namespace loan_broker; + using namespace loan; + + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + Account const depositor{"depositor"}; + Account const borrower{"borrower"}; + env.fund(XRP(10'000), owner, depositor, borrower); + env.close(); + + Asset const asset = xrpIssue(); + // Widen the Investment window so a single-payment loan (min payment + // interval kMinPaymentInterval = 60s) fits before RedemptionDate. + auto const [vault, keylet, sub, red] = + makeClosedEndedVault(env, owner, asset, 60u, kMinInvestmentPeriod + 3600u); + + // Deposit XRP(100) in Subscription so the depositor's shares are + // worth XRP(100). The vault holds XRP(100) with + // AssetsAvailable == AssetsTotal. + env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = XRP(100).value()})); + env.close(); + + // Create a loan broker backed by this vault. LoanBrokerSet has no + // phase gate, so this is fine to do in Subscription. + auto const brokerKeylet = + keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); + env(loan_broker::set(owner, keylet.key)); + env.close(); + + auto const withdraw = [&](STAmount const& amount, + TER expected, + std::source_location const& loc = + std::source_location::current()) { + env( + WithSourceLocation{ + vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = amount}), + loc}, + Ter{expected}); + env.close(); + }; + + // Subscription: allowed (LP cancel). + withdraw(XRP(1).value(), tesSUCCESS); + + // Investment: rejected. + closeToTime(env, tp{d{sub}} + getLedgerTimeResolution(env)); + withdraw(XRP(1).value(), tecTOO_SOON); + + // Deploy capital: borrower takes a loan of XRP(60) against the + // vault, dropping AssetsAvailable to ~XRP(39) while AssetsTotal + // remains ~XRP(99). + env(loan::set(borrower, brokerKeylet.key, XRP(60).value()), + loan::kInterestRate(TenthBips32(0)), + kGracePeriod(60), + kPaymentInterval(60), + kPaymentTotal(1), + Sig(sfCounterpartySignature, owner), + Fee(env.current()->fees().base * 2)); + env.close(); + + // Redemption: withdrawals are allowed but subject to the AssetsAvailable cap. A small + // withdrawal within AssetsAvailable succeeds. A withdrawal within the depositor's share + // value but exceeding the vault's liquid balance fails with tecINSUFFICIENT_FUNDS from the + // vault-shortage guard (not the insufficient-shares guard). + closeToTime(env, tp{d{red}}); + withdraw(XRP(10).value(), tesSUCCESS); + withdraw(XRP(80).value(), tecINSUFFICIENT_FUNDS); + } + + // End-to-end lifecycle of a closed-ended vault (Subscription → Investment → Redemption) with + // multiple depositors and a real loan originated through the Investment leg. Exercises every + // phase transition and verifies the expected deposit, withdrawal, and lending behaviour in each + // phase. + void + testVaultClosedEndedLifecycle() + { + testcase("closed-ended vault lifecycle (subscribe → invest → redeem)"); + using namespace test::jtx; + using namespace loan_broker; + using namespace loan; + + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + Account const alice{"alice"}; + Account const bob{"bob"}; + Account const borrower{"borrower"}; + env.fund(XRP(10'000), owner, alice, bob, borrower); + env.close(); + + auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded); + Asset const asset = xrpIssue(); + // Widen the Investment window so a single-payment loan (min payment interval + // kMinPaymentInterval = 60s) fits before RedemptionDate with headroom. + auto const [vault, keylet, sub, red] = + makeClosedEndedVault(env, owner, asset, 300u, kMinInvestmentPeriod + 3600u); + + auto const sleCreate = env.le(keylet); + BEAST_EXPECT(sleCreate); + MPTIssue const shares{sleCreate->at(sfShareMPTID)}; + + auto const balancesEq = [&](STAmount const& available, STAmount const& total) { + auto const sle = env.le(keylet); + BEAST_EXPECT(sle->at(sfAssetsAvailable) == available); + BEAST_EXPECT(sle->at(sfAssetsTotal) == total); + }; + auto const availableEq = [&](STAmount const& expected) { balancesEq(expected, expected); }; + + // env.balance(account, mptIssue) name-resolves the issuer via Env::lookup, but the share + // issuer is the vault's pseudo-account and is never registered with the jtx Env. Read the + // MPToken SLE directly to avoid the lookup. + auto const sharesEq = [&](Account const& holder, std::uint64_t expected) { + auto const sle = env.le(keylet::mptoken(shares.getMptID(), holder.id())); + std::uint64_t const actual = sle ? sle->getFieldU64(sfMPTAmount) : 0u; + BEAST_EXPECT(actual == expected); + }; + + // ---- Subscription phase ---- + // A legitimate VaultSet succeeds (positive control for 3.7). + { + auto tx = vault.set({.owner = owner, .id = keylet.key}); + tx[sfData] = "AA"; + env(tx); + env.close(); + } + + // alice deposits 100 XRP. + env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()})); + env.close(); + sharesEq(alice, 100'000'000); + availableEq(XRP(100).value()); + + // bob deposits 200 XRP. + env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = XRP(200).value()})); + env.close(); + sharesEq(bob, 200'000'000); + availableEq(XRP(300).value()); + + // alice cancels 25 XRP (LP cancel is permitted in Subscription). + env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(25).value()})); + env.close(); + sharesEq(alice, 75'000'000); + availableEq(XRP(275).value()); + + // Create a loan broker backed by this vault. LoanBrokerSet has no phase gate, so it is + // fine to do in Subscription. + auto const brokerKeylet = + keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); + env(loan_broker::set(owner, keylet.key)); + env.close(); + + // ---- Investment phase (now == sub + 1) ---- + env.close(tp{d{sub + 1}}); + + // Deposits into a closed-ended vault past SubscriptionDate return tecEXPIRED. + env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(10).value()}), + Ter{tecEXPIRED}); + env.close(); + // Withdrawals from a closed-ended vault during the Investment phase return tecTOO_SOON. + env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(10).value()}), + Ter{tecTOO_SOON}); + env.close(); + + // A real loan is originated during Investment (permitted only in this phase). Zero-interest + // one-payment schedule keeps AssetsTotal unchanged (both accrual and cash-basis + // accounting recognise no interest at origination); AssetsAvailable drops by the loan + // principal. + env(loan::set(borrower, brokerKeylet.key, XRP(60).value()), + loan::kInterestRate(TenthBips32(0)), + kGracePeriod(60), + kPaymentInterval(60), + kPaymentTotal(1), + Sig(sfCounterpartySignature, owner), + Fee(env.current()->fees().base * 2)); + env.close(); + auto const sleBroker = env.le(keylet::loanBroker(brokerKeylet.key)); + BEAST_EXPECT(sleBroker); + auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1u)); + BEAST_EXPECT(env.le(loanKeylet)); + balancesEq(XRP(215).value(), XRP(275).value()); + + // Non-immutable VaultSet still works in Investment (positive control). + { + auto tx = vault.set({.owner = owner, .id = keylet.key}); + tx[sfData] = "BB"; + env(tx); + env.close(); + } + + // Depositor share balances unchanged by the loan origination; only AssetsAvailable moved. + sharesEq(alice, 75'000'000); + sharesEq(bob, 200'000'000); + + // ---- Redemption phase (now == red) ---- + env.close(tp{d{red}}); + + // Deposits into a closed-ended vault past SubscriptionDate return tecEXPIRED, in both + // Investment and Redemption. + env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(10).value()}), + Ter{tecEXPIRED}); + env.close(); + + // alice redeems her remaining 75 XRP (fits within AssetsAvailable = 215). + env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(75).value()})); + env.close(); + sharesEq(alice, 0); + balancesEq(XRP(140).value(), XRP(200).value()); + + // bob has 200 XRP-worth of shares but only 140 XRP is available (the remaining 60 XRP + // sits in the outstanding loan). A full 200 XRP withdrawal fails against the + // AssetsAvailable cap; bob redeems 140 XRP instead and is left holding 60M shares backed + // by the loan receivable — the realistic outcome when capital is still deployed at + // Redemption. + env(vault.withdraw({.depositor = bob, .id = keylet.key, .amount = XRP(200).value()}), + Ter{tecINSUFFICIENT_FUNDS}); + env.close(); + env(vault.withdraw({.depositor = bob, .id = keylet.key, .amount = XRP(140).value()})); + env.close(); + sharesEq(bob, 60'000'000); + balancesEq(XRP(0).value(), XRP(60).value()); + + // Defensive spot-check that the three immutable fields have not changed across the entire + // lifecycle. Direct immutability coverage lives with the invariant tests. + auto const sleFinal = env.le(keylet); + if (BEAST_EXPECT(sleFinal)) + { + BEAST_EXPECT(sleFinal->at(sfVaultKind) == closedEnded); + BEAST_EXPECT(sleFinal->at(sfSubscriptionDate) == sub); + BEAST_EXPECT(sleFinal->at(sfRedemptionDate) == red); + } + } + + // SubscriptionDate boundary cases at the top of the UINT32 range. + // (1) The largest legal sub picks red = UINT32_MAX exactly, which hits + // the inclusive lower bound of the kMinInvestmentPeriod gap check. + // (2) sub = UINT32_MAX must be rejected: sub + kMinInvestmentPeriod is + // unrepresentable as the tx's UINT32 sfRedemptionDate, so no red value + // can satisfy the gap check. + void + testVaultCreateSubscriptionDateBoundary() + { + testcase("closed-ended VaultCreate SubscriptionDate near UINT32_MAX"); + using namespace test::jtx; + + auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded); + Asset const asset = xrpIssue(); + + { + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + env.fund(XRP(1000), owner); + env.close(); + + Vault const vault{env}; + auto const sub = std::numeric_limits::max() - kMinInvestmentPeriod; + auto const red = std::numeric_limits::max(); + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = red}); + env(tx); + env.close(); + auto const sle = env.le(keylet); + if (BEAST_EXPECT(sle)) + { + BEAST_EXPECT(sle->at(sfSubscriptionDate) == sub); + BEAST_EXPECT(sle->at(sfRedemptionDate) == red); + } + } + + // sub = UINT32_MAX: no legal red exists because sub + kMinInvestmentPeriod + // wraps in a UINT32. Every candidate red must fall to temMALFORMED via + // the gap check in preflight. + auto const rejectAtMax = [&, this](std::uint32_t red) { + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + env.fund(XRP(1000), owner); + env.close(); + + Vault const vault{env}; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = std::numeric_limits::max(), + .redemptionDate = red}); + env(tx, Ter{temMALFORMED}); + }; + rejectAtMax(std::numeric_limits::max()); + rejectAtMax(0u); + rejectAtMax(kMinInvestmentPeriod - 1u); + } + + // A loan whose payment is made after the Investment phase has ended + // (well past its next-due-date and grace period, into Redemption) must + // still be repayable. The vault phase must not gate LoanPay. + void + testVaultLoanLatePaymentAfterInvestment() + { + testcase("closed-ended vault: late loan payment during Redemption succeeds"); + using namespace test::jtx; + using namespace loan_broker; + using namespace loan; + + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + Account const alice{"alice"}; + Account const borrower{"borrower"}; + env.fund(XRP(10'000), owner, alice, borrower); + env.close(); + + Asset const asset = xrpIssue(); + auto const [vault, keylet, sub, red] = + makeClosedEndedVault(env, owner, asset, 300u, kMinInvestmentPeriod + 3600u); + + env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()})); + env.close(); + + auto const brokerKeylet = + keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); + env(loan_broker::set(owner, keylet.key)); + env.close(); + + // Investment phase: originate a zero-interest, single-payment loan + // with a 300s payment interval and 60s grace. The payment is due + // shortly after origination and well before RedemptionDate. + env.close(tp{d{sub + 1}}); + env(loan::set(borrower, brokerKeylet.key, XRP(60).value()), + loan::kInterestRate(TenthBips32(0)), + kGracePeriod(60), + kPaymentInterval(300), + kPaymentTotal(1), + Sig(sfCounterpartySignature, owner), + Fee(env.current()->fees().base * 2)); + env.close(); + auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1u)); + BEAST_EXPECT(env.le(loanKeylet)); + + // Advance to Redemption. The payment is now past its due date and + // grace, and the vault is no longer in Investment. + closeToTime(env, tp{d{red}}); + + env(loan::pay(borrower, loanKeylet.key, XRP(60).value(), tfLoanLatePayment)); + env.close(); + + // Loan principal returned to the vault; assetsAvailable == assetsTotal. + auto const sleAfter = env.le(keylet); + if (BEAST_EXPECT(sleAfter)) + { + BEAST_EXPECT(sleAfter->at(sfAssetsAvailable) == sleAfter->at(sfAssetsTotal)); + BEAST_EXPECT(sleAfter->at(sfAssetsAvailable) == XRP(100).value()); + } + + env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()})); + env.close(); + } + + // Two concurrent loans against the same closed-ended vault in Investment + // must coexist: both loan SLEs are created, AssetsAvailable reflects the + // sum of the two outstanding principals, and each can be repaid + // independently. + void + testVaultClosedEndedMultipleLoans() + { + testcase("closed-ended vault: multiple concurrent loans in Investment"); + using namespace test::jtx; + using namespace loan_broker; + using namespace loan; + + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + Account const alice{"alice"}; + Account const bob{"bob"}; + Account const borrower1{"borrower1"}; + Account const borrower2{"borrower2"}; + env.fund(XRP(10'000), owner, alice, bob, borrower1, borrower2); + env.close(); + + Asset const asset = xrpIssue(); + auto const [vault, keylet, sub, red] = + makeClosedEndedVault(env, owner, asset, 300u, kMinInvestmentPeriod + 3600u); + + env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()})); + env.close(); + env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = XRP(100).value()})); + env.close(); + + auto const brokerKeylet = + keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); + env(loan_broker::set(owner, keylet.key)); + env.close(); + + env.close(tp{d{sub + 1}}); + + auto const originate = [&](Account const& b, STAmount const& principal) { + env(loan::set(b, brokerKeylet.key, principal), + loan::kInterestRate(TenthBips32(0)), + kGracePeriod(60), + kPaymentInterval(300), + kPaymentTotal(1), + Sig(sfCounterpartySignature, owner), + Fee(env.current()->fees().base * 2)); + env.close(); + }; + originate(borrower1, XRP(50).value()); + originate(borrower2, XRP(70).value()); + + auto const loan1 = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1u)); + auto const loan2 = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(2u)); + BEAST_EXPECT(env.le(loan1)); + BEAST_EXPECT(env.le(loan2)); + + // Zero-interest at origination: AssetsTotal unchanged, AssetsAvailable + // drops by the sum of the two loan principals. + { + auto const sle = env.le(keylet); + if (BEAST_EXPECT(sle)) + { + BEAST_EXPECT(sle->at(sfAssetsTotal) == XRP(200).value()); + BEAST_EXPECT(sle->at(sfAssetsAvailable) == XRP(80).value()); + } + } + + // Repay the first loan; the second remains outstanding. + env(loan::pay(borrower1, loan1.key, XRP(50).value())); + env.close(); + { + auto const sle = env.le(keylet); + if (BEAST_EXPECT(sle)) + { + BEAST_EXPECT(sle->at(sfAssetsTotal) == XRP(200).value()); + BEAST_EXPECT(sle->at(sfAssetsAvailable) == XRP(130).value()); + } + } + + // Repay the second loan; vault is fully liquid again. + env(loan::pay(borrower2, loan2.key, XRP(70).value())); + env.close(); + { + auto const sle = env.le(keylet); + if (BEAST_EXPECT(sle)) + { + BEAST_EXPECT(sle->at(sfAssetsAvailable) == sle->at(sfAssetsTotal)); + BEAST_EXPECT(sle->at(sfAssetsAvailable) == XRP(200).value()); + } + } + + // Redemption: both depositors withdraw in full. + env.close(tp{d{red}}); + env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()})); + env.close(); + env(vault.withdraw({.depositor = bob, .id = keylet.key, .amount = XRP(100).value()})); + env.close(); + } + + // VaultClawback has no phase gate: an issuer must be able to reclaim + // asset from a depositor in Subscription, Investment and Redemption + // alike. Uses an IOU with asfAllowTrustLineClawback so the issuer path + // is exercised (XRP clawback with an explicit amount is temMALFORMED). + void + testVaultClawbackClosedEndedPhases() + { + testcase("closed-ended vault: VaultClawback succeeds in each phase"); + using namespace test::jtx; + + Env env{*this, testableAmendments()}; + Account const issuer{"issuer"}; + Account const owner{"owner"}; + Account const alice{"alice"}; + env.fund(XRP(10'000), issuer, owner, alice); + env.close(); + + env(fset(issuer, asfAllowTrustLineClawback)); + env.close(); + + PrettyAsset const iou = issuer["IOU"]; + env.trust(iou(10'000), alice); + env(pay(issuer, alice, iou(1'000))); + env.close(); + + auto const [vault, keylet, sub, red] = + makeClosedEndedVault(env, owner, iou, 300u, kMinInvestmentPeriod + 3600u); + + env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = iou(300).value()})); + env.close(); + + auto const totalsEq = [&](STAmount const& expected) { + auto const sle = env.le(keylet); + if (BEAST_EXPECT(sle)) + BEAST_EXPECT(sle->at(sfAssetsTotal) == expected); + }; + + // Subscription phase clawback. + env(vault.clawback( + {.issuer = issuer, .id = keylet.key, .holder = alice, .amount = iou(10).value()})); + env.close(); + totalsEq(iou(290).value()); + + // Investment phase clawback. + env.close(tp{d{sub + 1}}); + env(vault.clawback( + {.issuer = issuer, .id = keylet.key, .holder = alice, .amount = iou(10).value()})); + env.close(); + totalsEq(iou(280).value()); + + // Redemption phase clawback. + env.close(tp{d{red}}); + env(vault.clawback( + {.issuer = issuer, .id = keylet.key, .holder = alice, .amount = iou(10).value()})); + env.close(); + totalsEq(iou(270).value()); + } + // Test for non-asset specific behaviors. void testCreateFailXRP() @@ -4591,6 +5608,90 @@ class Vault_test : public beast::unit_test::Suite } } + // RPC coverage: closed-ended vaults must return VaultKind, SubscriptionDate and RedemptionDate + // in both vault_info and ledger_entry responses. Open-ended vaults must not. + void + testRPCClosedEnded() + { + using namespace test::jtx; + + testcase("RPC closed-ended vault fields"); + Env env{*this, testableAmendments()}; + Account const owner{"owner"}; + Account const owner2{"owner2"}; + env.fund(XRP(1000), owner, owner2); + env.close(); + + auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded); + Asset const asset = xrpIssue(); + auto const sub = env.now().time_since_epoch().count() + 60; + auto const red = sub + kMinInvestmentPeriod; + + Vault const vault{env}; + auto [tx, keylet] = vault.create( + {.owner = owner, + .asset = asset, + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = red}); + env(tx); + env.close(); + + auto [tx2, keylet2] = vault.create({.owner = owner2, .asset = asset}); + env(tx2); + env.close(); + + auto const asUInt = [](json::Value const& jv) -> json::UInt { + return jv.isUInt() ? jv.asUInt() : json::UInt(jv.asInt()); + }; + auto const checkClosedEnded = [&](json::Value const& v) { + BEAST_EXPECT(v.isObject()); + BEAST_EXPECT(v.isMember(sfVaultKind.fieldName)); + BEAST_EXPECT(asUInt(v[sfVaultKind.fieldName]) == json::UInt(closedEnded)); + BEAST_EXPECT(v.isMember(sfSubscriptionDate.fieldName)); + BEAST_EXPECT(asUInt(v[sfSubscriptionDate.fieldName]) == json::UInt(sub)); + BEAST_EXPECT(v.isMember(sfRedemptionDate.fieldName)); + BEAST_EXPECT(asUInt(v[sfRedemptionDate.fieldName]) == json::UInt(red)); + }; + auto const checkOpenEnded = [&](json::Value const& v) { + BEAST_EXPECT(v.isObject()); + BEAST_EXPECT(!v.isMember(sfVaultKind.fieldName)); + BEAST_EXPECT(!v.isMember(sfSubscriptionDate.fieldName)); + BEAST_EXPECT(!v.isMember(sfRedemptionDate.fieldName)); + }; + + { + json::Value jvParams; + jvParams[jss::vault_id] = strHex(keylet.key); + auto jv = env.rpc("json", "vault_info", to_string(jvParams)); + BEAST_EXPECT(!jv[jss::result].isMember(jss::error)); + checkClosedEnded(jv[jss::result][jss::vault]); + } + { + json::Value jvParams; + jvParams[jss::ledger_index] = jss::validated; + jvParams[jss::vault] = strHex(keylet.key); + auto jv = env.rpc("json", "ledger_entry", to_string(jvParams)); + BEAST_EXPECT(!jv[jss::result].isMember(jss::error)); + checkClosedEnded(jv[jss::result][jss::node]); + } + { + json::Value jvParams; + jvParams[jss::vault_id] = strHex(keylet2.key); + auto jv = env.rpc("json", "vault_info", to_string(jvParams)); + BEAST_EXPECT(!jv[jss::result].isMember(jss::error)); + checkOpenEnded(jv[jss::result][jss::vault]); + } + { + json::Value jvParams; + jvParams[jss::ledger_index] = jss::validated; + jvParams[jss::vault] = strHex(keylet2.key); + auto jv = env.rpc("json", "ledger_entry", to_string(jvParams)); + BEAST_EXPECT(!jv[jss::result].isMember(jss::error)); + checkOpenEnded(jv[jss::result][jss::node]); + } + } + void testVaultClawbackBurnShares() { @@ -8579,6 +9680,16 @@ public: testCreateFailXRP(); testCreateFailIOU(); testCreateFailMPT(); + testVaultCreateClosedEnded(); + testVaultCreateSubscriptionDateBoundary(); + testVaultPhaseDerivation(); + testVaultPhaseDerivationOpenEnded(); + testVaultDepositClosedEnded(); + testVaultWithdrawClosedEnded(); + testVaultClosedEndedLifecycle(); + testVaultLoanLatePaymentAfterInvestment(); + testVaultClosedEndedMultipleLoans(); + testVaultClawbackClosedEndedPhases(); testWithMPT(); testWithIOU(); testWithDomainCheck(); @@ -8589,6 +9700,7 @@ public: testFailedPseudoAccount(); testScaleIOU(); testRPC(); + testRPCClosedEnded(); testVaultClawbackBurnShares(); testVaultClawbackAssets(); testVaultEscrowedMPT(); diff --git a/src/test/app/lending/LoanSet_test.cpp b/src/test/app/lending/LoanSet_test.cpp index 85528ee9a0..3571853b47 100644 --- a/src/test/app/lending/LoanSet_test.cpp +++ b/src/test/app/lending/LoanSet_test.cpp @@ -13,12 +13,14 @@ #include #include +#include #include #include #include #include #include #include +#include #include #include #include @@ -26,6 +28,7 @@ #include #include +#include #include #include #include @@ -592,6 +595,127 @@ private: nullptr); } + // LoanSet in a closed-ended vault — phase gating and maturity bound. + void + testLoanSetClosedEnded() + { + testcase("LoanSet closed-ended: phase and maturity bound"); + using namespace jtx; + using namespace loan; + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + // Common loan schedule used by the phase-rejection cases below. + constexpr std::uint32_t kInterval = 3600u * 24u; // 1 day + constexpr std::uint32_t kTotal = 2u; + + // featureLendingProtocolV1_1 is excluded from `all_` by convention (see the comment on + // `all_`), so callers must opt in. Closed-ended vaults are gated on this amendment; without + // it VaultCreate returns temDISABLED and every follow-on txn sees tecNO_ENTRY. + auto const withEnv = [&, this](auto&& body) { + Env env(*this, testableAmendments() | featureLendingProtocolV1_1); + env.fund(XRP(1'000'000'000), issuer, lender, borrower); + env.close(); + PrettyAsset const asset{xrpIssue(), 1'000'000}; + body(env, asset); + }; + + auto const setLoan = [&](Env& env, BrokerInfo const& broker, TER expected) { + env(set(lender, broker.brokerID, broker.asset(100).value()), + kCounterparty(borrower), + Sig(sfCounterpartySignature, borrower), + Fee(env.current()->fees().base * 5), + kPaymentTotal(kTotal), + kPaymentInterval(kInterval), + Ter(expected)); + env.close(); + }; + + // 1. Rejected during Subscription: the broker is created in Subscription (skipPhaseAdvance + // = true), then LoanSet is attempted before advancing past SubscriptionDate. + withEnv([&](Env& env, PrettyAsset const& asset) { + auto const broker = createVaultAndBroker( + env, + asset, + lender, + BrokerParameters{.vaultKind = VaultKind::ClosedEnded, .skipPhaseAdvance = true}); + setLoan(env, broker, tecTOO_SOON); + }); + + // 2. Rejected during Redemption: broker is set up normally (which lands the vault in + // Investment), then advance the clock past RedemptionDate before attempting LoanSet. + withEnv([&](Env& env, PrettyAsset const& asset) { + auto const broker = createVaultAndBroker( + env, asset, lender, BrokerParameters{.vaultKind = VaultKind::ClosedEnded}); + BEAST_EXPECT(broker.redemptionDate.has_value()); + using d = NetClock::duration; + using tp = NetClock::time_point; + env.close(tp{d{*broker.redemptionDate + 1}}); + setLoan(env, broker, tecEXPIRED); + }); + + // 3. Accepted during Investment when the schedule comfortably fits before RedemptionDate. + withEnv([&](Env& env, PrettyAsset const& asset) { + auto const broker = createVaultAndBroker( + env, asset, lender, BrokerParameters{.vaultKind = VaultKind::ClosedEnded}); + setLoan(env, broker, tesSUCCESS); + }); + + // 4. Rejected during Investment when the loan's final payment would land on or after + // RedemptionDate. Use a tight redemptionOffset and a schedule whose final payment is well + // past that boundary. + withEnv([&](Env& env, PrettyAsset const& asset) { + constexpr std::uint32_t kRedemptionOffset = 3u * 24u * 3600u; + auto const broker = createVaultAndBroker( + env, + asset, + lender, + BrokerParameters{ + .vaultKind = VaultKind::ClosedEnded, .redemptionOffset = kRedemptionOffset}); + env(set(lender, broker.brokerID, broker.asset(100).value()), + kCounterparty(borrower), + Sig(sfCounterpartySignature, borrower), + Fee(env.current()->fees().base * 5), + kPaymentTotal(10u), + kPaymentInterval(kInterval), + Ter(tecNO_PERMISSION)); + env.close(); + }); + + // 5. Boundary: schedule whose finalPayment lands exactly (RedemptionDate - 1) is accepted, + // and one second later (== RedemptionDate) is rejected. Uses payTotal = 1 so the arithmetic + // is simple: finalPayment = startDate + interval. + withEnv([&](Env& env, PrettyAsset const& asset) { + auto const broker = createVaultAndBroker( + env, asset, lender, BrokerParameters{.vaultKind = VaultKind::ClosedEnded}); + BEAST_EXPECT(broker.redemptionDate.has_value()); + + auto const startDate = env.now().time_since_epoch().count(); + auto const acceptInterval = *broker.redemptionDate - 1 - startDate; + env(set(lender, broker.brokerID, broker.asset(100).value()), + kCounterparty(borrower), + Sig(sfCounterpartySignature, borrower), + Fee(env.current()->fees().base * 5), + kPaymentTotal(1u), + kPaymentInterval(acceptInterval), + Ter(tesSUCCESS)); + env.close(); + + auto const rejectInterval = + *broker.redemptionDate - env.now().time_since_epoch().count(); + env(set(lender, broker.brokerID, broker.asset(100).value()), + kCounterparty(borrower), + Sig(sfCounterpartySignature, borrower), + Fee(env.current()->fees().base * 5), + kPaymentTotal(1u), + kPaymentInterval(rejectInterval), + Ter(tecNO_PERMISSION)); + env.close(); + }); + } + public: void run() override @@ -599,6 +723,8 @@ public: for (auto const& features : jtx::amendmentCombinations( {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) testLoanSet(features); + + testLoanSetClosedEnded(); } }; diff --git a/src/test/app/lending/LoanTestBase.h b/src/test/app/lending/LoanTestBase.h index dabdfc9bed..950b196043 100644 --- a/src/test/app/lending/LoanTestBase.h +++ b/src/test/app/lending/LoanTestBase.h @@ -95,6 +95,23 @@ protected: // tests that need finer loanScale to exercise rounding edge cases. std::optional vaultScale = std::nullopt; // NOLINT(readability-redundant-member-init) + // Vault kind axis. When ClosedEnded, createVaultAndBroker sets sfSubscriptionDate / + // sfRedemptionDate from env.now() using the offsets below and advances the ledger clock + // past SubscriptionDate so the vault is in the Investment phase by the time the broker is + // set up. Requires featureLendingProtocolV1_1. + VaultKind vaultKind = VaultKind::OpenEnded; + // Seconds past env.now() at which SubscriptionDate lands. Must be strictly positive + // (VaultCreate::preclaim rejects SubscriptionDate <= parentCloseTime). + std::uint32_t subscriptionOffset = 60; + // Seconds between SubscriptionDate and RedemptionDate. Must be >= kMinInvestmentPeriod, < + // kMaxInvestmentPeriod, and generous enough to fit any loan schedule the test runs + // (finalPayment must be strictly before RedemptionDate). Default sized to comfortably + // exceed any schedule realistic tests are likely to configure. + std::uint32_t redemptionOffset = 10u * 365u * 24u * 60u * 60u; + // When true, createVaultAndBroker skips its automatic clock advance past SubscriptionDate. + // Useful for tests that need to observe the vault while it is still in the Subscription + // phase. Ignored for open-ended vaults. + bool skipPhaseAdvance = false; [[nodiscard]] Number maxCoveredLoanValue(Number const& currentDebt) const @@ -122,15 +139,23 @@ protected: uint256 brokerID; uint256 vaultID; BrokerParameters params; + // Absolute dates resolved by createVaultAndBroker when params.vaultKind + // is ClosedEnded; std::nullopt for open-ended vaults. + std::optional subscriptionDate; + std::optional redemptionDate; BrokerInfo( jtx::PrettyAsset const& asset, Keylet const& brokerKeylet, Keylet const& vaultKeylet, - BrokerParameters p) + BrokerParameters p, + std::optional subscriptionDate = std::nullopt, + std::optional redemptionDate = std::nullopt) : asset(asset) , brokerID(brokerKeylet.key) , vaultID(vaultKeylet.key) , params(std::move(p)) + , subscriptionDate(subscriptionDate) + , redemptionDate(redemptionDate) { } @@ -461,7 +486,23 @@ protected: auto const coverRateMinValue = params.coverRateMin; - auto [tx, vaultKeylet] = vault.create({.owner = lender, .asset = asset}); + std::optional subscriptionDate; + std::optional redemptionDate; + if (params.vaultKind == VaultKind::ClosedEnded) + { + auto const nowSec = env.now().time_since_epoch().count(); + subscriptionDate = nowSec + params.subscriptionOffset; + redemptionDate = *subscriptionDate + params.redemptionOffset; + } + + auto [tx, vaultKeylet] = vault.create( + {.owner = lender, + .asset = asset, + .vaultKind = params.vaultKind == VaultKind::OpenEnded + ? std::optional{} + : std::optional{std::to_underlying(params.vaultKind)}, + .subscriptionDate = subscriptionDate, + .redemptionDate = redemptionDate}); if (params.vaultScale) tx[sfScale] = *params.vaultScale; env(tx); @@ -475,6 +516,15 @@ protected: BEAST_EXPECT(vault->at(sfAssetsAvailable) == deposit.value()); } + // For closed-ended vaults, advance past SubscriptionDate so subsequent LoanSet operations + // run in the Investment phase (unless the caller explicitly asked to stay in Subscription). + if (subscriptionDate && !params.skipPhaseAdvance) + { + using d = NetClock::duration; + using tp = NetClock::time_point; + env.close(tp{d{*subscriptionDate + 1}}); + } + auto const keylet = keylet::loanBroker(lender.id(), SeqProxy::rawSequence(env.seq(lender))); using namespace loan_broker; @@ -490,7 +540,7 @@ protected: env.close(); - return {asset, keylet, vaultKeylet, params}; + return {asset, keylet, vaultKeylet, params, subscriptionDate, redemptionDate}; } /** diff --git a/src/test/app/lending/LoanValidation_test.cpp b/src/test/app/lending/LoanValidation_test.cpp index 884384db55..c6ff22bbb3 100644 --- a/src/test/app/lending/LoanValidation_test.cpp +++ b/src/test/app/lending/LoanValidation_test.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -90,9 +91,11 @@ private: } void - testInvalidLoanSet() + testInvalidLoanSet(VaultKind vaultKind) { - testcase("Invalid LoanSet"); + testcase( + std::string("Invalid LoanSet (") + + (vaultKind == VaultKind::OpenEnded ? "open-ended" : "closed-ended") + " vault)"); using namespace jtx; using namespace loan; Account const lender{"lender"}; @@ -106,7 +109,8 @@ private: 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)}; + BrokerInfo const brokerInfo{ + createVaultAndBroker(env, issuer["IOU"], lender, {.vaultKind = vaultKind})}; auto const loanSetFee = Fee(env.current()->fees().base * 2); Number const debtMaximumRequest = brokerInfo.asset(1'000).value(); @@ -530,7 +534,8 @@ private: runAmendmentIndependent() { testDisabled(); - testInvalidLoanSet(); + for (auto const kind : {VaultKind::OpenEnded, VaultKind::ClosedEnded}) + testInvalidLoanSet(kind); testInvalidLoanDelete(); testInvalidLoanManage(); testInvalidLoanPay(); diff --git a/src/test/jtx/impl/vault.cpp b/src/test/jtx/impl/vault.cpp index baff576243..978c3864d6 100644 --- a/src/test/jtx/impl/vault.cpp +++ b/src/test/jtx/impl/vault.cpp @@ -28,6 +28,12 @@ Vault::create(CreateArgs const& args) const jv[jss::Asset] = toJson(args.asset); if (args.flags) jv[jss::Flags] = *args.flags; + if (args.vaultKind) + jv[sfVaultKind] = *args.vaultKind; + if (args.subscriptionDate) + jv[sfSubscriptionDate] = *args.subscriptionDate; + if (args.redemptionDate) + jv[sfRedemptionDate] = *args.redemptionDate; return {jv, keylet}; } diff --git a/src/test/jtx/vault.h b/src/test/jtx/vault.h index e72eae89b7..992051b61f 100644 --- a/src/test/jtx/vault.h +++ b/src/test/jtx/vault.h @@ -25,6 +25,12 @@ struct Vault Asset asset; std::optional flags = std::nullopt; // NOLINT(readability-redundant-member-init) + std::optional vaultKind = + std::nullopt; // NOLINT(readability-redundant-member-init) + std::optional subscriptionDate = + std::nullopt; // NOLINT(readability-redundant-member-init) + std::optional redemptionDate = + std::nullopt; // NOLINT(readability-redundant-member-init) }; /** diff --git a/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp b/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp index f55d01f606..26dde55563 100644 --- a/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp +++ b/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp @@ -36,6 +36,9 @@ TEST(VaultTests, BuilderSettersRoundTrip) auto const withdrawalPolicyValue = canonical_UINT8(); auto const scaleValue = canonical_UINT8(); auto const lEVersionValue = canonical_UINT8(); + auto const vaultKindValue = canonical_UINT8(); + auto const subscriptionDateValue = canonical_UINT32(); + auto const redemptionDateValue = canonical_UINT32(); VaultBuilder builder{ previousTxnIDValue, @@ -56,6 +59,9 @@ TEST(VaultTests, BuilderSettersRoundTrip) builder.setLossUnrealized(lossUnrealizedValue); builder.setScale(scaleValue); builder.setLEVersion(lEVersionValue); + builder.setVaultKind(vaultKindValue); + builder.setSubscriptionDate(subscriptionDateValue); + builder.setRedemptionDate(redemptionDateValue); builder.setLedgerIndex(index); builder.setFlags(0x1u); @@ -176,6 +182,30 @@ TEST(VaultTests, BuilderSettersRoundTrip) EXPECT_TRUE(entry.hasLEVersion()); } + { + auto const& expected = vaultKindValue; + auto const actualOpt = entry.getVaultKind(); + ASSERT_TRUE(actualOpt.has_value()); + expectEqualField(expected, *actualOpt, "sfVaultKind"); + EXPECT_TRUE(entry.hasVaultKind()); + } + + { + auto const& expected = subscriptionDateValue; + auto const actualOpt = entry.getSubscriptionDate(); + ASSERT_TRUE(actualOpt.has_value()); + expectEqualField(expected, *actualOpt, "sfSubscriptionDate"); + EXPECT_TRUE(entry.hasSubscriptionDate()); + } + + { + auto const& expected = redemptionDateValue; + auto const actualOpt = entry.getRedemptionDate(); + ASSERT_TRUE(actualOpt.has_value()); + expectEqualField(expected, *actualOpt, "sfRedemptionDate"); + EXPECT_TRUE(entry.hasRedemptionDate()); + } + EXPECT_TRUE(entry.hasLedgerIndex()); auto const ledgerIndex = entry.getLedgerIndex(); ASSERT_TRUE(ledgerIndex.has_value()); @@ -205,6 +235,9 @@ TEST(VaultTests, BuilderFromSleRoundTrip) auto const withdrawalPolicyValue = canonical_UINT8(); auto const scaleValue = canonical_UINT8(); auto const lEVersionValue = canonical_UINT8(); + auto const vaultKindValue = canonical_UINT8(); + auto const subscriptionDateValue = canonical_UINT32(); + auto const redemptionDateValue = canonical_UINT32(); auto sle = std::make_shared(Vault::entryType, index); @@ -224,6 +257,9 @@ TEST(VaultTests, BuilderFromSleRoundTrip) sle->at(sfWithdrawalPolicy) = withdrawalPolicyValue; sle->at(sfScale) = scaleValue; sle->at(sfLEVersion) = lEVersionValue; + sle->at(sfVaultKind) = vaultKindValue; + sle->at(sfSubscriptionDate) = subscriptionDateValue; + sle->at(sfRedemptionDate) = redemptionDateValue; VaultBuilder builderFromSle{sle}; EXPECT_TRUE(builderFromSle.validate()); @@ -415,6 +451,45 @@ TEST(VaultTests, BuilderFromSleRoundTrip) expectEqualField(expected, *fromBuilderOpt, "sfLEVersion"); } + { + auto const& expected = vaultKindValue; + + auto const fromSleOpt = entryFromSle.getVaultKind(); + auto const fromBuilderOpt = entryFromBuilder.getVaultKind(); + + ASSERT_TRUE(fromSleOpt.has_value()); + ASSERT_TRUE(fromBuilderOpt.has_value()); + + expectEqualField(expected, *fromSleOpt, "sfVaultKind"); + expectEqualField(expected, *fromBuilderOpt, "sfVaultKind"); + } + + { + auto const& expected = subscriptionDateValue; + + auto const fromSleOpt = entryFromSle.getSubscriptionDate(); + auto const fromBuilderOpt = entryFromBuilder.getSubscriptionDate(); + + ASSERT_TRUE(fromSleOpt.has_value()); + ASSERT_TRUE(fromBuilderOpt.has_value()); + + expectEqualField(expected, *fromSleOpt, "sfSubscriptionDate"); + expectEqualField(expected, *fromBuilderOpt, "sfSubscriptionDate"); + } + + { + auto const& expected = redemptionDateValue; + + auto const fromSleOpt = entryFromSle.getRedemptionDate(); + auto const fromBuilderOpt = entryFromBuilder.getRedemptionDate(); + + ASSERT_TRUE(fromSleOpt.has_value()); + ASSERT_TRUE(fromBuilderOpt.has_value()); + + expectEqualField(expected, *fromSleOpt, "sfRedemptionDate"); + expectEqualField(expected, *fromBuilderOpt, "sfRedemptionDate"); + } + EXPECT_EQ(entryFromSle.getKey(), index); EXPECT_EQ(entryFromBuilder.getKey(), index); } @@ -499,5 +574,11 @@ TEST(VaultTests, OptionalFieldsReturnNullopt) EXPECT_FALSE(entry.getScale().has_value()); EXPECT_FALSE(entry.hasLEVersion()); EXPECT_FALSE(entry.getLEVersion().has_value()); + EXPECT_FALSE(entry.hasVaultKind()); + EXPECT_FALSE(entry.getVaultKind().has_value()); + EXPECT_FALSE(entry.hasSubscriptionDate()); + EXPECT_FALSE(entry.getSubscriptionDate().has_value()); + EXPECT_FALSE(entry.hasRedemptionDate()); + EXPECT_FALSE(entry.getRedemptionDate().has_value()); } } diff --git a/src/tests/libxrpl/protocol_autogen/transactions/VaultCreateTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/VaultCreateTests.cpp index 9c1e14f6f4..592d40a6f6 100644 --- a/src/tests/libxrpl/protocol_autogen/transactions/VaultCreateTests.cpp +++ b/src/tests/libxrpl/protocol_autogen/transactions/VaultCreateTests.cpp @@ -36,6 +36,9 @@ TEST(TransactionsVaultCreateTests, BuilderSettersRoundTrip) auto const withdrawalPolicyValue = canonical_UINT8(); auto const dataValue = canonical_VL(); auto const scaleValue = canonical_UINT8(); + auto const vaultKindValue = canonical_UINT8(); + auto const subscriptionDateValue = canonical_UINT32(); + auto const redemptionDateValue = canonical_UINT32(); VaultCreateBuilder builder{ accountValue, @@ -51,6 +54,9 @@ TEST(TransactionsVaultCreateTests, BuilderSettersRoundTrip) builder.setWithdrawalPolicy(withdrawalPolicyValue); builder.setData(dataValue); builder.setScale(scaleValue); + builder.setVaultKind(vaultKindValue); + builder.setSubscriptionDate(subscriptionDateValue); + builder.setRedemptionDate(redemptionDateValue); auto tx = builder.build(publicKey, secretKey); @@ -122,6 +128,30 @@ TEST(TransactionsVaultCreateTests, BuilderSettersRoundTrip) EXPECT_TRUE(tx.hasScale()); } + { + auto const& expected = vaultKindValue; + auto const actualOpt = tx.getVaultKind(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfVaultKind should be present"; + expectEqualField(expected, *actualOpt, "sfVaultKind"); + EXPECT_TRUE(tx.hasVaultKind()); + } + + { + auto const& expected = subscriptionDateValue; + auto const actualOpt = tx.getSubscriptionDate(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfSubscriptionDate should be present"; + expectEqualField(expected, *actualOpt, "sfSubscriptionDate"); + EXPECT_TRUE(tx.hasSubscriptionDate()); + } + + { + auto const& expected = redemptionDateValue; + auto const actualOpt = tx.getRedemptionDate(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfRedemptionDate should be present"; + expectEqualField(expected, *actualOpt, "sfRedemptionDate"); + EXPECT_TRUE(tx.hasRedemptionDate()); + } + } // 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper, @@ -145,6 +175,9 @@ TEST(TransactionsVaultCreateTests, BuilderFromStTxRoundTrip) auto const withdrawalPolicyValue = canonical_UINT8(); auto const dataValue = canonical_VL(); auto const scaleValue = canonical_UINT8(); + auto const vaultKindValue = canonical_UINT8(); + auto const subscriptionDateValue = canonical_UINT32(); + auto const redemptionDateValue = canonical_UINT32(); // Build an initial transaction VaultCreateBuilder initialBuilder{ @@ -160,6 +193,9 @@ TEST(TransactionsVaultCreateTests, BuilderFromStTxRoundTrip) initialBuilder.setWithdrawalPolicy(withdrawalPolicyValue); initialBuilder.setData(dataValue); initialBuilder.setScale(scaleValue); + initialBuilder.setVaultKind(vaultKindValue); + initialBuilder.setSubscriptionDate(subscriptionDateValue); + initialBuilder.setRedemptionDate(redemptionDateValue); auto initialTx = initialBuilder.build(publicKey, secretKey); @@ -226,6 +262,27 @@ TEST(TransactionsVaultCreateTests, BuilderFromStTxRoundTrip) expectEqualField(expected, *actualOpt, "sfScale"); } + { + auto const& expected = vaultKindValue; + auto const actualOpt = rebuiltTx.getVaultKind(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfVaultKind should be present"; + expectEqualField(expected, *actualOpt, "sfVaultKind"); + } + + { + auto const& expected = subscriptionDateValue; + auto const actualOpt = rebuiltTx.getSubscriptionDate(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfSubscriptionDate should be present"; + expectEqualField(expected, *actualOpt, "sfSubscriptionDate"); + } + + { + auto const& expected = redemptionDateValue; + auto const actualOpt = rebuiltTx.getRedemptionDate(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfRedemptionDate should be present"; + expectEqualField(expected, *actualOpt, "sfRedemptionDate"); + } + } // 3) Verify wrapper throws when constructed from wrong transaction type. @@ -295,6 +352,12 @@ TEST(TransactionsVaultCreateTests, OptionalFieldsReturnNullopt) EXPECT_FALSE(tx.getData().has_value()); EXPECT_FALSE(tx.hasScale()); EXPECT_FALSE(tx.getScale().has_value()); + EXPECT_FALSE(tx.hasVaultKind()); + EXPECT_FALSE(tx.getVaultKind().has_value()); + EXPECT_FALSE(tx.hasSubscriptionDate()); + EXPECT_FALSE(tx.getSubscriptionDate().has_value()); + EXPECT_FALSE(tx.hasRedemptionDate()); + EXPECT_FALSE(tx.getRedemptionDate().has_value()); } } From df85d43d8a57f800f8a3147f4c9d2ecf4777fff3 Mon Sep 17 00:00:00 2001 From: Pratik Mankawde <3397372+pratikmankawde@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:54:58 +0000 Subject: [PATCH 075/102] test: Make Drop50 message drop deterministic in LedgerReplayer test (#7964) Signed-off-by: Pratik Mankawde <3397372+pratikmankawde@users.noreply.github.com> --- src/test/app/LedgerReplay_test.cpp | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/src/test/app/LedgerReplay_test.cpp b/src/test/app/LedgerReplay_test.cpp index 2e2c80d6f8..0853affab7 100644 --- a/src/test/app/LedgerReplay_test.cpp +++ b/src/test/app/LedgerReplay_test.cpp @@ -28,7 +28,6 @@ #include #include -#include #include #include #include @@ -53,6 +52,7 @@ #include #include +#include #include #include #include @@ -402,7 +402,7 @@ public: enum class PeerSetBehavior { Good, - Drop50, + DropAlternate, DropAll, DropSkipListReply, DropLedgerDeltaReply, @@ -445,17 +445,13 @@ struct TestPeerSet : public PeerSet protocol::MessageType type, std::shared_ptr const& peer) override { - int dropRate = 0; - if (behavior == PeerSetBehavior::Drop50) - { - dropRate = 50; - } - else if (behavior == PeerSetBehavior::DropAll) - { - dropRate = 100; - } + if (behavior == PeerSetBehavior::DropAll) + return; - if (randInt(1, 100) <= dropRate) + // Drop every other message deterministically. Alternating drops + // still exercise the timeout/retry path while guaranteeing every + // subtask eventually gets a reply. + if (behavior == PeerSetBehavior::DropAlternate && sendCount++ % 2 == 0) return; switch (type) @@ -500,6 +496,7 @@ struct TestPeerSet : public PeerSet LedgerReplayMsgHandler& remote; std::shared_ptr dummyPeer; PeerSetBehavior behavior; + std::atomic sendCount{0}; }; /** @@ -1397,7 +1394,7 @@ struct LedgerReplayer_test : public beast::unit_test::Suite case PeerSetBehavior::Good: testcase("good network"); break; - case PeerSetBehavior::Drop50: + case PeerSetBehavior::DropAlternate: testcase("network drops 50% messages"); break; case PeerSetBehavior::Repeat: @@ -1613,7 +1610,7 @@ struct LedgerReplayer_test : public beast::unit_test::Suite testAllInboundLedgers(4); testPeerSetBehavior(PeerSetBehavior::Good, 1); testPeerSetBehavior(PeerSetBehavior::Good); - testPeerSetBehavior(PeerSetBehavior::Drop50); + testPeerSetBehavior(PeerSetBehavior::DropAlternate); testPeerSetBehavior(PeerSetBehavior::Repeat); testStop(); testSkipListBadReply(); From 028ccea7a14178d6795705a851c7d6f6a3d17bbe Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Thu, 13 Aug 2026 17:48:35 +0000 Subject: [PATCH 076/102] build: Add curl to packaging images (#8024) --- package/Dockerfile | 7 ------- package/install-packaging-tools.sh | 12 ++++++++++++ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/package/Dockerfile b/package/Dockerfile index 6cb2a09933..978b569bd8 100644 --- a/package/Dockerfile +++ b/package/Dockerfile @@ -2,13 +2,6 @@ ARG BASE_IMAGE=debian:bookworm FROM ${BASE_IMAGE} -# Packaging runs in a vanilla distro image, so the tooling has to come -# from the distro's archive: debhelper for deb, rpm-build (and the -# systemd / find-debuginfo macros it depends on) for rpm. -# The container also uses git (real history) for -# build_pkg.sh's SOURCE_DATE_EPOCH; otherwise it falls back to a tarball -# download and the timestamp comes from wall-clock time. - COPY package/install-packaging-tools.sh /tmp/install-packaging-tools.sh RUN /tmp/install-packaging-tools.sh diff --git a/package/install-packaging-tools.sh b/package/install-packaging-tools.sh index a26159a204..06ab44ac93 100755 --- a/package/install-packaging-tools.sh +++ b/package/install-packaging-tools.sh @@ -22,12 +22,23 @@ case "${ID}" in ;; esac +# Packaging runs in a vanilla distro image, so the tooling comes from the distro's +# archive rather than from nixpkgs: +# +# - debhelper and dpkg-dev build the DEB +# - rpm-build builds the RPM, with systemd-rpm-macros and redhat-rpm-config +# supplying the systemd and find-debuginfo macros the spec uses +# - git gives build_pkg.sh a real history to read SOURCE_DATE_EPOCH from; +# without one the timestamp falls back to the wall clock +# - curl uploads the finished packages in publish_pkg.sh +# - ca-certificates lets curl and git verify TLS function install() { case "${ID}" in debian | ubuntu) apt-get update -y apt-get install -y --no-install-recommends \ ca-certificates \ + curl \ debhelper \ debhelper-compat \ dpkg-dev \ @@ -36,6 +47,7 @@ function install() { rhel | centos | rocky | almalinux) dnf install -y --setopt=install_weak_deps=False \ + curl-minimal \ git \ rpm-build \ redhat-rpm-config \ From a0074f83d35f7fec4532d48f8ad3837d1ddc311e Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Fri, 14 Aug 2026 10:06:49 +0000 Subject: [PATCH 077/102] build: Fix versioned tools for exec wrappers (#8027) --- nix/packages.nix | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/nix/packages.nix b/nix/packages.nix index 0623ff51b9..c7972c9843 100644 --- a/nix/packages.nix +++ b/nix/packages.nix @@ -50,6 +50,9 @@ let # environment (the plain stdenv compiler in the dev shell, the custom-glibc # wrappers in ci-env.nix), so those callers pass their own `package`; the # clang tooling is environment-independent and is linked in commonPackages. + # + # Exec wrappers, not symlinks: the nixpkgs clang-tools wrapper dispatches on + # `$(basename $0)-unwrapped`, which a suffixed symlink turns into a dead path. mkVersionedToolLinks = { name, @@ -57,12 +60,15 @@ let version, tools, }: - pkgs.linkFarm "${name}-${toString version}-versioned-links" ( - map (tool: { - name = "bin/${tool}-${toString version}"; - path = "${package}/bin/${tool}"; - }) tools - ); + pkgs.symlinkJoin { + name = "${name}-${toString version}-versioned-links"; + paths = map ( + tool: + pkgs.writeShellScriptBin "${tool}-${toString version}" '' + exec "${package}/bin/${tool}" "$@" + '' + ) tools; + }; # The cc-wrapper doesn't re-export gcov, but coverage tooling (gcovr) needs a # gcov that exactly matches the compiler. Surface it from a gcc `cc` output. From d34aa37b3c9e7d2a3e71c15a009680fa7279c284 Mon Sep 17 00:00:00 2001 From: Mayukha Vadari Date: Fri, 14 Aug 2026 13:49:08 +0000 Subject: [PATCH 078/102] refactor: Use std::format instead of boost::format where it fits (#7996) Co-authored-by: Timur Yalymov <36795566+tyalymov@users.noreply.github.com> Co-authored-by: Cursor Co-authored-by: Vito Tumas <5780819+Tapanito@users.noreply.github.com> Co-authored-by: Ayaz Salikhov --- include/xrpl/basics/StringUtilities.h | 1 - include/xrpl/net/HTTPClientSSLContext.h | 8 +- include/xrpl/rdb/DBInit.h | 29 +++- include/xrpl/server/Wallet.h | 4 + src/libxrpl/protocol/STLedgerEntry.cpp | 5 +- src/libxrpl/protocol/STTx.cpp | 17 +- src/libxrpl/protocol/STXChainBridge.cpp | 16 +- src/libxrpl/server/Vacuum.cpp | 4 +- src/libxrpl/server/Wallet.cpp | 11 +- src/test/app/AMMCalc_test.cpp | 4 +- src/test/core/Config_test.cpp | 63 ++++--- src/test/rpc/ServerInfo_test.cpp | 16 +- src/tests/libxrpl/protocol/STXChainBridge.cpp | 60 +++++++ src/xrpld/app/misc/Transaction.h | 4 + src/xrpld/app/misc/detail/WorkSSL.cpp | 4 +- src/xrpld/app/misc/detail/WorkSSL.h | 1 - src/xrpld/app/rdb/backend/detail/Node.cpp | 161 ++++++++++-------- src/xrpld/core/detail/Config.cpp | 11 +- src/xrpld/rpc/detail/RPCHelpers.cpp | 9 +- .../rpc/handlers/account/AccountInfo.cpp | 5 +- .../rpc/handlers/orderbook/BookOffers.cpp | 35 ++-- 21 files changed, 286 insertions(+), 182 deletions(-) create mode 100644 src/tests/libxrpl/protocol/STXChainBridge.cpp diff --git a/include/xrpl/basics/StringUtilities.h b/include/xrpl/basics/StringUtilities.h index d606613c65..e3b91c2f25 100644 --- a/include/xrpl/basics/StringUtilities.h +++ b/include/xrpl/basics/StringUtilities.h @@ -2,7 +2,6 @@ #include -#include #include #include diff --git a/include/xrpl/net/HTTPClientSSLContext.h b/include/xrpl/net/HTTPClientSSLContext.h index 51b50a084c..43467faa89 100644 --- a/include/xrpl/net/HTTPClientSSLContext.h +++ b/include/xrpl/net/HTTPClientSSLContext.h @@ -8,11 +8,11 @@ #include #include #include -#include #include #include +#include #include #include #include @@ -38,8 +38,8 @@ public: if (ec && sslVerifyDir.empty()) { - Throw(boost::str( - boost::format("Failed to set_default_verify_paths: %s") % ec.message())); + Throw( + std::format("Failed to set_default_verify_paths: {}", ec.message())); } } else @@ -54,7 +54,7 @@ public: if (ec) { Throw( - boost::str(boost::format("Failed to add verify path: %s") % ec.message())); + std::format("Failed to add verify path: {}", ec.message())); } } } diff --git a/include/xrpl/rdb/DBInit.h b/include/xrpl/rdb/DBInit.h index 10b04905f2..e6e7e87b6b 100644 --- a/include/xrpl/rdb/DBInit.h +++ b/include/xrpl/rdb/DBInit.h @@ -2,6 +2,9 @@ #include #include +#include +#include +#include namespace xrpl { @@ -9,9 +12,29 @@ namespace xrpl { // These pragmas are built at startup and applied to all database // connections, unless otherwise noted. -inline constexpr char const* kCommonDbPragmaJournal{"PRAGMA journal_mode=%s;"}; -inline constexpr char const* kCommonDbPragmaSync{"PRAGMA synchronous=%s;"}; -inline constexpr char const* kCommonDbPragmaTemp{"PRAGMA temp_store=%s;"}; +// +// They are exposed as functions rather than as format-string constants so +// that the un-substituted template can never reach sqlite: an unrecognized +// pragma value is silently ignored, so forgetting to interpolate would +// leave the setting at its default instead of failing loudly. +[[nodiscard]] inline std::string +commonDbPragmaJournal(std::string_view journalMode) +{ + return std::format("PRAGMA journal_mode={};", journalMode); +} + +[[nodiscard]] inline std::string +commonDbPragmaSync(std::string_view synchronous) +{ + return std::format("PRAGMA synchronous={};", synchronous); +} + +[[nodiscard]] inline std::string +commonDbPragmaTemp(std::string_view tempStore) +{ + return std::format("PRAGMA temp_store={};", tempStore); +} + // A warning will be logged if any lower-safety sqlite tuning settings // are used and at least this much ledger history is configured. This // includes full history nodes. This is because such a large amount of diff --git a/include/xrpl/server/Wallet.h b/include/xrpl/server/Wallet.h index ed8378989f..95486cc468 100644 --- a/include/xrpl/server/Wallet.h +++ b/include/xrpl/server/Wallet.h @@ -10,6 +10,10 @@ #include #include +// boost::optional (not std::optional) appears in the declarations below, +// because SOCI's into()/use() bindings only support boost::optional. +#include + #include #include #include diff --git a/src/libxrpl/protocol/STLedgerEntry.cpp b/src/libxrpl/protocol/STLedgerEntry.cpp index 8c5c5b5eae..9ee8d030ff 100644 --- a/src/libxrpl/protocol/STLedgerEntry.cpp +++ b/src/libxrpl/protocol/STLedgerEntry.cpp @@ -18,12 +18,11 @@ #include #include -#include - #include #include #include #include +#include #include #include #include @@ -111,7 +110,7 @@ STLedgerEntry::getSType() const std::string STLedgerEntry::getText() const { - return str(boost::format("{ %s, %s }") % to_string(key_) % STObject::getText()); + return std::format("{{ {}, {} }}", to_string(key_), STObject::getText()); } json::Value diff --git a/src/libxrpl/protocol/STTx.cpp b/src/libxrpl/protocol/STTx.cpp index 7f1e19ea12..ce672b515d 100644 --- a/src/libxrpl/protocol/STTx.cpp +++ b/src/libxrpl/protocol/STTx.cpp @@ -33,13 +33,13 @@ #include #include -#include #include #include #include #include #include +#include #include #include #include @@ -399,16 +399,21 @@ STTx::getMetaSQL( TxnSql status, std::string const& escapedMetaData) const { - static boost::format const kBfTrans("('%s', '%s', '%s', '%d', '%d', '%c', %s, %s)"); std::string rTxn = sqlBlobLiteral(rawTxn.peekData()); auto format = TxFormats::getInstance().findByType(txType_); XRPL_ASSERT(format, "xrpl::STTx::getMetaSQL : non-null type format"); - return str( - boost::format(kBfTrans) % to_string(getTransactionID()) % format->getName() % - toBase58(getAccountID(sfAccount)) % getFieldU32(sfSequence) % inLedger % - safeCast(status) % rTxn % escapedMetaData); + return std::format( + "('{}', '{}', '{}', '{}', '{}', '{}', {}, {})", + to_string(getTransactionID()), + format->getName(), + toBase58(getAccountID(sfAccount)), + getFieldU32(sfSequence), + inLedger, + safeCast(status), + rTxn, + escapedMetaData); } static std::expected diff --git a/src/libxrpl/protocol/STXChainBridge.cpp b/src/libxrpl/protocol/STXChainBridge.cpp index 005c9ccbce..f9f1fd1dcc 100644 --- a/src/libxrpl/protocol/STXChainBridge.cpp +++ b/src/libxrpl/protocol/STXChainBridge.cpp @@ -11,9 +11,8 @@ #include #include -#include - #include +#include #include #include #include @@ -141,10 +140,15 @@ STXChainBridge::getJson(JsonOptions jo) const std::string STXChainBridge::getText() const { - return str( - boost::format("{ %s = %s, %s = %s, %s = %s, %s = %s }") % sfLockingChainDoor.getName() % - lockingChainDoor_.getText() % sfLockingChainIssue.getName() % lockingChainIssue_.getText() % - sfIssuingChainDoor.getName() % issuingChainDoor_.getText() % sfIssuingChainIssue.getName() % + return std::format( + "{{ {} = {}, {} = {}, {} = {}, {} = {} }}", + sfLockingChainDoor.getName(), + lockingChainDoor_.getText(), + sfLockingChainIssue.getName(), + lockingChainIssue_.getText(), + sfIssuingChainDoor.getName(), + issuingChainDoor_.getText(), + sfIssuingChainIssue.getName(), issuingChainIssue_.getText()); } diff --git a/src/libxrpl/server/Vacuum.cpp b/src/libxrpl/server/Vacuum.cpp index df768d509a..c952e722b8 100644 --- a/src/libxrpl/server/Vacuum.cpp +++ b/src/libxrpl/server/Vacuum.cpp @@ -5,8 +5,6 @@ #include #include -#include // IWYU pragma: keep - #include #include @@ -40,7 +38,7 @@ doVacuumDB(DatabaseCon::Setup const& setup, beast::Journal j) // Only the most trivial databases will fit in memory on typical // (recommended) hardware. Force temp files to be written to disk // regardless of the config settings. - session << boost::format(kCommonDbPragmaTemp) % "file"; + session << commonDbPragmaTemp("file"); session << "PRAGMA page_size;", soci::into(pageSize); std::cout << "VACUUM beginning. page_size: " << pageSize << std::endl; diff --git a/src/libxrpl/server/Wallet.cpp b/src/libxrpl/server/Wallet.cpp index 42ac80ef3f..56d0db67d4 100644 --- a/src/libxrpl/server/Wallet.cpp +++ b/src/libxrpl/server/Wallet.cpp @@ -16,7 +16,6 @@ #include #include -#include #include // IWYU pragma: keep #include // IWYU pragma: keep @@ -30,6 +29,7 @@ #include #include +#include #include #include #include @@ -172,11 +172,10 @@ getNodeIdentity(soci::session& session) // If a valid identity wasn't found, we randomly generate a new one: auto [newpublicKey, newsecretKey] = randomKeyPair(KeyType::Secp256k1); - session << str( - boost::format( - "INSERT INTO NodeIdentity (PublicKey,PrivateKey) " - "VALUES ('%s','%s');") % - toBase58(TokenType::NodePublic, newpublicKey) % + session << std::format( + "INSERT INTO NodeIdentity (PublicKey,PrivateKey) " + "VALUES ('{}','{}');", + toBase58(TokenType::NodePublic, newpublicKey), toBase58(TokenType::NodePrivate, newsecretKey)); return {newpublicKey, newsecretKey}; diff --git a/src/test/app/AMMCalc_test.cpp b/src/test/app/AMMCalc_test.cpp index 74080e669c..23f251d57a 100644 --- a/src/test/app/AMMCalc_test.cpp +++ b/src/test/app/AMMCalc_test.cpp @@ -20,6 +20,7 @@ #include #include +#include #include #include #include @@ -188,8 +189,7 @@ class AMMCalc_test : public beast::unit_test::Suite static std::string toString(STAmount const& a) { - return (boost::format("%s/%s") % a.getText() % ::xrpl::to_string(a.get().currency)) - .str(); + return std::format("{}/{}", a.getText(), ::xrpl::to_string(a.get().currency)); } static STAmount diff --git a/src/test/core/Config_test.cpp b/src/test/core/Config_test.cpp index dec6393010..5ed5ef4049 100644 --- a/src/test/core/Config_test.cpp +++ b/src/test/core/Config_test.cpp @@ -10,8 +10,6 @@ #include // IWYU pragma: keep #include -#include // IWYU pragma: keep -#include #include #include @@ -20,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -36,7 +35,7 @@ namespace detail { std::string configContents(std::string const& dbPath, std::string const& validatorsFile) { - static boost::format kConfigContentsTemplate(R"xrpldConfig( + static constexpr char const* kConfigContentsTemplate = R"xrpldConfig( [server] port_rpc port_peer @@ -83,9 +82,9 @@ cache_mb=256 file_size_mb=8 file_size_mult=2 -%1% +{} -%2% +{} # This needs to be an absolute directory reference, not a relative one. # Modify this value as required. @@ -106,7 +105,7 @@ r.ripple.com 51235 # Turn down default logging to save disk space in the long run. # Valid values here are trace, debug, info, warning, error, and fatal [rpc_startup] -{ "command": "log_level", "severity": "warning" } +{{ "command": "log_level", "severity": "warning" }} # Defaults to 1 ("yes") so that certificates will be validated. To allow the use # of self-signed certificates for development or internal use, set to 0 ("no"). @@ -115,12 +114,12 @@ r.ripple.com 51235 [sqdb] backend=sqlite -)xrpldConfig"); +)xrpldConfig"; std::string dbPathSection = dbPath.empty() ? "" : "[database_path]\n" + dbPath; std::string valFileSection = validatorsFile.empty() ? "" : "[validators_file]\n" + validatorsFile; - return boost::str(kConfigContentsTemplate % dbPathSection % valFileSection); + return std::format(kConfigContentsTemplate, dbPathSection, valFileSection); } /** @@ -427,7 +426,7 @@ port_wss_admin using namespace std::filesystem; { - boost::format cc("[database_path]\n%1%\n"); + constexpr char const* cc = "[database_path]\n{}\n"; auto const cwd = current_path(); path const dataDirRel("test_data_dir"); @@ -435,13 +434,13 @@ port_wss_admin { // Dummy test - do we get back what we put in Config c; - c.loadFromString(boost::str(cc % dataDirAbs.string())); + c.loadFromString(std::format(cc, dataDirAbs.string())); BEAST_EXPECT(c.legacy(Sections::kDatabasePath) == dataDirAbs.string()); } { // Rel paths should convert to abs paths Config c; - c.loadFromString(boost::str(cc % dataDirRel.string())); + c.loadFromString(std::format(cc, dataDirRel.string())); BEAST_EXPECT(c.legacy(Sections::kDatabasePath) == dataDirAbs.string()); } { @@ -508,20 +507,20 @@ port_wss_admin { Config c; - static boost::format kConfigTemplate(R"xrpldConfig( + static constexpr char const* kConfigTemplate = R"xrpldConfig( [validation_seed] -%1% +{} [validator_token] -%2% -)xrpldConfig"); +{} +)xrpldConfig"; std::string error; auto const expectedError = "Cannot have both [validation_seed] " "and [validator_token] config sections"; try { - c.loadFromString(boost::str(kConfigTemplate % validationSeed % token)); + c.loadFromString(std::format(kConfigTemplate, validationSeed, token)); } catch (std::runtime_error const& e) { @@ -604,7 +603,7 @@ main using namespace std::filesystem; { // load should throw for missing specified validators file - boost::format cc("[validators_file]\n%1%\n"); + constexpr char const* cc = "[validators_file]\n{}\n"; std::string error; std::string const missingPath = "/no/way/this/path/exists"; auto const expectedError = @@ -612,7 +611,7 @@ main try { Config c; - c.loadFromString(boost::str(cc % missingPath)); + c.loadFromString(std::format(cc, missingPath)); } catch (std::runtime_error const& e) { @@ -624,14 +623,14 @@ main // load should throw for invalid [validators_file] detail::ValidatorsTxtGuard const vtg(*this, "test_cfg", "validators.cfg"); path const invalidFile = current_path() / vtg.subdir(); - boost::format cc("[validators_file]\n%1%\n"); + constexpr char const* cc = "[validators_file]\n{}\n"; std::string error; auto const expectedError = "Invalid file specified in [validators_file]: " + invalidFile.string(); try { Config c; - c.loadFromString(boost::str(cc % invalidFile.string())); + c.loadFromString(std::format(cc, invalidFile.string())); } catch (std::runtime_error const& e) { @@ -829,8 +828,8 @@ trust-these-validators.gov detail::ValidatorsTxtGuard const vtg(*this, "test_cfg", "validators.cfg"); BEAST_EXPECT(vtg.validatorsFileExists()); Config c; - boost::format cc("[validators_file]\n%1%\n"); - c.loadFromString(boost::str(cc % vtg.validatorsFile())); + constexpr char const* cc = "[validators_file]\n{}\n"; + c.loadFromString(std::format(cc, vtg.validatorsFile())); BEAST_EXPECT(c.legacy(Sections::kValidatorsFile) == vtg.validatorsFile()); BEAST_EXPECT(c.section(Sections::kValidators).values().size() == 8); BEAST_EXPECT(c.section(Sections::kValidatorListSites).values().size() == 2); @@ -909,9 +908,9 @@ trust-these-validators.gov { // load validators from both config and validators file - boost::format cc(R"xrpldConfig( + constexpr char const* cc = R"xrpldConfig( [validators_file] -%1% +{} [validators] n949f75evCHwgyP4fPVgaHqNHxUVN15PsJEZ3B3HnXPcPjcZAoy7 @@ -930,11 +929,11 @@ trust-these-validators.gov [validator_list_keys] 021A99A537FDEBC34E4FCA03B39BEADD04299BB19E85097EC92B15A3518801E566 -)xrpldConfig"); +)xrpldConfig"; detail::ValidatorsTxtGuard const vtg(*this, "test_cfg", "validators.cfg"); BEAST_EXPECT(vtg.validatorsFileExists()); Config c; - c.loadFromString(boost::str(cc % vtg.validatorsFile())); + c.loadFromString(std::format(cc, vtg.validatorsFile())); BEAST_EXPECT(c.legacy(Sections::kValidatorsFile) == vtg.validatorsFile()); BEAST_EXPECT(c.section(Sections::kValidators).values().size() == 15); BEAST_EXPECT(c.section(Sections::kValidatorListSites).values().size() == 4); @@ -945,13 +944,13 @@ trust-these-validators.gov { // load should throw if [validator_list_threshold] is present both // in xrpld.cfg and validators file - boost::format cc(R"xrpldConfig( + constexpr char const* cc = R"xrpldConfig( [validators_file] -%1% +{} [validator_list_threshold] 1 -)xrpldConfig"); +)xrpldConfig"; std::string error; detail::ValidatorsTxtGuard const vtg(*this, "test_cfg", "validators.cfg"); BEAST_EXPECT(vtg.validatorsFileExists()); @@ -961,7 +960,7 @@ trust-these-validators.gov try { Config c; - c.loadFromString(boost::str(cc % vtg.validatorsFile())); + c.loadFromString(std::format(cc, vtg.validatorsFile())); fail(); } catch (std::runtime_error const& e) @@ -975,7 +974,7 @@ trust-these-validators.gov // [validator_list_keys] are missing from xrpld.cfg and // validators file Config const c; - boost::format cc("[validators_file]\n%1%\n"); + constexpr char const* cc = "[validators_file]\n{}\n"; std::string error; detail::ValidatorsTxtGuard const vtg(*this, "test_cfg", "validators.cfg"); BEAST_EXPECT(vtg.validatorsFileExists()); @@ -988,7 +987,7 @@ trust-these-validators.gov try { Config c2; - c2.loadFromString(boost::str(cc % vtg.validatorsFile())); + c2.loadFromString(std::format(cc, vtg.validatorsFile())); } catch (std::runtime_error const& e) { diff --git a/src/test/rpc/ServerInfo_test.cpp b/src/test/rpc/ServerInfo_test.cpp index 52a1e6cdb0..100ae0e49b 100644 --- a/src/test/rpc/ServerInfo_test.cpp +++ b/src/test/rpc/ServerInfo_test.cpp @@ -9,8 +9,7 @@ #include #include -#include - +#include #include namespace xrpl::test { @@ -36,12 +35,13 @@ public: makeValidatorConfig() { auto p = std::make_unique(); - boost::format toLoad(R"xrpldConfig( + auto const toLoad = std::format( + R"xrpldConfig( [validator_token] -%1% +{} [validators] -%2% +{} [port_grpc] ip = 0.0.0.0 @@ -52,9 +52,11 @@ ip = 0.0.0.0 port = 50052 protocol = wss2 admin = 127.0.0.1 -)xrpldConfig"); +)xrpldConfig", + validator_data::kToken, + validator_data::kPublicKey); - p->loadFromString(boost::str(toLoad % validator_data::kToken % validator_data::kPublicKey)); + p->loadFromString(toLoad); setupConfigForUnitTests(*p); diff --git a/src/tests/libxrpl/protocol/STXChainBridge.cpp b/src/tests/libxrpl/protocol/STXChainBridge.cpp new file mode 100644 index 0000000000..f4e6e60cc9 --- /dev/null +++ b/src/tests/libxrpl/protocol/STXChainBridge.cpp @@ -0,0 +1,60 @@ +#include + +#include +#include +#include + +#include + +#include +#include + +using namespace xrpl; + +namespace { + +// Built from raw bytes rather than base58 so the test does not depend on +// hand-computed checksums. +AccountID +account(std::string_view hex) +{ + AccountID id; + EXPECT_TRUE(id.parseHex(hex)); + return id; +} + +} // namespace + +// getText() builds its string from eight substitutions of the same type, so a +// transposed pair would still compile and still type check. Pin the output so +// the field/value pairing is actually verified. +TEST(STXChainBridge, getTextPairsEachFieldWithItsValue) +{ + auto const lockingDoor = account("0102030405060708090A0B0C0D0E0F1011121314"); + auto const issuingDoor = account("14131211100F0E0D0C0B0A090807060504030201"); + + auto const lockingIssue = xrpIssue(); + Issue const issuingIssue{toCurrency("USD"), issuingDoor}; + + STXChainBridge const bridge{lockingDoor, lockingIssue, issuingDoor, issuingIssue}; + + std::string const expected = "{ LockingChainDoor = " + toBase58(lockingDoor) + + ", LockingChainIssue = " + lockingIssue.getText() + + ", IssuingChainDoor = " + toBase58(issuingDoor) + + ", IssuingChainIssue = " + issuingIssue.getText() + " }"; + + EXPECT_EQ(bridge.getText(), expected); +} + +TEST(STXChainBridge, getTextOnADefaultBridge) +{ + STXChainBridge const bridge; + auto const text = bridge.getText(); + + // The outer braces are literal, and the four field names appear in + // declaration order regardless of the values. + EXPECT_TRUE(text.starts_with("{ LockingChainDoor = ")); + EXPECT_TRUE(text.ends_with(" }")); + EXPECT_LT(text.find("LockingChainIssue"), text.find("IssuingChainDoor")); + EXPECT_LT(text.find("IssuingChainDoor"), text.find("IssuingChainIssue")); +} diff --git a/src/xrpld/app/misc/Transaction.h b/src/xrpld/app/misc/Transaction.h index b6b6d1a8d5..61951fbb59 100644 --- a/src/xrpld/app/misc/Transaction.h +++ b/src/xrpld/app/misc/Transaction.h @@ -15,6 +15,10 @@ #include #include +// boost::optional (not std::optional) appears in the declarations below, +// because SOCI's into()/use() bindings only support boost::optional. +#include + #include #include #include diff --git a/src/xrpld/app/misc/detail/WorkSSL.cpp b/src/xrpld/app/misc/detail/WorkSSL.cpp index e8d24b55d6..48231b147e 100644 --- a/src/xrpld/app/misc/detail/WorkSSL.cpp +++ b/src/xrpld/app/misc/detail/WorkSSL.cpp @@ -10,8 +10,8 @@ #include #include #include -#include +#include #include #include @@ -38,7 +38,7 @@ WorkSSL::WorkSSL( { auto ec = context_.preConnectVerify(stream_, host_); if (ec) - Throw(boost::str(boost::format("preConnectVerify: %s") % ec.message())); + Throw(std::format("preConnectVerify: {}", ec.message())); } void diff --git a/src/xrpld/app/misc/detail/WorkSSL.h b/src/xrpld/app/misc/detail/WorkSSL.h index d4b3b9ff25..e4b7586054 100644 --- a/src/xrpld/app/misc/detail/WorkSSL.h +++ b/src/xrpld/app/misc/detail/WorkSSL.h @@ -7,7 +7,6 @@ #include #include -#include #include #include diff --git a/src/xrpld/app/rdb/backend/detail/Node.cpp b/src/xrpld/app/rdb/backend/detail/Node.cpp index ff57087ec5..be4c5d29e5 100644 --- a/src/xrpld/app/rdb/backend/detail/Node.cpp +++ b/src/xrpld/app/rdb/backend/detail/Node.cpp @@ -40,7 +40,6 @@ #include #include -#include #include // IWYU pragma: keep #include @@ -58,6 +57,7 @@ #include #include #include +#include #include #include #include @@ -109,18 +109,16 @@ makeLedgerDBs( // ledger database auto lgr{std::make_unique( setup, kLgrDbName, setup.lgrPragma, kLgrDbInit, checkpointerSetup, j)}; - lgr->getSession() << boost::str( - boost::format("PRAGMA cache_size=-%d;") % - kilobytes(config.getValueFor(SizedItem::LgrDbCache))); + lgr->getSession() << std::format( + "PRAGMA cache_size=-{};", kilobytes(config.getValueFor(SizedItem::LgrDbCache))); if (config.useTxTables()) { // transaction database auto tx{std::make_unique( setup, kTxDbName, setup.txPragma, kTxDbInit, checkpointerSetup, j)}; - tx->getSession() << boost::str( - boost::format("PRAGMA cache_size=-%d;") % - kilobytes(config.getValueFor(SizedItem::TxnDbCache))); + tx->getSession() << std::format( + "PRAGMA cache_size=-{};", kilobytes(config.getValueFor(SizedItem::TxnDbCache))); if (!setup.standAlone || setup.startUp == StartUpType::Load || setup.startUp == StartUpType::LoadFile || setup.startUp == StartUpType::Replay) @@ -280,15 +278,17 @@ saveValidatedLedger( } { - static boost::format kDeleteLedger("DELETE FROM Ledgers WHERE LedgerSeq = %u;"); - static boost::format kDeleteTranS1("DELETE FROM Transactions WHERE LedgerSeq = %u;"); - static boost::format kDeleteTranS2("DELETE FROM AccountTransactions WHERE LedgerSeq = %u;"); - static boost::format kDeleteAcctTrans( - "DELETE FROM AccountTransactions WHERE TransID = '%s';"); + static constexpr char const* kDeleteLedger = "DELETE FROM Ledgers WHERE LedgerSeq = {};"; + static constexpr char const* kDeleteTranS1 = + "DELETE FROM Transactions WHERE LedgerSeq = {};"; + static constexpr char const* kDeleteTranS2 = + "DELETE FROM AccountTransactions WHERE LedgerSeq = {};"; + static constexpr char const* kDeleteAcctTrans = + "DELETE FROM AccountTransactions WHERE TransID = '{}';"; { auto db = ldgDB.checkoutDb(); - *db << boost::str(kDeleteLedger % seq); + *db << std::format(kDeleteLedger, seq); } if (app.config().useTxTables()) @@ -305,19 +305,19 @@ saveValidatedLedger( soci::transaction tr(*db); - *db << boost::str(kDeleteTranS1 % seq); - *db << boost::str(kDeleteTranS2 % seq); + *db << std::format(kDeleteTranS1, seq); + *db << std::format(kDeleteTranS2, seq); std::string const ledgerSeq(std::to_string(seq)); for (auto const& acceptedLedgerTx : *aLedger) { - uint256 transactionID = acceptedLedgerTx->getTransactionID(); + uint256 const transactionID = acceptedLedgerTx->getTransactionID(); std::string const txnId(to_string(transactionID)); std::string const txnSeq(std::to_string(acceptedLedgerTx->getTxnSeq())); - *db << boost::str(kDeleteAcctTrans % transactionID); + *db << std::format(kDeleteAcctTrans, txnId); auto const& accts = acceptedLedgerTx->getAffected(); @@ -629,11 +629,11 @@ getHashesByIndex(soci::session& session, LedgerIndex minSeq, LedgerIndex maxSeq, std::pair>, int> getTxHistory(soci::session& session, Application& app, LedgerIndex startIndex, int quantity) { - std::string const sql = boost::str( - boost::format( - "SELECT LedgerSeq, Status, RawTxn " - "FROM Transactions ORDER BY LedgerSeq DESC LIMIT %u,%u;") % - startIndex % quantity); + std::string const sql = std::format( + "SELECT LedgerSeq, Status, RawTxn " + "FROM Transactions ORDER BY LedgerSeq DESC LIMIT {},{};", + startIndex, + quantity); std::vector> txs; int total = 0; @@ -730,41 +730,50 @@ transactionsSQL( if (options.ledgerRange.max != 0u) { - maxClause = boost::str( - boost::format("AND AccountTransactions.LedgerSeq <= '%u'") % options.ledgerRange.max); + maxClause = + std::format("AND AccountTransactions.LedgerSeq <= '{}'", options.ledgerRange.max); } if (options.ledgerRange.min != 0u) { - minClause = boost::str( - boost::format("AND AccountTransactions.LedgerSeq >= '%u'") % options.ledgerRange.min); + minClause = + std::format("AND AccountTransactions.LedgerSeq >= '{}'", options.ledgerRange.min); } std::string sql; if (count) { - sql = boost::str( - boost::format( - "SELECT %s FROM AccountTransactions " - "WHERE Account = '%s' %s %s LIMIT %u, %u;") % - selection % toBase58(options.account) % maxClause % minClause % options.offset % + sql = std::format( + "SELECT {} FROM AccountTransactions " + "WHERE Account = '{}' {} {} LIMIT {}, {};", + selection, + toBase58(options.account), + maxClause, + minClause, + options.offset, numberOfResults); } else { - sql = boost::str( - boost::format( - "SELECT %s FROM " - "AccountTransactions INNER JOIN Transactions " - "ON Transactions.TransID = AccountTransactions.TransID " - "WHERE Account = '%s' %s %s " - "ORDER BY AccountTransactions.LedgerSeq %s, " - "AccountTransactions.TxnSeq %s, AccountTransactions.TransID %s " - "LIMIT %u, %u;") % - selection % toBase58(options.account) % maxClause % minClause % - (descending ? "DESC" : "ASC") % (descending ? "DESC" : "ASC") % - (descending ? "DESC" : "ASC") % options.offset % numberOfResults); + char const* const order = descending ? "DESC" : "ASC"; + sql = std::format( + "SELECT {} FROM " + "AccountTransactions INNER JOIN Transactions " + "ON Transactions.TransID = AccountTransactions.TransID " + "WHERE Account = '{}' {} {} " + "ORDER BY AccountTransactions.LedgerSeq {}, " + "AccountTransactions.TxnSeq {}, AccountTransactions.TransID {} " + "LIMIT {}, {};", + selection, + toBase58(options.account), + maxClause, + minClause, + order, + order, + order, + options.offset, + numberOfResults); } JLOG(j.trace()) << "txSQL query: " << sql; return sql; @@ -1105,14 +1114,6 @@ accountTxPage( std::optional newmarker; - static std::string const kPrefix( - R"(SELECT AccountTransactions.LedgerSeq,AccountTransactions.TxnSeq, - Status,RawTxn,TxnMeta - FROM AccountTransactions INNER JOIN Transactions - ON Transactions.TransID = AccountTransactions.TransID - AND AccountTransactions.Account = '%s' WHERE - )"); - std::string sql; // SQL's BETWEEN uses a closed interval ([a,b]) @@ -1121,13 +1122,22 @@ accountTxPage( if (findLedger == 0) { - sql = boost::str( - boost::format(kPrefix + R"(AccountTransactions.LedgerSeq BETWEEN %u AND %u - ORDER BY AccountTransactions.LedgerSeq %s, - AccountTransactions.TxnSeq %s - LIMIT %u;)") % - toBase58(options.account) % options.ledgerRange.min % options.ledgerRange.max % order % - order % queryLimit); + sql = std::format( + R"(SELECT AccountTransactions.LedgerSeq,AccountTransactions.TxnSeq, + Status,RawTxn,TxnMeta + FROM AccountTransactions INNER JOIN Transactions + ON Transactions.TransID = AccountTransactions.TransID + AND AccountTransactions.Account = '{}' WHERE + AccountTransactions.LedgerSeq BETWEEN {} AND {} + ORDER BY AccountTransactions.LedgerSeq {}, + AccountTransactions.TxnSeq {} + LIMIT {};)", + toBase58(options.account), + options.ledgerRange.min, + options.ledgerRange.max, + order, + order, + queryLimit); } else { @@ -1136,27 +1146,34 @@ accountTxPage( std::uint32_t const maxLedger = forward ? options.ledgerRange.max : findLedger - 1; auto b58acct = toBase58(options.account); - sql = boost::str( - boost::format( - R"(SELECT AccountTransactions.LedgerSeq,AccountTransactions.TxnSeq, + sql = std::format( + R"(SELECT AccountTransactions.LedgerSeq,AccountTransactions.TxnSeq, Status,RawTxn,TxnMeta FROM AccountTransactions, Transactions WHERE (AccountTransactions.TransID = Transactions.TransID AND - AccountTransactions.Account = '%s' AND - AccountTransactions.LedgerSeq BETWEEN %u AND %u) + AccountTransactions.Account = '{}' AND + AccountTransactions.LedgerSeq BETWEEN {} AND {}) UNION SELECT AccountTransactions.LedgerSeq,AccountTransactions.TxnSeq,Status,RawTxn,TxnMeta FROM AccountTransactions, Transactions WHERE (AccountTransactions.TransID = Transactions.TransID AND - AccountTransactions.Account = '%s' AND - AccountTransactions.LedgerSeq = %u AND - AccountTransactions.TxnSeq %s %u) - ORDER BY AccountTransactions.LedgerSeq %s, - AccountTransactions.TxnSeq %s - LIMIT %u; - )") % - b58acct % minLedger % maxLedger % b58acct % findLedger % compare % findSeq % order % - order % queryLimit); + AccountTransactions.Account = '{}' AND + AccountTransactions.LedgerSeq = {} AND + AccountTransactions.TxnSeq {} {}) + ORDER BY AccountTransactions.LedgerSeq {}, + AccountTransactions.TxnSeq {} + LIMIT {}; + )", + b58acct, + minLedger, + maxLedger, + b58acct, + findLedger, + compare, + findSeq, + order, + order, + queryLimit); } { diff --git a/src/xrpld/core/detail/Config.cpp b/src/xrpld/core/detail/Config.cpp index efe4ab1cc9..3ff62c9b64 100644 --- a/src/xrpld/core/detail/Config.cpp +++ b/src/xrpld/core/detail/Config.cpp @@ -21,7 +21,6 @@ #include #include #include -#include #include #include #include // IWYU pragma: keep @@ -34,6 +33,7 @@ #include #include #include +#include #include #include #include @@ -400,7 +400,7 @@ Config::setup(std::string const& strConf, bool bQuiet, bool bSilent, bool bStand std::filesystem::create_directories(dataDir, ec); if (ec) - Throw(boost::str(boost::format("Can not create %s") % dataDir)); + Throw(std::format("Can not create {}", dataDir.string())); legacy(Sections::kDatabasePath, std::filesystem::absolute(dataDir).string()); } @@ -1315,8 +1315,7 @@ setupDatabaseCon(Config const& c, std::optional j) boost::iequals(journalMode, "truncate") || boost::iequals(journalMode, "persist") || boost::iequals(journalMode, "wal")) { - result->emplace_back( - boost::str(boost::format(kCommonDbPragmaJournal) % journalMode)); + result->emplace_back(commonDbPragmaJournal(journalMode)); } else { @@ -1337,7 +1336,7 @@ setupDatabaseCon(Config const& c, std::optional j) if (higherRisk || boost::iequals(synchronous, "normal") || boost::iequals(synchronous, "full") || boost::iequals(synchronous, "extra")) { - result->emplace_back(boost::str(boost::format(kCommonDbPragmaSync) % synchronous)); + result->emplace_back(commonDbPragmaSync(synchronous)); } else { @@ -1358,7 +1357,7 @@ setupDatabaseCon(Config const& c, std::optional j) if (higherRisk || boost::iequals(tempStore, "default") || boost::iequals(tempStore, "file")) { - result->emplace_back(boost::str(boost::format(kCommonDbPragmaTemp) % tempStore)); + result->emplace_back(commonDbPragmaTemp(tempStore)); } else { diff --git a/src/xrpld/rpc/detail/RPCHelpers.cpp b/src/xrpld/rpc/detail/RPCHelpers.cpp index 4fa0fab6f7..321f8f5a3c 100644 --- a/src/xrpld/rpc/detail/RPCHelpers.cpp +++ b/src/xrpld/rpc/detail/RPCHelpers.cpp @@ -37,6 +37,7 @@ #include #include #include +#include #include #include #include @@ -424,7 +425,7 @@ parseSubUnsubJson( if (jv.isMember(jss::mpt_issuance_id) && (jv.isMember(jss::currency) || jv.isMember(jss::issuer))) { - JLOG(j.info()) << boost::format("Bad %s currency or MPT.") % name.cStr(); + JLOG(j.info()) << std::format("Bad {} currency or MPT.", name.cStr()); return RpcInvalidParams; } @@ -435,7 +436,7 @@ parseSubUnsubJson( if (!jv.isMember(jss::currency) || !toCurrency(issue.currency, jv[jss::currency].asString())) { - JLOG(j.info()) << boost::format("Bad %s currency.") % name.cStr(); + JLOG(j.info()) << std::format("Bad {} currency.", name.cStr()); return assetError; } @@ -445,7 +446,7 @@ parseSubUnsubJson( // Don't allow illegal issuers. || (!issue.currency != !issue.account) || noAccount() == issue.account) { - JLOG(j.info()) << boost::format("Bad %s issuer.") % name.cStr(); + JLOG(j.info()) << std::format("Bad {} issuer.", name.cStr()); return issuerError; } asset = issue; @@ -459,7 +460,7 @@ parseSubUnsubJson( } else { - JLOG(j.info()) << boost::format("Neither %s currency or MPT is present.") % name.cStr(); + JLOG(j.info()) << std::format("Neither {} currency or MPT is present.", name.cStr()); return assetError; } diff --git a/src/xrpld/rpc/handlers/account/AccountInfo.cpp b/src/xrpld/rpc/handlers/account/AccountInfo.cpp index eed4e4cfe3..6b244af1a9 100644 --- a/src/xrpld/rpc/handlers/account/AccountInfo.cpp +++ b/src/xrpld/rpc/handlers/account/AccountInfo.cpp @@ -23,10 +23,9 @@ #include #include -#include - #include #include +#include #include #include #include @@ -60,7 +59,7 @@ injectSLE(json::Value& jv, SLE const& sle) md5 = toLower(md5); // VFALCO TODO Give a name to this constant and move it // to a more visible location. - jv[jss::urlgravatar] = str(boost::format("https://www.gravatar.com/avatar/%s") % md5); + jv[jss::urlgravatar] = std::format("https://www.gravatar.com/avatar/{}", md5); } } diff --git a/src/xrpld/rpc/handlers/orderbook/BookOffers.cpp b/src/xrpld/rpc/handlers/orderbook/BookOffers.cpp index ae539a59f3..219c29d53a 100644 --- a/src/xrpld/rpc/handlers/orderbook/BookOffers.cpp +++ b/src/xrpld/rpc/handlers/orderbook/BookOffers.cpp @@ -22,6 +22,7 @@ #include #include +#include #include #include @@ -32,7 +33,7 @@ validateTakerJSON(json::Value const& taker, json::StaticString const& name) { if (!taker.isMember(jss::currency) && !taker.isMember(jss::mpt_issuance_id)) { - return rpc::missingFieldError((boost::format("%s.currency") % name.cStr()).str()); + return rpc::missingFieldError(std::format("{}.currency", name.cStr())); } if (taker.isMember(jss::mpt_issuance_id) && @@ -44,8 +45,7 @@ validateTakerJSON(json::Value const& taker, json::StaticString const& name) if ((taker.isMember(jss::currency) && !taker[jss::currency].isString()) || (taker.isMember(jss::mpt_issuance_id) && !taker[jss::mpt_issuance_id].isString())) { - return rpc::expectedFieldError( - (boost::format("%s.currency") % name.cStr()).str(), "string"); + return rpc::expectedFieldError(std::format("{}.currency", name.cStr()), "string"); } return std::nullopt; @@ -70,10 +70,9 @@ parseTakerAssetJSON( if (!toCurrency(issue.currency, taker[jss::currency].asString())) { - JLOG(j.info()) << boost::format("Bad %s currency.") % name.cStr(); + JLOG(j.info()) << std::format("Bad {} currency.", name.cStr()); return rpc::makeError( - assetError, - (boost::format("Invalid field '%s.currency', bad currency.") % name.cStr()).str()); + assetError, std::format("Invalid field '{}.currency', bad currency.", name.cStr())); } asset = issue; } @@ -83,8 +82,7 @@ parseTakerAssetJSON( if (!mptid.parseHex(taker[jss::mpt_issuance_id].asString())) { return rpc::makeError( - assetError, - (boost::format("Invalid field '%s.mpt_issuance_id'") % name.cStr()).str()); + assetError, std::format("Invalid field '{}.mpt_issuance_id'", name.cStr())); } asset = mptid; } @@ -113,24 +111,21 @@ parseTakerIssuerJSON( { if (!taker[jss::issuer].isString()) { - return rpc::expectedFieldError( - (boost::format("%s.issuer") % name.cStr()).str(), "string"); + return rpc::expectedFieldError(std::format("{}.issuer", name.cStr()), "string"); } if (!toIssuer(issue.account, taker[jss::issuer].asString())) { return rpc::makeError( issuerError, - (boost::format("Invalid field '%s.issuer', bad issuer.") % name.cStr()).str()); + std::format("Invalid field '{}.issuer', bad issuer.", name.cStr())); } if (issue.account == noAccount()) { return rpc::makeError( issuerError, - (boost::format("Invalid field '%s.issuer', bad issuer account one.") % - name.cStr()) - .str()); + std::format("Invalid field '{}.issuer', bad issuer account one.", name.cStr())); } } else @@ -142,19 +137,17 @@ parseTakerIssuerJSON( { return rpc::makeError( issuerError, - (boost::format( - "Unneeded field '%s.issuer' for XRP currency " - "specification.") % - name.cStr()) - .str()); + std::format( + "Unneeded field '{}.issuer' for XRP currency " + "specification.", + name.cStr())); } if (!isXRP(issue.currency) && isXRP(issue.account)) { return rpc::makeError( issuerError, - (boost::format("Invalid field '%s.issuer', expected non-XRP issuer.") % name.cStr()) - .str()); + std::format("Invalid field '{}.issuer', expected non-XRP issuer.", name.cStr())); } } From bd87edfc75f1ff4ee8e117e05cf37f20380fba20 Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Fri, 14 Aug 2026 14:07:55 +0000 Subject: [PATCH 079/102] test: Check versioned tools in check-tools & print nicely (#8030) --- .cspell.config.yaml | 1 + .github/scripts/strategy-matrix/linux.json | 2 +- .github/workflows/publish-docs.yml | 2 +- .github/workflows/reusable-clang-tidy.yml | 2 +- .github/workflows/reusable-upload-recipe.yml | 2 +- bin/check-tools.sh | 66 +++++-- nix/check-tools/README.md | 13 +- nix/check-tools/macos.txt | 170 ++++++++++++---- nix/check-tools/nix-ubuntu-amd64.txt | 198 +++++++++++++++---- nix/check-tools/nix-ubuntu-arm64.txt | 198 +++++++++++++++---- 10 files changed, 511 insertions(+), 143 deletions(-) diff --git a/.cspell.config.yaml b/.cspell.config.yaml index bb763e9935..ec9f87cfdd 100644 --- a/.cspell.config.yaml +++ b/.cspell.config.yaml @@ -7,6 +7,7 @@ ignorePaths: - cmake/** - LICENSE.md - .clang-tidy + - nix/check-tools/*.txt # generated, and full of Nix store hashes language: en allowCompoundWords: true # TODO (#6334) ignoreRandomStrings: true diff --git a/.github/scripts/strategy-matrix/linux.json b/.github/scripts/strategy-matrix/linux.json index 33146cff3b..97163fb8ce 100644 --- a/.github/scripts/strategy-matrix/linux.json +++ b/.github/scripts/strategy-matrix/linux.json @@ -1,5 +1,5 @@ { - "image_tag": "sha-fecfc0c", + "image_tag": "sha-a0074f8", "configs": { "ubuntu": [ { diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index a3e096315c..6e973a251d 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -41,7 +41,7 @@ env: jobs: build: runs-on: ubuntu-latest - container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-fecfc0c + container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-a0074f8 steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/reusable-clang-tidy.yml b/.github/workflows/reusable-clang-tidy.yml index f1fdc0569a..2049b1ce55 100644 --- a/.github/workflows/reusable-clang-tidy.yml +++ b/.github/workflows/reusable-clang-tidy.yml @@ -34,7 +34,7 @@ jobs: needs: [determine-files] if: ${{ needs.determine-files.outputs.cpp_changed_files != '' || needs.determine-files.outputs.need_full_run == 'true' }} runs-on: ["self-hosted", "Linux", "X64", "heavy"] - container: "ghcr.io/xrplf/xrpld/nix-debian:sha-fecfc0c" + container: "ghcr.io/xrplf/xrpld/nix-debian:sha-a0074f8" permissions: contents: read issues: write diff --git a/.github/workflows/reusable-upload-recipe.yml b/.github/workflows/reusable-upload-recipe.yml index b4ab638dee..a8d35fadad 100644 --- a/.github/workflows/reusable-upload-recipe.yml +++ b/.github/workflows/reusable-upload-recipe.yml @@ -40,7 +40,7 @@ defaults: jobs: upload: runs-on: ubuntu-latest - container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-fecfc0c + container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-a0074f8 env: REMOTE_NAME: ${{ inputs.remote_name }} CONAN_LOGIN_USERNAME_XRPLF: ${{ secrets.remote_username }} diff --git a/bin/check-tools.sh b/bin/check-tools.sh index e230302742..8273375428 100755 --- a/bin/check-tools.sh +++ b/bin/check-tools.sh @@ -15,10 +15,14 @@ # - Windows: the core build tools only (CMake, Conan, Git, Python). # MSVC is expected to be provided separately and is not checked here. # -# Some tools (clang-format, doxygen, gcovr, gh, git-cliff, gpg, pre-commit, -# run-clang-tidy) are present in our Linux CI images and in local development -# setups, but not in the macOS CI environment. They are checked everywhere -# except when running in CI on macOS. +# Some tools (clang-format, clang-tidy, doxygen, gcovr, gh, git-cliff, gpg, +# pre-commit, run-clang-tidy) are present in our Linux CI images and in local +# development setups, but not in the macOS CI environment. They are checked +# everywhere except when running in CI on macOS. +# +# Tools that Nix also exposes under a version-suffixed name (`clang-tidy-22`, +# `g++-15`, ...) are probed under both names: a suffixed name can break while +# the plain one still works (see mkVersionedToolLinks in nix/packages.nix). # # Environment variables: # CI if set, skip the tools above when on macOS. @@ -26,14 +30,27 @@ set -uo pipefail +# Version suffixes of the Nix tool links, tracking nix/packages.nix. +gcc_version=15 +llvm_version=22 + missing=() checked=0 +# tool_path +# Fully resolved path of a tool, so the snapshots record which derivation +# provides it. Prints nothing when it isn't on PATH. +tool_path() { + local path + path="$(command -v "$1" 2>/dev/null)" || return 0 + readlink -f "${path}" 2>/dev/null || printf '%s' "${path}" +} + # check [probe-command...] # Runs the probe (default: " --version"), capturing both stdout and -# stderr, and prints one aligned line: the status, the name, and the first -# non-blank line of the probe output (its version). Records as missing -# if the command is not found or exits non-zero. +# stderr, and prints three lines: the status and name, the first non-blank line +# of the probe output (its version, or the error when it failed), and the tool's +# resolved path. Records as missing if it is not found or exits non-zero. check() { local name="$1" shift @@ -43,14 +60,17 @@ check() { fi checked=$((checked + 1)) - local output version + local output version path + path="$(tool_path "${name}")" if output="$("${probe[@]}" 2>&1)"; then - version="$(printf '%s\n' "${output}" | grep -m1 '[^[:space:]]' || true)" - printf ' [ ok ] %-20s %s\n' "${name}" "${version}" + printf ' ✅ %s\n' "${name}" else - printf ' [MISS] %s\n' "${name}" + printf ' ❌ %s\n' "${name}" missing+=("${name}") fi + version="$(printf '%s\n' "${output}" | grep -m1 '[^[:space:]]' || true)" + printf ' %s\n' "${version:-(no output)}" + printf ' %s\n' "${path:-(not found)}" } case "$(uname -s)" in @@ -82,7 +102,9 @@ if [ "${os}" = "linux" ] || [ "${os}" = "macos" ]; then echo "Development tooling:" check ccache check clang + check "clang-${llvm_version}" check clang++ + check "clang++-${llvm_version}" check ClangBuildAnalyzer check curl check file @@ -101,7 +123,14 @@ if [ "${os}" = "linux" ] || [ "${os}" = "macos" ]; then # setups, but not in the macOS CI environment. So check them everywhere # except when running in CI on macOS. if [ "${os}" = "linux" ] || [ -z "${CI:-}" ]; then + check clang-apply-replacements + check "clang-apply-replacements-${llvm_version}" check clang-format + check "clang-format-${llvm_version}" + # clang-tidy leads --version with the LLVM banner, not the version. + tidy_probe="--version | grep -m1 -oE 'LLVM version [0-9.]+'" + check clang-tidy sh -c "clang-tidy ${tidy_probe}" + check "clang-tidy-${llvm_version}" sh -c "clang-tidy-${llvm_version} ${tidy_probe}" check dot check doxygen check gcovr @@ -112,6 +141,7 @@ if [ "${os}" = "linux" ] || [ "${os}" = "macos" ]; then # pre-commit, or its alternative implementation prek check pre-commit sh -c 'pre-commit --version || prek --version' check run-clang-tidy run-clang-tidy --help + check "run-clang-tidy-${llvm_version}" "run-clang-tidy-${llvm_version}" --help fi fi @@ -126,7 +156,7 @@ if [ "${os}" = "linux" ] || [ "${os}" = "macos" ]; then check cargo-audit cargo audit --version check cargo-llvm-cov cargo llvm-cov --version check cargo-nextest cargo nextest --version - check clippy clippy-driver --version + check clippy-driver check rust-analyzer check rustc check rustfmt @@ -138,7 +168,11 @@ if [ "${os}" = "linux" ]; then echo echo "GCC toolchain:" check gcc + check "gcc-${gcc_version}" check g++ + check "g++-${gcc_version}" + check cpp + check "cpp-${gcc_version}" check gcov echo @@ -163,9 +197,9 @@ else checked=$((checked + 1)) tmp_clone="$(mktemp -d)" if git clone --depth 1 https://github.com/XRPLF/actions.git "${tmp_clone}/actions" >/dev/null 2>&1; then - printf ' [ ok ] git clone over HTTPS\n' + printf ' ✅ git clone over HTTPS\n' else - printf ' [MISS] git clone over HTTPS\n' + printf ' ❌ git clone over HTTPS\n' missing+=("git-https-clone") fi rm -rf "${tmp_clone}" @@ -173,9 +207,9 @@ fi echo if [ "${#missing[@]}" -eq 0 ]; then - echo "All ${checked} checked tools are present and runnable." + echo "✅ All ${checked} checked tools are present and runnable." else - echo "Missing or non-functional tools (${#missing[@]} of ${checked}):" >&2 + echo "❌ Missing or non-functional tools (${#missing[@]} of ${checked}):" >&2 for tool in "${missing[@]}"; do echo " - ${tool}" >&2 done diff --git a/nix/check-tools/README.md b/nix/check-tools/README.md index 5b7538f2ca..f23b2dcc21 100644 --- a/nix/check-tools/README.md +++ b/nix/check-tools/README.md @@ -1,7 +1,8 @@ # check-tools snapshots These files capture the output of [`bin/check-tools.sh`](../../bin/check-tools.sh) -— the versions of the development tooling — in each Nix environment: +— the version and resolved store path of each development tool — in each Nix +environment: | File | Environment | | ---------------------- | ------------------------------------ | @@ -17,9 +18,13 @@ So if you change the environment (bump the image tag in and commit the affected snapshots. Each snapshot is `check-tools.sh` stdout with the git-clone connectivity check -skipped (`CHECK_TOOLS_SKIP_CLONE=1`), so it contains only deterministic version -data. On macOS the dev-shell greeting that `nix develop` prints first is dropped -with `sed -n '/^Detected OS:/,$p'`. +skipped (`CHECK_TOOLS_SKIP_CLONE=1`), so it is deterministic for a given +environment. On macOS the dev-shell greeting that `nix develop` prints first is +dropped with `sed -n '/^Detected OS:/,$p'`. + +The store paths carry their derivation hash, so they change whenever a tool is +rebuilt — a `flake.lock` update generally rewrites most of them even when no +version moves. That is deliberate: it makes tooling changes visible in review. ## Regenerating diff --git a/nix/check-tools/macos.txt b/nix/check-tools/macos.txt index 93cc926181..8e99aa28e4 100644 --- a/nix/check-tools/macos.txt +++ b/nix/check-tools/macos.txt @@ -1,47 +1,143 @@ Detected OS: macos (Darwin arm64) Core build tools: - [ ok ] cmake cmake version 4.1.2 - [ ok ] conan Conan version 2.28.1 - [ ok ] git git version 2.54.0 - [ ok ] python3 Python 3.13.13 + ✅ cmake + cmake version 4.1.2 + /nix/store/gvabsb4yqb5xsqzqph54rijnn4zpihnp-cmake-4.1.2/bin/cmake + ✅ conan + Conan version 2.28.1 + /nix/store/9jiyxmkpwmn6dcqs0765s83riw3l5ail-conan-2.28.1/bin/conan + ✅ git + git version 2.54.0 + /nix/store/a14yxcqvv9x2l9mllgpirzhvz93pgprg-git-2.54.0/bin/git + ✅ python3 + Python 3.13.13 + /nix/store/ygxqin6ydzjfawywqpp5pal8wv6sf5bh-python3-3.13.13/bin/python3.13 Development tooling: - [ ok ] ccache ccache version 4.13.6 - [ ok ] clang clang version 22.1.7 - [ ok ] clang++ clang version 22.1.7 - [ ok ] ClangBuildAnalyzer ClangBuildAnalyzer 1.6.0 - [ ok ] curl curl 8.20.0 (aarch64-apple-darwin25.3.0) libcurl/8.20.0 OpenSSL/3.6.2 zlib/1.3.2 libssh2/1.11.1 nghttp2/1.69.0 mit-krb5/1.22.1 - [ ok ] file file-5.47 - [ ok ] less less 692 (PCRE2 regular expressions) - [ ok ] make GNU Make 4.4.1 - [ ok ] netstat present - [ ok ] ninja 1.13.2 - [ ok ] perl v5.42.0 - [ ok ] pkg-config 0.29.2 - [ ok ] vim VIM - Vi IMproved 9.2 (2026 Feb 14, compiled Jan 01 1980 00:00:00) - [ ok ] zip Zip 3.0 - [ ok ] clang-format clang-format version 22.1.7 - [ ok ] dot dot - graphviz version 12.2.1 (0) - [ ok ] doxygen 1.16.1 - [ ok ] gcovr gcovr 8.4 - [ ok ] gh gh version 2.94.0 (nixpkgs) - [ ok ] git-cliff git-cliff 2.13.1 - [ ok ] git-lfs git-lfs/3.7.1 (3.7.1; darwin arm64; go 1.26.3) - [ ok ] gpg gpg (GnuPG) 2.4.9 - [ ok ] pre-commit pre-commit 4.5.1 - [ ok ] run-clang-tidy usage: run-clang-tidy [-h] [-allow-enabling-alpha-checkers] + ✅ ccache + ccache version 4.13.6 + /nix/store/57davyvs6p6dkrl3svzwg1ph18wsy4cz-ccache-4.13.6/bin/ccache + ✅ clang + clang version 22.1.7 + /nix/store/192glrb2cldvziyf3378mzjqbzx3ih4g-clang-wrapper-22.1.7/bin/clang + ✅ clang-22 + clang version 22.1.7 + /nix/store/rbap7zqq7mw00fyqa02p5rj7gqjp4w5i-clang-22/bin/clang-22 + ✅ clang++ + clang version 22.1.7 + /nix/store/192glrb2cldvziyf3378mzjqbzx3ih4g-clang-wrapper-22.1.7/bin/clang++ + ✅ clang++-22 + clang version 22.1.7 + /nix/store/v9haf787f7bcz0mq1sad4bpyx21pj6li-clang++-22/bin/clang++-22 + ✅ ClangBuildAnalyzer + ClangBuildAnalyzer 1.6.0 + /nix/store/4l50ds9fa2mkvh7wg8qzrlbmjs12sb8l-clangbuildanalyzer-1.6.0/bin/ClangBuildAnalyzer + ✅ curl + curl 8.20.0 (aarch64-apple-darwin25.3.0) libcurl/8.20.0 OpenSSL/3.6.2 zlib/1.3.2 libssh2/1.11.1 nghttp2/1.69.0 mit-krb5/1.22.1 + /nix/store/kclq0czaxvsgh4ym9ld7b6iwy50l1snk-curl-8.20.0-bin/bin/curl + ✅ file + file-5.47 + /nix/store/dax63li7wwcbqxxkkgzc4g2rx7d4w86x-file-5.47/bin/file + ✅ less + less 692 (PCRE2 regular expressions) + /nix/store/lvr16y75r1pdxpdv0aph5ak2yd0hkvqm-less-692/bin/less + ✅ make + GNU Make 4.4.1 + /nix/store/8wwiw8pwyhrkzyq28hqzxfl4z84lks81-gnumake-4.4.1/bin/make + ✅ netstat + present + /nix/store/qsd1kzqb0ahrk433vmyl245gp623j19s-network_cmds-730.80.3/bin/netstat + ✅ ninja + 1.13.2 + /nix/store/bqykhrblarkj4fl0hz2mf8ngwfv6x6bz-ninja-1.13.2/bin/ninja + ✅ perl + v5.42.0 + /nix/store/js13ri9fvm0ajk1fpd3acigys2a9whdv-perl-5.42.0/bin/perl + ✅ pkg-config + 0.29.2 + /nix/store/lzrwr375jqhhbca116kja96xf1md83l8-pkg-config-wrapper-0.29.2/bin/pkg-config + ✅ vim + VIM - Vi IMproved 9.2 (2026 Feb 14, compiled Jan 01 1980 00:00:00) + /nix/store/6vbkykg92w603c0sw3mkk7p7mfaawbns-vim-9.2.0389/bin/vim + ✅ zip + Zip 3.0 + /nix/store/z6ph729vcakbvz3wh8ln1wk6mi06w487-zip-3.0/bin/zip + ✅ clang-apply-replacements + clang-apply-replacements version 22.1.7 + /nix/store/vzyyjf3cm1hbj9wcr2qcb66x6j98zpy7-clang-tools-22.1.7/bin/clang-apply-replacements + ✅ clang-apply-replacements-22 + clang-apply-replacements version 22.1.7 + /nix/store/m3ii69rca4077lf4wlk7m3jcag1fs577-clang-apply-replacements-22/bin/clang-apply-replacements-22 + ✅ clang-format + clang-format version 22.1.7 + /nix/store/vzyyjf3cm1hbj9wcr2qcb66x6j98zpy7-clang-tools-22.1.7/bin/clang-format + ✅ clang-format-22 + clang-format version 22.1.7 + /nix/store/4fawqy6ngqcsqd2ygyyzm93q0xy3f5gs-clang-format-22/bin/clang-format-22 + ✅ clang-tidy + LLVM version 22.1.7 + /nix/store/vzyyjf3cm1hbj9wcr2qcb66x6j98zpy7-clang-tools-22.1.7/bin/clang-tidy + ✅ clang-tidy-22 + LLVM version 22.1.7 + /nix/store/jqw4280saixaxxihwdba9ldm2fsm6dr3-clang-tidy-22/bin/clang-tidy-22 + ✅ dot + dot - graphviz version 12.2.1 (0) + /nix/store/ijb4fbnqa6wzlpqnhb6q9knqpf7qqn5z-graphviz-12.2.1/bin/dot + ✅ doxygen + 1.16.1 + /nix/store/kbryjdpq9jizjb0ws0nzbf2h2ymbdiwm-doxygen-1.16.1/bin/doxygen + ✅ gcovr + gcovr 8.4 + /nix/store/wn8jiyh9p0bybs96s4163qp3k8vfmczx-python3.13-gcovr-8.4/bin/gcovr + ✅ gh + gh version 2.94.0 (nixpkgs) + /nix/store/fhnpw0hs0gjms1ha6ap02jq7rx13gkbp-gh-2.94.0/bin/gh + ✅ git-cliff + git-cliff 2.13.1 + /nix/store/cy0wwhgxa7yvrz97zydbq6sqmixc90fq-git-cliff-2.13.1/bin/git-cliff + ✅ git-lfs + git-lfs/3.7.1 (3.7.1; darwin arm64; go 1.26.3) + /nix/store/k9r7zjfjplqa4d5s71cqvf2iv73jd9mc-git-lfs-3.7.1/bin/git-lfs + ✅ gpg + gpg (GnuPG) 2.4.9 + /nix/store/cgh6iwzz5jgx9z5whka4vgj210i6npc6-gnupg-2.4.9/bin/gpg + ✅ pre-commit + pre-commit 4.5.1 + /nix/store/z3cca68620w0w10f090szgzdnmh1waf2-pre-commit-4.5.1/bin/pre-commit + ✅ run-clang-tidy + usage: run-clang-tidy [-h] [-allow-enabling-alpha-checkers] + /nix/store/4x28x911z2f9y7adqlh3qspp4a16dig7-run-clang-tidy/bin/run-clang-tidy + ✅ run-clang-tidy-22 + usage: run-clang-tidy [-h] [-allow-enabling-alpha-checkers] + /nix/store/x8iymrh76sk5q91ryg5pa7i32s6gfh34-run-clang-tidy-22/bin/run-clang-tidy-22 Rust toolchain: - [ ok ] cargo cargo 1.95.0 (f2d3ce0bd 2026-03-21) - [ ok ] cargo-audit cargo-audit-audit 0.22.1 - [ ok ] cargo-llvm-cov cargo-llvm-cov 0.8.5 - [ ok ] cargo-nextest cargo-nextest 0.9.137 - [ ok ] clippy clippy 0.1.95 (59807616e1 2026-04-14) - [ ok ] rust-analyzer rust-analyzer 1.95.0 (59807616 2026-04-14) - [ ok ] rustc rustc 1.95.0 (59807616e 2026-04-14) - [ ok ] rustfmt rustfmt 1.9.0-stable (59807616e1 2026-04-14) + ✅ cargo + cargo 1.95.0 (f2d3ce0bd 2026-03-21) + /nix/store/92vz1f4kislnj58j1pr1788l688py6f0-rust-minimal-1.95.0/bin/cargo + ✅ cargo-audit + cargo-audit-audit 0.22.1 + /nix/store/snwkga2f5gyf404h7mmp9wriwxb8v65f-cargo-audit-0.22.1/bin/cargo-audit + ✅ cargo-llvm-cov + cargo-llvm-cov 0.8.5 + /nix/store/fpiqdh91gwyxalqp409ynm0s0g086w7w-cargo-llvm-cov-0.8.5/bin/cargo-llvm-cov + ✅ cargo-nextest + cargo-nextest 0.9.137 + /nix/store/ylz7m947mhkgsp6i7611id3s3gcd58nq-cargo-nextest-0.9.137/bin/cargo-nextest + ✅ clippy-driver + clippy 0.1.95 (59807616e1 2026-04-14) + /nix/store/92vz1f4kislnj58j1pr1788l688py6f0-rust-minimal-1.95.0/bin/clippy-driver + ✅ rust-analyzer + rust-analyzer 1.95.0 (59807616 2026-04-14) + /nix/store/jqvjap2727r9cjpr25fkw5glv2kbxrdx-rust-analyzer-preview-1.95.0-aarch64-apple-darwin/bin/rust-analyzer + ✅ rustc + rustc 1.95.0 (59807616e 2026-04-14) + /nix/store/92vz1f4kislnj58j1pr1788l688py6f0-rust-minimal-1.95.0/bin/rustc + ✅ rustfmt + rustfmt 1.9.0-stable (59807616e1 2026-04-14) + /nix/store/03x750yj6fakl7shbhicpnkxiwqxjrrs-rustfmt-preview-1.95.0-aarch64-apple-darwin/bin/rustfmt Skipping git-over-HTTPS check (CHECK_TOOLS_SKIP_CLONE is set). -All 36 checked tools are present and runnable. +✅ All 44 checked tools are present and runnable. diff --git a/nix/check-tools/nix-ubuntu-amd64.txt b/nix/check-tools/nix-ubuntu-amd64.txt index b922cca4a8..a5857c93f1 100644 --- a/nix/check-tools/nix-ubuntu-amd64.txt +++ b/nix/check-tools/nix-ubuntu-amd64.txt @@ -1,55 +1,171 @@ Detected OS: linux (Linux x86_64) Core build tools: - [ ok ] cmake cmake version 4.1.2 - [ ok ] conan Conan version 2.28.1 - [ ok ] git git version 2.54.0 - [ ok ] python3 Python 3.13.13 + ✅ cmake + cmake version 4.1.2 + /nix/store/r9941n32g4wyvggz2703dlplbdq8a6rd-cmake-4.1.2/bin/cmake + ✅ conan + Conan version 2.28.1 + /nix/store/lxny9y4jvjdws7hgz1mygvb7hjrpmna5-conan-2.28.1/bin/conan + ✅ git + git version 2.54.0 + /nix/store/bcnisk3ydfgv26v2gw3zlky24g00yww2-git-2.54.0/bin/git + ✅ python3 + Python 3.13.13 + /nix/store/60m4rxhg2fldqaak400c0lry96ijrzqn-python3-3.13.13/bin/python3.13 Development tooling: - [ ok ] ccache ccache version 4.13.6 - [ ok ] clang clang version 22.1.7 - [ ok ] clang++ clang version 22.1.7 - [ ok ] ClangBuildAnalyzer ClangBuildAnalyzer 1.6.0 - [ ok ] curl curl 8.20.0 (x86_64-pc-linux-gnu) libcurl/8.20.0 OpenSSL/3.6.2 zlib/1.3.2 libssh2/1.11.1 nghttp2/1.69.0 mit-krb5/1.22.1 - [ ok ] file file-5.47 - [ ok ] less less 692 (PCRE2 regular expressions) - [ ok ] make GNU Make 4.4.1 - [ ok ] netstat net-tools 2.10 - [ ok ] ninja 1.13.2 - [ ok ] perl v5.42.0 - [ ok ] pkg-config 0.29.2 - [ ok ] vim VIM - Vi IMproved 9.2 (2026 Feb 14, compiled Jan 01 1980 00:00:00) - [ ok ] zip Zip 3.0 - [ ok ] clang-format clang-format version 22.1.7 - [ ok ] dot dot - graphviz version 12.2.1 (0) - [ ok ] doxygen 1.16.1 - [ ok ] gcovr gcovr 8.4 - [ ok ] gh gh version 2.94.0 (nixpkgs) - [ ok ] git-cliff git-cliff 2.13.1 - [ ok ] git-lfs git-lfs/3.7.1 (3.7.1; linux amd64; go 1.26.3) - [ ok ] gpg gpg (GnuPG) 2.4.9 - [ ok ] pre-commit pre-commit 4.5.1 - [ ok ] run-clang-tidy usage: run-clang-tidy [-h] [-allow-enabling-alpha-checkers] + ✅ ccache + ccache version 4.13.6 + /nix/store/c9wwl7s5i6rsfwvf4v0xbbmzx5m6jgfr-ccache-4.13.6/bin/ccache + ✅ clang + clang version 22.1.7 + /nix/store/ff0hrp9r9i3pa5arkdw0sgmzp8d576qi-clang-wrapper-22.1.7/bin/clang + ✅ clang-22 + clang version 22.1.7 + /nix/store/dagc2rq44gfbr7w7yvvqca3yqpc9gqbq-clang-22/bin/clang-22 + ✅ clang++ + clang version 22.1.7 + /nix/store/ff0hrp9r9i3pa5arkdw0sgmzp8d576qi-clang-wrapper-22.1.7/bin/clang++ + ✅ clang++-22 + clang version 22.1.7 + /nix/store/l5m8clin1npl605wdkd8mr18ggxww3z4-clang++-22/bin/clang++-22 + ✅ ClangBuildAnalyzer + ClangBuildAnalyzer 1.6.0 + /nix/store/bshlmn8fqw55nsnm581xqlfbahfkykxx-clangbuildanalyzer-1.6.0/bin/ClangBuildAnalyzer + ✅ curl + curl 8.20.0 (x86_64-pc-linux-gnu) libcurl/8.20.0 OpenSSL/3.6.2 zlib/1.3.2 libssh2/1.11.1 nghttp2/1.69.0 mit-krb5/1.22.1 + /nix/store/zbwymrp4lcfjc4kkk0n4779v0kjjz58z-curl-8.20.0-bin/bin/curl + ✅ file + file-5.47 + /nix/store/bizyfqdw0h67wzqmp10knmf9s2pqahdb-file-5.47/bin/file + ✅ less + less 692 (PCRE2 regular expressions) + /nix/store/c6bacbn93qg4a7g9n4czww8rg24dvysr-less-692/bin/less + ✅ make + GNU Make 4.4.1 + /nix/store/d3bwqm6bymhy3pdgbvf7vxjqfp31m3j1-gnumake-4.4.1/bin/make + ✅ netstat + net-tools 2.10 + /nix/store/jmyzqvgflnswmws7rnxx6g3zbj680xvd-net-tools-2.10/bin/netstat + ✅ ninja + 1.13.2 + /nix/store/7a235m7crqbb4h49sak20fqxpw3n7hr0-ninja-1.13.2/bin/ninja + ✅ perl + v5.42.0 + /nix/store/6plwsm6pkq79yjv4xvy8csk2pd4hzr67-perl-5.42.0/bin/perl + ✅ pkg-config + 0.29.2 + /nix/store/1m05k7xgfnw6jc21xxk5681ni3ar97wf-pkg-config-wrapper-0.29.2/bin/pkg-config + ✅ vim + VIM - Vi IMproved 9.2 (2026 Feb 14, compiled Jan 01 1980 00:00:00) + /nix/store/hvyqx52g4g2fxhgpans3fksjj6lmlyaw-vim-9.2.0389/bin/vim + ✅ zip + Zip 3.0 + /nix/store/qnd2ag67hrjj0b6vbmisdshf50r6s72n-zip-3.0/bin/zip + ✅ clang-apply-replacements + clang-apply-replacements version 22.1.7 + /nix/store/4zp1rjpj2xijrv4kqpwsy3ixwb2r6nlk-clang-tools-22.1.7/bin/clang-apply-replacements + ✅ clang-apply-replacements-22 + clang-apply-replacements version 22.1.7 + /nix/store/py2wihg0a96qcppv4hjmww547xabr0fb-clang-apply-replacements-22/bin/clang-apply-replacements-22 + ✅ clang-format + clang-format version 22.1.7 + /nix/store/4zp1rjpj2xijrv4kqpwsy3ixwb2r6nlk-clang-tools-22.1.7/bin/clang-format + ✅ clang-format-22 + clang-format version 22.1.7 + /nix/store/kz820ccifjlwqnwqjsx7kbiajrgsmbrh-clang-format-22/bin/clang-format-22 + ✅ clang-tidy + LLVM version 22.1.7 + /nix/store/4zp1rjpj2xijrv4kqpwsy3ixwb2r6nlk-clang-tools-22.1.7/bin/clang-tidy + ✅ clang-tidy-22 + LLVM version 22.1.7 + /nix/store/gdrkvpw846lkyzh8y9p3zx50g6ml2v84-clang-tidy-22/bin/clang-tidy-22 + ✅ dot + dot - graphviz version 12.2.1 (0) + /nix/store/12rgns2296s4qcja778gvcbx61z77rc4-graphviz-12.2.1/bin/dot + ✅ doxygen + 1.16.1 + /nix/store/k0vzr5lvgq1byraknzwvk51wcgpnsrkh-doxygen-1.16.1/bin/doxygen + ✅ gcovr + gcovr 8.4 + /nix/store/iyzi7fpyclqrha054adnizvif02lg49x-python3.13-gcovr-8.4/bin/gcovr + ✅ gh + gh version 2.94.0 (nixpkgs) + /nix/store/pidh15szlsb1vc41xdsa3xbdghdazvby-gh-2.94.0/bin/gh + ✅ git-cliff + git-cliff 2.13.1 + /nix/store/1q851fs62shgjhc03fxxdkpzxdjg7k11-git-cliff-2.13.1/bin/git-cliff + ✅ git-lfs + git-lfs/3.7.1 (3.7.1; linux amd64; go 1.26.3) + /nix/store/6ljwpal7b1756708m33vj0crpral7mvl-git-lfs-3.7.1/bin/git-lfs + ✅ gpg + gpg (GnuPG) 2.4.9 + /nix/store/wx7vk8babxkgy813r70yc67vcwnmagbx-gnupg-2.4.9/bin/gpg + ✅ pre-commit + pre-commit 4.5.1 + /nix/store/bj6i9vl34cij5h0r165y40hrjqak0bmz-pre-commit-4.5.1/bin/pre-commit + ✅ run-clang-tidy + usage: run-clang-tidy [-h] [-allow-enabling-alpha-checkers] + /nix/store/sbg911hs9dbclrzlp04br3iyfpgnaj6r-run-clang-tidy/bin/run-clang-tidy + ✅ run-clang-tidy-22 + usage: run-clang-tidy [-h] [-allow-enabling-alpha-checkers] + /nix/store/n8yak1ap308gvi7gmrniw0ybsx80fjws-run-clang-tidy-22/bin/run-clang-tidy-22 Rust toolchain: - [ ok ] cargo cargo 1.95.0 (f2d3ce0bd 2026-03-21) - [ ok ] cargo-audit cargo-audit-audit 0.22.1 - [ ok ] cargo-llvm-cov cargo-llvm-cov 0.8.5 - [ ok ] cargo-nextest cargo-nextest 0.9.137 - [ ok ] clippy clippy 0.1.95 (59807616e1 2026-04-14) - [ ok ] rust-analyzer rust-analyzer 1.95.0 (5980761 2026-04-14) - [ ok ] rustc rustc 1.95.0 (59807616e 2026-04-14) - [ ok ] rustfmt rustfmt 1.9.0-stable (59807616e1 2026-04-14) + ✅ cargo + cargo 1.95.0 (f2d3ce0bd 2026-03-21) + /nix/store/85qbwr3vzfs58m7ywnjblz105p8ahbrv-cargo-1.95.0-x86_64-unknown-linux-gnu/bin/cargo + ✅ cargo-audit + cargo-audit-audit 0.22.1 + /nix/store/2w9if868piw98xz057sz97jnjvf7hnvf-cargo-audit-0.22.1/bin/cargo-audit + ✅ cargo-llvm-cov + cargo-llvm-cov 0.8.5 + /nix/store/jjpdf1l6izz6607a346ykra9sndzaw7h-cargo-llvm-cov-0.8.5/bin/cargo-llvm-cov + ✅ cargo-nextest + cargo-nextest 0.9.137 + /nix/store/jhkr7gwyrchkml33gyns9cy0yn7b57qc-cargo-nextest-0.9.137/bin/cargo-nextest + ✅ clippy-driver + clippy 0.1.95 (59807616e1 2026-04-14) + /nix/store/bnvg9nmdq4g98dd9v3r6nvjg5h2rr8i7-rust-minimal-1.95.0/bin/clippy-driver + ✅ rust-analyzer + rust-analyzer 1.95.0 (5980761 2026-04-14) + /nix/store/i3cnpngfwa3k4jn431pl6ji1r4qmxky9-rust-analyzer-preview-1.95.0-x86_64-unknown-linux-gnu/bin/rust-analyzer + ✅ rustc + rustc 1.95.0 (59807616e 2026-04-14) + /nix/store/bnvg9nmdq4g98dd9v3r6nvjg5h2rr8i7-rust-minimal-1.95.0/bin/rustc + ✅ rustfmt + rustfmt 1.9.0-stable (59807616e1 2026-04-14) + /nix/store/366hhk2dgwxmnf4hgrj4b8llhjr3hf0i-rustfmt-preview-1.95.0-x86_64-unknown-linux-gnu/bin/rustfmt GCC toolchain: - [ ok ] gcc gcc (GCC) 15.2.0 - [ ok ] g++ g++ (GCC) 15.2.0 - [ ok ] gcov gcov (GCC) 15.2.0 + ✅ gcc + gcc (GCC) 15.2.0 + /nix/store/3dd6y3pq00i3r85l45jvz63wjya403nl-gcc-wrapper-15.2.0/bin/gcc + ✅ gcc-15 + gcc (GCC) 15.2.0 + /nix/store/d6iri2s6bzqq5ac3fg25j6hgnn1lz44f-gcc-15/bin/gcc-15 + ✅ g++ + g++ (GCC) 15.2.0 + /nix/store/3dd6y3pq00i3r85l45jvz63wjya403nl-gcc-wrapper-15.2.0/bin/g++ + ✅ g++-15 + g++ (GCC) 15.2.0 + /nix/store/gm3msmmxq055lm9gprkfjj9d2gdz1mpg-g++-15/bin/g++-15 + ✅ cpp + cpp (GCC) 15.2.0 + /nix/store/3dd6y3pq00i3r85l45jvz63wjya403nl-gcc-wrapper-15.2.0/bin/cpp + ✅ cpp-15 + cpp (GCC) 15.2.0 + /nix/store/bn3gmn0m7g4gn2i0yml46fljc7mghiq5-cpp-15/bin/cpp-15 + ✅ gcov + gcov (GCC) 15.2.0 + /nix/store/xvv5sm5i8x0ks6ypfkzl7c4j9srnxz7k-gcc-15.2.0/bin/gcov Mold: - [ ok ] mold mold 2.41.0 (compatible with GNU ld) + ✅ mold + mold 2.41.0 (compatible with GNU ld) + /nix/store/2w6fpgxjzzyqmd25wzplm23dfa49a0p2-mold-unwrapped-wrapper-2.41.0/bin/mold Skipping git-over-HTTPS check (CHECK_TOOLS_SKIP_CLONE is set). -All 40 checked tools are present and runnable. +✅ All 52 checked tools are present and runnable. diff --git a/nix/check-tools/nix-ubuntu-arm64.txt b/nix/check-tools/nix-ubuntu-arm64.txt index 5267839682..820c6de086 100644 --- a/nix/check-tools/nix-ubuntu-arm64.txt +++ b/nix/check-tools/nix-ubuntu-arm64.txt @@ -1,55 +1,171 @@ Detected OS: linux (Linux aarch64) Core build tools: - [ ok ] cmake cmake version 4.1.2 - [ ok ] conan Conan version 2.28.1 - [ ok ] git git version 2.54.0 - [ ok ] python3 Python 3.13.13 + ✅ cmake + cmake version 4.1.2 + /nix/store/nkcpxjifkambzlrwh27a8igvhnbchibg-cmake-4.1.2/bin/cmake + ✅ conan + Conan version 2.28.1 + /nix/store/8i2gyqgc00xvxg9xm6y7n0ilncdv8imw-conan-2.28.1/bin/conan + ✅ git + git version 2.54.0 + /nix/store/ixp98f9avf8ikpdrmp40cj33g0dazyp9-git-2.54.0/bin/git + ✅ python3 + Python 3.13.13 + /nix/store/lqn6mbgzzdrqq2qkwddcmxj9z6amdd86-python3-3.13.13/bin/python3.13 Development tooling: - [ ok ] ccache ccache version 4.13.6 - [ ok ] clang clang version 22.1.7 - [ ok ] clang++ clang version 22.1.7 - [ ok ] ClangBuildAnalyzer ClangBuildAnalyzer 1.6.0 - [ ok ] curl curl 8.20.0 (aarch64-unknown-linux-gnu) libcurl/8.20.0 OpenSSL/3.6.2 zlib/1.3.2 libssh2/1.11.1 nghttp2/1.69.0 mit-krb5/1.22.1 - [ ok ] file file-5.47 - [ ok ] less less 692 (PCRE2 regular expressions) - [ ok ] make GNU Make 4.4.1 - [ ok ] netstat net-tools 2.10 - [ ok ] ninja 1.13.2 - [ ok ] perl v5.42.0 - [ ok ] pkg-config 0.29.2 - [ ok ] vim VIM - Vi IMproved 9.2 (2026 Feb 14, compiled Jan 01 1980 00:00:00) - [ ok ] zip Zip 3.0 - [ ok ] clang-format clang-format version 22.1.7 - [ ok ] dot dot - graphviz version 12.2.1 (0) - [ ok ] doxygen 1.16.1 - [ ok ] gcovr gcovr 8.4 - [ ok ] gh gh version 2.94.0 (nixpkgs) - [ ok ] git-cliff git-cliff 2.13.1 - [ ok ] git-lfs git-lfs/3.7.1 (3.7.1; linux arm64; go 1.26.3) - [ ok ] gpg gpg (GnuPG) 2.4.9 - [ ok ] pre-commit pre-commit 4.5.1 - [ ok ] run-clang-tidy usage: run-clang-tidy [-h] [-allow-enabling-alpha-checkers] + ✅ ccache + ccache version 4.13.6 + /nix/store/2q39xi2kbi04ibga7635f2sl148d1mzv-ccache-4.13.6/bin/ccache + ✅ clang + clang version 22.1.7 + /nix/store/xjqffrq9i7la058s9865ig71l9sp1ys5-clang-wrapper-22.1.7/bin/clang + ✅ clang-22 + clang version 22.1.7 + /nix/store/vcf6ilfwn57828hwzyp6zlyr24j9j6yw-clang-22/bin/clang-22 + ✅ clang++ + clang version 22.1.7 + /nix/store/xjqffrq9i7la058s9865ig71l9sp1ys5-clang-wrapper-22.1.7/bin/clang++ + ✅ clang++-22 + clang version 22.1.7 + /nix/store/xby0f6gamr7m27zp5cndsvghbp9lgb3c-clang++-22/bin/clang++-22 + ✅ ClangBuildAnalyzer + ClangBuildAnalyzer 1.6.0 + /nix/store/h893hd4q1bb6ily2lby5dzyfrrzd2nvj-clangbuildanalyzer-1.6.0/bin/ClangBuildAnalyzer + ✅ curl + curl 8.20.0 (aarch64-unknown-linux-gnu) libcurl/8.20.0 OpenSSL/3.6.2 zlib/1.3.2 libssh2/1.11.1 nghttp2/1.69.0 mit-krb5/1.22.1 + /nix/store/i1s0lqwlrmjd2dxzgy2p84cxqqsb0bmk-curl-8.20.0-bin/bin/curl + ✅ file + file-5.47 + /nix/store/dx973zg9km2w9albsib2vw9wyvacfrlw-file-5.47/bin/file + ✅ less + less 692 (PCRE2 regular expressions) + /nix/store/1blb3s7hhsr77wqi598m6k1qkfp3ms0w-less-692/bin/less + ✅ make + GNU Make 4.4.1 + /nix/store/9ngw1ippk25jjj5fjxv36xbp6iq7rxdx-gnumake-4.4.1/bin/make + ✅ netstat + net-tools 2.10 + /nix/store/7vdsz21f0s499s5yyqzp5s4676q4yxdd-net-tools-2.10/bin/netstat + ✅ ninja + 1.13.2 + /nix/store/8ksx98gsbn5lmlizcmw57yd4sg0k2p58-ninja-1.13.2/bin/ninja + ✅ perl + v5.42.0 + /nix/store/5wnly69vv1i3y97al4v3xrqymf9hlzgq-perl-5.42.0/bin/perl + ✅ pkg-config + 0.29.2 + /nix/store/c7vwy0gl1q0agl2h22gi0m9dg7xxad2l-pkg-config-wrapper-0.29.2/bin/pkg-config + ✅ vim + VIM - Vi IMproved 9.2 (2026 Feb 14, compiled Jan 01 1980 00:00:00) + /nix/store/v8c7pvx26irvy9k5sbwd183cyvckzzb3-vim-9.2.0389/bin/vim + ✅ zip + Zip 3.0 + /nix/store/5mh19mvbv9ym2sm9vymyyaac5l2cj2jq-zip-3.0/bin/zip + ✅ clang-apply-replacements + clang-apply-replacements version 22.1.7 + /nix/store/s53p2m776iqaz7acgr5csgpsd18w15h7-clang-tools-22.1.7/bin/clang-apply-replacements + ✅ clang-apply-replacements-22 + clang-apply-replacements version 22.1.7 + /nix/store/bg4kn8z81hk7b9284rjqvr51wpfjqc24-clang-apply-replacements-22/bin/clang-apply-replacements-22 + ✅ clang-format + clang-format version 22.1.7 + /nix/store/s53p2m776iqaz7acgr5csgpsd18w15h7-clang-tools-22.1.7/bin/clang-format + ✅ clang-format-22 + clang-format version 22.1.7 + /nix/store/79v57mzcw8ng8kl7p961ck08ymhp31v7-clang-format-22/bin/clang-format-22 + ✅ clang-tidy + LLVM version 22.1.7 + /nix/store/s53p2m776iqaz7acgr5csgpsd18w15h7-clang-tools-22.1.7/bin/clang-tidy + ✅ clang-tidy-22 + LLVM version 22.1.7 + /nix/store/wdyd6cb9z1lyi37lbzvwldgcc7yv1n5c-clang-tidy-22/bin/clang-tidy-22 + ✅ dot + dot - graphviz version 12.2.1 (0) + /nix/store/58rrk4yzwpmyxvl8cqm18h3dhv24zf00-graphviz-12.2.1/bin/dot + ✅ doxygen + 1.16.1 + /nix/store/hq32kzwpl89wgr49iq0gmqn9r5n072zq-doxygen-1.16.1/bin/doxygen + ✅ gcovr + gcovr 8.4 + /nix/store/sml3xbbfhhlhk6h7jnlg19pdbx9b764b-python3.13-gcovr-8.4/bin/gcovr + ✅ gh + gh version 2.94.0 (nixpkgs) + /nix/store/7hh2qi0gj2ifbxbl56cjzbiyfc379bji-gh-2.94.0/bin/gh + ✅ git-cliff + git-cliff 2.13.1 + /nix/store/bidn3pz53yd6qlg711917xx0q10hqmqv-git-cliff-2.13.1/bin/git-cliff + ✅ git-lfs + git-lfs/3.7.1 (3.7.1; linux arm64; go 1.26.3) + /nix/store/4rsklvkbac5bayy0zv12kxyvspi4sshd-git-lfs-3.7.1/bin/git-lfs + ✅ gpg + gpg (GnuPG) 2.4.9 + /nix/store/ka4i8zz5ni3rzqnzcxbfvwr95fk8pn6q-gnupg-2.4.9/bin/gpg + ✅ pre-commit + pre-commit 4.5.1 + /nix/store/n981w6hjfar2l81kxbxs2wxl64vwa5kj-pre-commit-4.5.1/bin/pre-commit + ✅ run-clang-tidy + usage: run-clang-tidy [-h] [-allow-enabling-alpha-checkers] + /nix/store/4z2fyklg78klallr7x9j02kz92hnxp4m-run-clang-tidy/bin/run-clang-tidy + ✅ run-clang-tidy-22 + usage: run-clang-tidy [-h] [-allow-enabling-alpha-checkers] + /nix/store/f8m0p9ad40brp9ahy4i0h27kqjkya1j9-run-clang-tidy-22/bin/run-clang-tidy-22 Rust toolchain: - [ ok ] cargo cargo 1.95.0 (f2d3ce0bd 2026-03-21) - [ ok ] cargo-audit cargo-audit-audit 0.22.1 - [ ok ] cargo-llvm-cov cargo-llvm-cov 0.8.5 - [ ok ] cargo-nextest cargo-nextest 0.9.137 - [ ok ] clippy clippy 0.1.95 (59807616e1 2026-04-14) - [ ok ] rust-analyzer rust-analyzer 1.95.0 (5980761 2026-04-14) - [ ok ] rustc rustc 1.95.0 (59807616e 2026-04-14) - [ ok ] rustfmt rustfmt 1.9.0-stable (59807616e1 2026-04-14) + ✅ cargo + cargo 1.95.0 (f2d3ce0bd 2026-03-21) + /nix/store/yw1rs50s6qpsw0zyl7j3dpm18swbl0ag-cargo-1.95.0-aarch64-unknown-linux-gnu/bin/cargo + ✅ cargo-audit + cargo-audit-audit 0.22.1 + /nix/store/9rxbrn9aa2r1z96186s69pc7vzizyfch-cargo-audit-0.22.1/bin/cargo-audit + ✅ cargo-llvm-cov + cargo-llvm-cov 0.8.5 + /nix/store/vwjsi159n89szrx4yh5pc3jlf2gp4fld-cargo-llvm-cov-0.8.5/bin/cargo-llvm-cov + ✅ cargo-nextest + cargo-nextest 0.9.137 + /nix/store/qb6bcg2fjvm3r9s9j98nmffmf9xwh45s-cargo-nextest-0.9.137/bin/cargo-nextest + ✅ clippy-driver + clippy 0.1.95 (59807616e1 2026-04-14) + /nix/store/nz4qv12pf16c092qr9hh4dsn0fzf47da-rust-minimal-1.95.0/bin/clippy-driver + ✅ rust-analyzer + rust-analyzer 1.95.0 (5980761 2026-04-14) + /nix/store/m1rn67sqfz8s44idcxqallg680ifk71r-rust-analyzer-preview-1.95.0-aarch64-unknown-linux-gnu/bin/rust-analyzer + ✅ rustc + rustc 1.95.0 (59807616e 2026-04-14) + /nix/store/nz4qv12pf16c092qr9hh4dsn0fzf47da-rust-minimal-1.95.0/bin/rustc + ✅ rustfmt + rustfmt 1.9.0-stable (59807616e1 2026-04-14) + /nix/store/jidfsprj2820glyzjn54ldn3j1fmz8c5-rustfmt-preview-1.95.0-aarch64-unknown-linux-gnu/bin/rustfmt GCC toolchain: - [ ok ] gcc gcc (GCC) 15.2.0 - [ ok ] g++ g++ (GCC) 15.2.0 - [ ok ] gcov gcov (GCC) 15.2.0 + ✅ gcc + gcc (GCC) 15.2.0 + /nix/store/rn6svg593xsmn8qcjzk8x9pa1i62c4kb-gcc-wrapper-15.2.0/bin/gcc + ✅ gcc-15 + gcc (GCC) 15.2.0 + /nix/store/h489d1rmjisfbxh5kmsb0a7c35j8qsdf-gcc-15/bin/gcc-15 + ✅ g++ + g++ (GCC) 15.2.0 + /nix/store/rn6svg593xsmn8qcjzk8x9pa1i62c4kb-gcc-wrapper-15.2.0/bin/g++ + ✅ g++-15 + g++ (GCC) 15.2.0 + /nix/store/9ywmhz8bmzknrn3pn84g46z8hj3vrmw5-g++-15/bin/g++-15 + ✅ cpp + cpp (GCC) 15.2.0 + /nix/store/rn6svg593xsmn8qcjzk8x9pa1i62c4kb-gcc-wrapper-15.2.0/bin/cpp + ✅ cpp-15 + cpp (GCC) 15.2.0 + /nix/store/vmjilh1b830qz9yh0a1jj5ads0jxizdk-cpp-15/bin/cpp-15 + ✅ gcov + gcov (GCC) 15.2.0 + /nix/store/rmwf5hpi1y2m1wpnfvlxmrhksm4djk2j-gcc-15.2.0/bin/gcov Mold: - [ ok ] mold mold 2.41.0 (compatible with GNU ld) + ✅ mold + mold 2.41.0 (compatible with GNU ld) + /nix/store/f5qh5a0bx1dslmnf5n5gx0s6aljbswq3-mold-unwrapped-wrapper-2.41.0/bin/mold Skipping git-over-HTTPS check (CHECK_TOOLS_SKIP_CLONE is set). -All 40 checked tools are present and runnable. +✅ All 52 checked tools are present and runnable. From 2adffaef724f0180ffc44fb0a91c6bb854a2ebaf Mon Sep 17 00:00:00 2001 From: Bart Date: Fri, 14 Aug 2026 15:36:47 +0000 Subject: [PATCH 080/102] refactor: Remove support for protocol version 2.1 (#7432) Co-authored-by: Bart <11445373+bthomee@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 --- include/xrpl/proto/xrpl.proto | 15 +- src/test/app/ValidatorList_test.cpp | 238 +++++-------------- src/test/overlay/ProtocolVersion_test.cpp | 38 +-- src/test/overlay/compression_test.cpp | 30 --- src/xrpld/app/misc/ValidatorList.h | 13 - src/xrpld/app/misc/detail/ValidatorList.cpp | 167 +++---------- src/xrpld/overlay/Peer.h | 2 - src/xrpld/overlay/detail/Message.cpp | 1 - src/xrpld/overlay/detail/PeerImp.cpp | 38 +-- src/xrpld/overlay/detail/PeerImp.h | 2 - src/xrpld/overlay/detail/ProtocolMessage.h | 5 - src/xrpld/overlay/detail/ProtocolVersion.cpp | 1 - src/xrpld/overlay/detail/TrafficCount.cpp | 1 - 13 files changed, 114 insertions(+), 437 deletions(-) diff --git a/include/xrpl/proto/xrpl.proto b/include/xrpl/proto/xrpl.proto index b9cb94e668..644e099179 100644 --- a/include/xrpl/proto/xrpl.proto +++ b/include/xrpl/proto/xrpl.proto @@ -1,10 +1,10 @@ syntax = "proto2"; package protocol; -// Unused numbers in the list below may have been used previously. Please don't -// reassign them for reuse unless you are 100% certain that there won't be a -// conflict. Even if you're sure, it's probably best to assign a new type. enum MessageType { + // Previously used - don't reuse. + reserved 0 to 1, 4, 6 to 14, 16 to 29, 36 to 40, 43 to 54, 61 to 62; + mtMANIFESTS = 2; mtPING = 3; mtCLUSTER = 5; @@ -17,7 +17,6 @@ enum MessageType { mtHAVE_SET = 35; mtVALIDATION = 41; mtGET_OBJECTS = 42; - mtVALIDATOR_LIST = 54; mtSQUELCH = 55; mtVALIDATOR_LIST_COLLECTION = 56; mtPROOF_PATH_REQ = 57; @@ -162,14 +161,6 @@ message TMHaveTransactionSet { required bytes hash = 2; } -// Validator list (UNL) -message TMValidatorList { - required bytes manifest = 1; - required bytes blob = 2; - required bytes signature = 3; - required uint32 version = 4; -} - // Validator List v2 message ValidatorBlobInfo { optional bytes manifest = 1; diff --git a/src/test/app/ValidatorList_test.cpp b/src/test/app/ValidatorList_test.cpp index 323c77c780..d2e6cb24aa 100644 --- a/src/test/app/ValidatorList_test.cpp +++ b/src/test/app/ValidatorList_test.cpp @@ -2253,8 +2253,7 @@ private: { testcase("Sha512 hashing"); // Tests that ValidatorList hash_append helpers with a single blob - // returns the same result as xrpl::Sha512Half used by the - // TMValidatorList protocol message handler + // return the same result as xrpl::Sha512Half std::string const manifest = "This is not really a manifest"; std::string const blob = "This is not really a blob"; std::string const signature = "This is not really a signature"; @@ -2275,17 +2274,6 @@ private: BEAST_EXPECT(global != sha512Half(blob, blobMap, version)); } - { - protocol::TMValidatorList msg1; - msg1.set_manifest(manifest); - msg1.set_blob(blob); - msg1.set_signature(signature); - msg1.set_version(version); - BEAST_EXPECT(global == sha512Half(msg1)); - msg1.set_signature(blob); - BEAST_EXPECT(global != sha512Half(msg1)); - } - { protocol::TMValidatorListCollection msg2; msg2.set_manifest(manifest); @@ -2323,19 +2311,7 @@ private: BEAST_EXPECT(!ec); return std::make_pair(header, buffers); }; - auto extractProtocolMessage1 = [this, &extractHeader](Message& message) { - auto [header, buffers] = extractHeader(message); - if (BEAST_EXPECT(header) && - BEAST_EXPECT(header->messageType == protocol::mtVALIDATOR_LIST)) - { - auto const msg = - detail::parseMessageContent(*header, buffers.data()); - BEAST_EXPECT(msg); - return msg; - } - return std::shared_ptr(); - }; - auto extractProtocolMessage2 = [this, &extractHeader](Message& message) { + auto extractProtocolMessage = [this, &extractHeader](Message& message) { auto [header, buffers] = extractHeader(message); if (BEAST_EXPECT(header) && BEAST_EXPECT(header->messageType == protocol::mtVALIDATOR_LIST_COLLECTION)) @@ -2347,92 +2323,55 @@ private: } return std::shared_ptr(); }; - auto verifyMessage = - [this, manifestCutoff, &extractProtocolMessage1, &extractProtocolMessage2]( - auto const version, - auto const& manifest, - auto const& blobInfos, - auto const& messages, - std::vector>> expectedInfo) { - BEAST_EXPECT(messages.size() == expectedInfo.size()); - auto msgIter = expectedInfo.begin(); - for (auto const& messageWithHash : messages) + auto verifyMessage = [this, manifestCutoff, &extractProtocolMessage]( + auto const version, + auto const& manifest, + auto const& blobInfos, + auto const& messages, + std::vector> expectedInfo) { + BEAST_EXPECT(messages.size() == expectedInfo.size()); + auto msgIter = expectedInfo.begin(); + for (auto const& messageWithHash : messages) + { + if (!BEAST_EXPECT(msgIter != expectedInfo.end())) + break; + if (!BEAST_EXPECT(messageWithHash.message)) + continue; + auto const& expectedSeqs = *msgIter; + auto seqIter = expectedSeqs.begin(); { - if (!BEAST_EXPECT(msgIter != expectedInfo.end())) - break; - if (!BEAST_EXPECT(messageWithHash.message)) - continue; - auto const& expectedSeqs = msgIter->second; - auto seqIter = expectedSeqs.begin(); - auto const size = - messageWithHash.message->getBuffer(compression::Compressed::Off).size(); - // This size is arbitrary, but shouldn't change - BEAST_EXPECT(size == msgIter->first); - if (expectedSeqs.size() == 1) + std::vector hashingBlobs; + hashingBlobs.reserve(expectedSeqs.size()); + + auto const msg = extractProtocolMessage(*messageWithHash.message); + if (BEAST_EXPECT(msg)) { - auto const msg = extractProtocolMessage1(*messageWithHash.message); - auto const expectedVersion = 1; - if (BEAST_EXPECT(msg)) + BEAST_EXPECT(msg->version() == version); + BEAST_EXPECT(msg->manifest() == manifest); + for (auto const& blobInfo : msg->blobs()) { - BEAST_EXPECT(msg->version() == expectedVersion); if (!BEAST_EXPECT(seqIter != expectedSeqs.end())) - continue; + break; auto const& expectedBlob = blobInfos.at(*seqIter); - BEAST_EXPECT((*seqIter < manifestCutoff) == !!expectedBlob.manifest); - auto const expectedManifest = - *seqIter < manifestCutoff && expectedBlob.manifest - ? *expectedBlob.manifest - : manifest; - BEAST_EXPECT(msg->manifest() == expectedManifest); - BEAST_EXPECT(msg->blob() == expectedBlob.blob); - BEAST_EXPECT(msg->signature() == expectedBlob.signature); + hashingBlobs.push_back(expectedBlob); + BEAST_EXPECT(blobInfo.has_manifest() == !!expectedBlob.manifest); + BEAST_EXPECT(blobInfo.has_manifest() == (*seqIter < manifestCutoff)); + + if (*seqIter < manifestCutoff) + BEAST_EXPECT(blobInfo.manifest() == *expectedBlob.manifest); + BEAST_EXPECT(blobInfo.blob() == expectedBlob.blob); + BEAST_EXPECT(blobInfo.signature() == expectedBlob.signature); ++seqIter; - BEAST_EXPECT(seqIter == expectedSeqs.end()); - - BEAST_EXPECT( - messageWithHash.hash == - sha512Half( - expectedManifest, - expectedBlob.blob, - expectedBlob.signature, - expectedVersion)); } + BEAST_EXPECT(seqIter == expectedSeqs.end()); } - else - { - std::vector hashingBlobs; - hashingBlobs.reserve(msgIter->second.size()); - - auto const msg = extractProtocolMessage2(*messageWithHash.message); - if (BEAST_EXPECT(msg)) - { - BEAST_EXPECT(msg->version() == version); - BEAST_EXPECT(msg->manifest() == manifest); - for (auto const& blobInfo : msg->blobs()) - { - if (!BEAST_EXPECT(seqIter != expectedSeqs.end())) - break; - auto const& expectedBlob = blobInfos.at(*seqIter); - hashingBlobs.push_back(expectedBlob); - BEAST_EXPECT(blobInfo.has_manifest() == !!expectedBlob.manifest); - BEAST_EXPECT( - blobInfo.has_manifest() == (*seqIter < manifestCutoff)); - - if (*seqIter < manifestCutoff) - BEAST_EXPECT(blobInfo.manifest() == *expectedBlob.manifest); - BEAST_EXPECT(blobInfo.blob() == expectedBlob.blob); - BEAST_EXPECT(blobInfo.signature() == expectedBlob.signature); - ++seqIter; - } - BEAST_EXPECT(seqIter == expectedSeqs.end()); - } - BEAST_EXPECT( - messageWithHash.hash == sha512Half(manifest, hashingBlobs, version)); - } - ++msgIter; + BEAST_EXPECT( + messageWithHash.hash == sha512Half(manifest, hashingBlobs, version)); } - BEAST_EXPECT(msgIter == expectedInfo.end()); - }; + ++msgIter; + } + BEAST_EXPECT(msgIter == expectedInfo.end()); + }; auto verifyBuildMessages = [this]( std::pair const& result, std::size_t expectedSequence, @@ -2471,66 +2410,10 @@ private: std::vector messages; - // Version 1 - - // This peer has a VL ahead of our "current" - verifyBuildMessages( - ValidatorList::buildValidatorListMessages( - 1, 8, maxSequence, version, manifest, blobInfos, messages), - 0, - 0); - BEAST_EXPECT(messages.empty()); - - // Don't repeat the work if messages is populated, even though the - // peerSequence provided indicates it should. Note that this - // situation is contrived for this test and should never happen in - // real code. - messages.emplace_back(); - verifyBuildMessages( - ValidatorList::buildValidatorListMessages( - 1, 3, maxSequence, version, manifest, blobInfos, messages), - 5, - 0); - BEAST_EXPECT(messages.size() == 1 && !messages.front().message); - - // Generate a version 1 message - messages.clear(); - verifyBuildMessages( - ValidatorList::buildValidatorListMessages( - 1, 3, maxSequence, version, manifest, blobInfos, messages), - 5, - 1); - if (BEAST_EXPECT(messages.size() == 1) && BEAST_EXPECT(messages.front().message)) - { - auto const& messageWithHash = messages.front(); - auto const msg = extractProtocolMessage1(*messageWithHash.message); - auto const size = - messageWithHash.message->getBuffer(compression::Compressed::Off).size(); - // This size is arbitrary, but shouldn't change - BEAST_EXPECT(size == 108); - auto const& expected = blobInfos.at(5); - if (BEAST_EXPECT(msg)) - { - BEAST_EXPECT(msg->version() == 1); - // NOLINTNEXTLINE(bugprone-unchecked-optional-access) - BEAST_EXPECT(msg->manifest() == *expected.manifest); - BEAST_EXPECT(msg->blob() == expected.blob); - BEAST_EXPECT(msg->signature() == expected.signature); - } - BEAST_EXPECT( - messageWithHash.hash == - // NOLINTNEXTLINE(bugprone-unchecked-optional-access) - sha512Half(*expected.manifest, expected.blob, expected.signature, 1)); - } - - // Version 2 - - messages.clear(); - // This peer has a VL ahead of us. verifyBuildMessages( ValidatorList::buildValidatorListMessages( - 2, maxSequence * 2, maxSequence, version, manifest, blobInfos, messages), + maxSequence * 2, maxSequence, version, manifest, blobInfos, messages), 0, 0); BEAST_EXPECT(messages.empty()); @@ -2542,19 +2425,19 @@ private: messages.emplace_back(); verifyBuildMessages( ValidatorList::buildValidatorListMessages( - 2, 3, maxSequence, version, manifest, blobInfos, messages), + 3, maxSequence, version, manifest, blobInfos, messages), maxSequence, 0); BEAST_EXPECT(messages.size() == 1 && !messages.front().message); - // Generate a version 2 message. Don't send the current + // Generate a message. Don't send the current messages.clear(); verifyBuildMessages( ValidatorList::buildValidatorListMessages( - 2, 5, maxSequence, version, manifest, blobInfos, messages), + 5, maxSequence, version, manifest, blobInfos, messages), maxSequence, 4); - verifyMessage(version, manifest, blobInfos, messages, {{372, {6, 7, 10, 12}}}); + verifyMessage(version, manifest, blobInfos, messages, {{6, 7, 10, 12}}); // Test message splitting on size limits. @@ -2562,50 +2445,39 @@ private: messages.clear(); verifyBuildMessages( ValidatorList::buildValidatorListMessages( - 2, 5, maxSequence, version, manifest, blobInfos, messages, 300), + 5, maxSequence, version, manifest, blobInfos, messages, 300), maxSequence, 4); - verifyMessage(version, manifest, blobInfos, messages, {{212, {6, 7}}, {192, {10, 12}}}); + verifyMessage(version, manifest, blobInfos, messages, {{6, 7}, {10, 12}}); // Set a limit between the size of the two earlier messages so one // will split and the other won't messages.clear(); verifyBuildMessages( ValidatorList::buildValidatorListMessages( - 2, 5, maxSequence, version, manifest, blobInfos, messages, 200), + 5, maxSequence, version, manifest, blobInfos, messages, 200), maxSequence, 4); - verifyMessage( - version, manifest, blobInfos, messages, {{108, {6}}, {108, {7}}, {192, {10, 12}}}); + verifyMessage(version, manifest, blobInfos, messages, {{6}, {7}, {10, 12}}); // Set a limit so that all the VLs are sent individually messages.clear(); verifyBuildMessages( ValidatorList::buildValidatorListMessages( - 2, 5, maxSequence, version, manifest, blobInfos, messages, 150), + 5, maxSequence, version, manifest, blobInfos, messages, 150), maxSequence, 4); - verifyMessage( - version, - manifest, - blobInfos, - messages, - {{108, {6}}, {108, {7}}, {110, {10}}, {110, {12}}}); + verifyMessage(version, manifest, blobInfos, messages, {{6}, {7}, {10}, {12}}); // Set a limit smaller than some of the messages. Because single // messages send regardless, they will all still be sent messages.clear(); verifyBuildMessages( ValidatorList::buildValidatorListMessages( - 2, 5, maxSequence, version, manifest, blobInfos, messages, 108), + 5, maxSequence, version, manifest, blobInfos, messages, 108), maxSequence, 4); - verifyMessage( - version, - manifest, - blobInfos, - messages, - {{108, {6}}, {108, {7}}, {110, {10}}, {110, {12}}}); + verifyMessage(version, manifest, blobInfos, messages, {{6}, {7}, {10}, {12}}); } void diff --git a/src/test/overlay/ProtocolVersion_test.cpp b/src/test/overlay/ProtocolVersion_test.cpp index e31a574502..e7b63a34cb 100644 --- a/src/test/overlay/ProtocolVersion_test.cpp +++ b/src/test/overlay/ProtocolVersion_test.cpp @@ -33,22 +33,30 @@ public: void run() override { - testcase("Convert protocol version to string"); - BEAST_EXPECT(to_string(makeProtocol(1, 3)) == "XRPL/1.3"); - BEAST_EXPECT(to_string(makeProtocol(2, 0)) == "XRPL/2.0"); - BEAST_EXPECT(to_string(makeProtocol(2, 1)) == "XRPL/2.1"); - BEAST_EXPECT(to_string(makeProtocol(10, 10)) == "XRPL/10.10"); + { + testcase("Convert protocol version to string"); + + BEAST_EXPECT(to_string(makeProtocol(0, 0)) == "XRPL/0.0"); + BEAST_EXPECT(to_string(makeProtocol(0, 1)) == "XRPL/0.1"); + BEAST_EXPECT(to_string(makeProtocol(1, 3)) == "XRPL/1.3"); + BEAST_EXPECT(to_string(makeProtocol(2, 0)) == "XRPL/2.0"); + BEAST_EXPECT(to_string(makeProtocol(2, 1)) == "XRPL/2.1"); + BEAST_EXPECT(to_string(makeProtocol(10, 10)) == "XRPL/10.10"); + BEAST_EXPECT(to_string(makeProtocol(65535, 65535)) == "XRPL/65535.65535"); + } { testcase("Convert strings to protocol versions"); - // Empty string + // Invalid versions, either they do not parse as XRPL/N.M or are unsupported. check("", ""); + check("RTXP/1.1,RTXP/1.2,RTXP/1.3", ""); + check("XRPL/-2.1,XRPL/0.3,XRPL/2,XRPL/2.01,websocket", ""); - check("RTXP/1.1,RTXP/1.2,RTXP/1.3,XRPL/2.1,XRPL/2.0,/XRPL/3.0", "XRPL/2.0,XRPL/2.1"); - check("RTXP/0.9,RTXP/1.01,XRPL/0.3,XRPL/2.01,websocket", ""); + // Mixture of valid, duplicate, and invalid versions. + check("RTXP/1.3,XRPL/2.1,XRPL/2.0,/XRPL/3.0", "XRPL/2.0,XRPL/2.1"); check( - "XRPL/2.0,XRPL/2.0,XRPL/19.4,XRPL/7.89,XRPL/XRPL/3.0,XRPL/2.01", + "XRPL/2.0,XRPL/2.0,XRPL/19.4,XRPL/7.89,XRPL/XRPL/3.0,XRPL/2.01,XRPL/-65535.65535", "XRPL/2.0,XRPL/7.89,XRPL/19.4"); check( "XRPL/2.0,XRPL/3.0,XRPL/4,XRPL/,XRPL,OPT XRPL/2.2,XRPL/5.67", @@ -58,15 +66,17 @@ public: { testcase("Protocol version negotiation"); - BEAST_EXPECT(negotiateProtocolVersion("RTXP/1.2") == std::nullopt); + // Only the highest supported protocol version, if any, is returned. + BEAST_EXPECT(negotiateProtocolVersion("") == std::nullopt); + BEAST_EXPECT(negotiateProtocolVersion("XRPL/0.0") == std::nullopt); + BEAST_EXPECT(negotiateProtocolVersion("RTXP/1.2,XRPL/0.1") == std::nullopt); BEAST_EXPECT( - negotiateProtocolVersion("RTXP/1.2, XRPL/2.0, XRPL/2.1") == makeProtocol(2, 1)); + negotiateProtocolVersion("XRPL/999.999, XRPL/-2.2,WebSocket/1.0") == std::nullopt); BEAST_EXPECT(negotiateProtocolVersion("XRPL/2.2") == makeProtocol(2, 2)); BEAST_EXPECT( - negotiateProtocolVersion("RTXP/1.2, XRPL/2.3, XRPL/2.4, XRPL/999.999") == + negotiateProtocolVersion( + "RTXP/1.2, XRPL/2.1, XRPL/2.2, XRPL/2.3, XRPL/2.4, XRPL/999.999") == makeProtocol(2, 3)); - BEAST_EXPECT(negotiateProtocolVersion("XRPL/999.999, WebSocket/1.0") == std::nullopt); - BEAST_EXPECT(negotiateProtocolVersion("") == std::nullopt); } } }; diff --git a/src/test/overlay/compression_test.cpp b/src/test/overlay/compression_test.cpp index a583a3aeab..40dee96c75 100644 --- a/src/test/overlay/compression_test.cpp +++ b/src/test/overlay/compression_test.cpp @@ -292,33 +292,6 @@ public: return getObject; } - static std::shared_ptr - buildValidatorList() - { - auto list = std::make_shared(); - - auto master = randomKeyPair(KeyType::Ed25519); - auto signing = randomKeyPair(KeyType::Ed25519); - STObject st(sfGeneric); - st[sfSequence] = 0; - st[sfPublicKey] = std::get<0>(master); - st[sfSigningPubKey] = std::get<0>(signing); - st[sfDomain] = makeSlice(std::string("example.com")); - sign(st, HashPrefix::Manifest, KeyType::Ed25519, std::get<1>(master), sfMasterSignature); - sign(st, HashPrefix::Manifest, KeyType::Ed25519, std::get<1>(signing)); - Serializer s; - st.add(s); - list->set_manifest(s.data(), s.size()); - list->set_version(3); - STObject const signature(sfSignature); - xrpl::sign(st, HashPrefix::Manifest, KeyType::Ed25519, std::get<1>(signing)); - Serializer s1; - st.add(s1); - list->set_signature(s1.data(), s1.size()); - list->set_blob(strHex(s.slice())); - return list; - } - static std::shared_ptr buildValidatorListCollection() { @@ -359,7 +332,6 @@ public: protocol::TMGetLedger const getLedger; protocol::TMLedgerData const ledgerData; protocol::TMGetObjectByHash const getObject; - protocol::TMValidatorList const validatorList; protocol::TMValidatorListCollection const validatorListCollection; // 4.5KB @@ -386,8 +358,6 @@ public: doTest(buildLedgerData(500000, *logs), protocol::mtLEDGER_DATA, 100, "TMLedgerData500000"); // 7.7KB doTest(buildGetObjectByHash(), protocol::mtGET_OBJECTS, 4, "TMGetObjectByHash"); - // 895B - doTest(buildValidatorList(), protocol::mtVALIDATOR_LIST, 4, "TMValidatorList"); doTest( buildValidatorListCollection(), protocol::mtVALIDATOR_LIST_COLLECTION, diff --git a/src/xrpld/app/misc/ValidatorList.h b/src/xrpld/app/misc/ValidatorList.h index abec6cf4e0..4e001affe8 100644 --- a/src/xrpld/app/misc/ValidatorList.h +++ b/src/xrpld/app/misc/ValidatorList.h @@ -30,7 +30,6 @@ #include namespace protocol { -class TMValidatorList; class TMValidatorListCollection; } // namespace protocol @@ -371,9 +370,6 @@ public: static std::vector parseBlobs(std::uint32_t version, json::Value const& body); - static std::vector - parseBlobs(protocol::TMValidatorList const& body); - static std::vector parseBlobs(protocol::TMValidatorListCollection const& body); @@ -391,7 +387,6 @@ public: [[nodiscard]] static std::pair buildValidatorListMessages( - std::size_t messageVersion, std::uint64_t peerSequence, std::size_t maxSequence, std::uint32_t rawVersion, @@ -987,14 +982,6 @@ hash_append(Hasher& h, std::map const& blobs) namespace protocol { -template -void -hash_append(Hasher& h, TMValidatorList const& msg) -{ - using beast::hash_append; - hash_append(h, msg.manifest(), msg.blob(), msg.signature(), msg.version()); -} - template void hash_append(Hasher& h, TMValidatorListCollection const& msg) diff --git a/src/xrpld/app/misc/detail/ValidatorList.cpp b/src/xrpld/app/misc/detail/ValidatorList.cpp index 0ada8ed55f..f099ebf059 100644 --- a/src/xrpld/app/misc/detail/ValidatorList.cpp +++ b/src/xrpld/app/misc/detail/ValidatorList.cpp @@ -449,13 +449,6 @@ ValidatorList::parseBlobs(std::uint32_t version, json::Value const& body) } } -// static -std::vector -ValidatorList::parseBlobs(protocol::TMValidatorList const& body) -{ - return {{.blob = body.blob(), .signature = body.signature(), .manifest = {}}}; -} - // static std::vector ValidatorList::parseBlobs(protocol::TMValidatorListCollection const& body) @@ -476,7 +469,7 @@ ValidatorList::parseBlobs(protocol::TMValidatorListCollection const& body) } XRPL_ASSERT( result.size() == body.blobs_size(), - "xrpl::ValidatorList::parseBlobs(TMValidatorList) : result size " + "xrpl::ValidatorList::parseBlobs(TMValidatorListCollection) : result size " "match"); return result; } @@ -520,29 +513,6 @@ splitMessageParts( { if (end <= begin) return 0; - if (end - begin == 1) - { - protocol::TMValidatorList smallMsg; - smallMsg.set_version(1); - smallMsg.set_manifest(largeMsg.manifest()); - - auto const& blob = largeMsg.blobs(begin); - smallMsg.set_blob(blob.blob()); - smallMsg.set_signature(blob.signature()); - // This is only possible if "downgrading" a v2 UNL to v1. - if (blob.has_manifest()) - smallMsg.set_manifest(blob.manifest()); - - XRPL_ASSERT( - Message::totalSize(smallMsg) <= kMaximumMessageSize, - "xrpl::splitMessageParts : maximum message size"); - - messages.emplace_back( - std::make_shared(smallMsg, protocol::mtVALIDATOR_LIST), - sha512Half(smallMsg), - 1); - return messages.back().numVLs; - } std::optional smallMsg; smallMsg.emplace(); @@ -554,13 +524,29 @@ splitMessageParts( *smallMsg->add_blobs() = largeMsg.blobs(i); } - if (Message::totalSize(*smallMsg) > maxSize) + auto const size = Message::totalSize(*smallMsg); + + // Split until each message fits, but a single blob can't be split any + // further, so stop recursing at that point regardless of maxSize. + if (size > maxSize && end - begin > 1) { // free up the message space smallMsg.reset(); return splitMessage(messages, largeMsg, maxSize, begin, end); } + // An unsplittable blob is still bounded by the protocol limit: peers drop + // messages exceeding it on receipt, so don't waste the bandwidth. maxSize + // only ever tightens this (it defaults to kMaximumMessageSize), so a blob + // reaching here can exceed maxSize but never the protocol limit. + if (size > kMaximumMessageSize) + { + // LCOV_EXCL_START + UNREACHABLE("xrpl::splitMessageParts : maximum message size exceeded"); + return 0; + // LCOV_EXCL_STOP + } + messages.emplace_back( std::make_shared(*smallMsg, protocol::mtVALIDATOR_LIST_COLLECTION), sha512Half(*smallMsg), @@ -568,37 +554,6 @@ splitMessageParts( return messages.back().numVLs; } -// Build a v1 protocol message using only the current VL -std::size_t -buildValidatorListMessage( - std::vector& messages, - std::uint32_t rawVersion, - std::string const& rawManifest, - ValidatorBlobInfo const& currentBlob, - std::size_t maxSize) -{ - XRPL_ASSERT( - messages.empty(), - "xrpl::buildValidatorListMessage(ValidatorBlobInfo) : empty messages " - "input"); - protocol::TMValidatorList msg; - auto const manifest = currentBlob.manifest ? *currentBlob.manifest : rawManifest; - auto const version = 1; - msg.set_manifest(manifest); - msg.set_blob(currentBlob.blob); - msg.set_signature(currentBlob.signature); - // Override the version - msg.set_version(version); - - XRPL_ASSERT( - Message::totalSize(msg) <= kMaximumMessageSize, - "xrpl::buildValidatorListMessage(ValidatorBlobInfo) : maximum " - "message size"); - messages.emplace_back( - std::make_shared(msg, protocol::mtVALIDATOR_LIST), sha512Half(msg), 1); - return 1; -} - // Build a v2 protocol message using all the VLs with sequence larger than the // peer's std::size_t @@ -650,7 +605,6 @@ buildValidatorListMessage( // static std::pair ValidatorList::buildValidatorListMessages( - std::size_t messageVersion, std::uint64_t peerSequence, std::size_t maxSequence, std::uint32_t rawVersion, @@ -663,14 +617,12 @@ ValidatorList::buildValidatorListMessages( !blobInfos.empty(), "xrpl::ValidatorList::buildValidatorListMessages : empty messages " "input"); - auto const& [currentSeq, currentBlob] = *blobInfos.begin(); auto numVLs = std::accumulate( messages.begin(), messages.end(), 0, [](std::size_t total, MessageWithHash const& m) { return total + m.numVLs; }); - if (messageVersion == 2 && peerSequence < maxSequence) + if (peerSequence < maxSequence) { - // Version 2 if (messages.empty()) { numVLs = buildValidatorListMessage( @@ -678,36 +630,13 @@ ValidatorList::buildValidatorListMessages( if (messages.empty()) { // No message was generated. Create an empty placeholder so we - // dont' repeat the work later. + // don't repeat the work later. messages.emplace_back(); } } - // Don't send it next time. return {maxSequence, numVLs}; } - if (messageVersion == 1 && peerSequence < currentSeq) - { - // Version 1 - if (messages.empty()) - { - numVLs = buildValidatorListMessage( - messages, - rawVersion, - currentBlob.manifest ? *currentBlob.manifest : rawManifest, - currentBlob, - maxSize); - if (messages.empty()) - { - // No message was generated. Create an empty placeholder so we - // dont' repeat the work later. - messages.emplace_back(); - } - } - - // Don't send it next time. - return {currentSeq, numVLs}; - } return {0, 0}; } @@ -725,19 +654,8 @@ ValidatorList::sendValidatorList( HashRouter& hashRouter, beast::Journal j) { - std::size_t messageVersion = 0; - if (peer.supportsFeature(ProtocolFeature::ValidatorList2Propagation)) - { - messageVersion = 2; - } - else if (peer.supportsFeature(ProtocolFeature::ValidatorListPropagation)) - { - messageVersion = 1; - } - if (messageVersion == 0u) - return; auto const [newPeerSequence, numVLs] = buildValidatorListMessages( - messageVersion, peerSequence, maxSequence, rawVersion, rawManifest, blobInfos, messages); + peerSequence, maxSequence, rawVersion, rawManifest, blobInfos, messages); if (newPeerSequence != 0u) { XRPL_ASSERT( @@ -764,24 +682,11 @@ ValidatorList::sendValidatorList( "xrpl::ValidatorList::sendValidatorList : sent or one message"); if (sent) { - if (messageVersion > 1) - { - JLOG(j.debug()) << "Sent " << messages.size() - << " validator list collection(s) containing " << numVLs - << " validator list(s) for " << strHex(publisherKey) - << " with sequence range " << peerSequence << ", " - << newPeerSequence << " to " << peer.fingerprint(); - } - else - { - XRPL_ASSERT( - numVLs == 1, - "xrpl::ValidatorList::sendValidatorList : one validator " - "list"); - JLOG(j.debug()) << "Sent validator list for " << strHex(publisherKey) - << " with sequence " << newPeerSequence << " to " - << peer.fingerprint(); - } + JLOG(j.debug()) << "Sent " << messages.size() + << " validator list collection(s) containing " << numVLs + << " validator list(s) for " << strHex(publisherKey) + << " with sequence range " << peerSequence << ", " << newPeerSequence + << " to " << peer.fingerprint(); } } } @@ -856,16 +761,9 @@ ValidatorList::broadcastBlobs( if (toSkip) { - // We don't know what messages or message versions we're sending - // until we examine our peer's properties. Build the message(s) on - // demand, but reuse them when possible. - - // This will hold a v1 message with only the current VL if we have - // any peers that don't support v2 - std::vector messages1; - // This will hold v2 messages indexed by the peer's - // `publisherListSequence`. For each `publisherListSequence`, we'll - // only send the VLs with higher sequences. + // Build v2 messages on demand and reuse them when possible. Messages + // are indexed by the peer's `publisherListSequence`; for each sequence, + // we only send VLs with higher sequences. std::map> messages2; // If any peers are found that are worth considering, this list will // be built to hold info for all of the valid VLs. @@ -885,8 +783,6 @@ ValidatorList::broadcastBlobs( { if (blobInfos.empty()) buildBlobInfos(blobInfos, lists); - auto const v2 = - peer->supportsFeature(ProtocolFeature::ValidatorList2Propagation); sendValidatorList( *peer, peerSequence, @@ -895,11 +791,10 @@ ValidatorList::broadcastBlobs( lists.rawVersion, lists.rawManifest, blobInfos, - v2 ? messages2[peerSequence] : messages1, + messages2[peerSequence], hashRouter, j); - // Even if the peer doesn't support the messages, - // suppress it so it'll be ignored next time. + // Don't send it next time. hashRouter.addSuppressionPeer(hash, peer->id()); } } diff --git a/src/xrpld/overlay/Peer.h b/src/xrpld/overlay/Peer.h index 87750ed40e..6c4cf1dff1 100644 --- a/src/xrpld/overlay/Peer.h +++ b/src/xrpld/overlay/Peer.h @@ -20,8 +20,6 @@ class Charge; } // namespace resource enum class ProtocolFeature { - ValidatorListPropagation, - ValidatorList2Propagation, LedgerReplay, LedgerNodeDepth, }; diff --git a/src/xrpld/overlay/detail/Message.cpp b/src/xrpld/overlay/detail/Message.cpp index c6e0511515..a6af525620 100644 --- a/src/xrpld/overlay/detail/Message.cpp +++ b/src/xrpld/overlay/detail/Message.cpp @@ -82,7 +82,6 @@ Message::compress() case protocol::mtGET_LEDGER: case protocol::mtLEDGER_DATA: case protocol::mtGET_OBJECTS: - case protocol::mtVALIDATOR_LIST: case protocol::mtVALIDATOR_LIST_COLLECTION: case protocol::mtREPLAY_DELTA_RESPONSE: case protocol::mtTRANSACTIONS: diff --git a/src/xrpld/overlay/detail/PeerImp.cpp b/src/xrpld/overlay/detail/PeerImp.cpp index 726002fce4..3f0b4453b8 100644 --- a/src/xrpld/overlay/detail/PeerImp.cpp +++ b/src/xrpld/overlay/detail/PeerImp.cpp @@ -542,10 +542,6 @@ PeerImp::supportsFeature(ProtocolFeature f) const { switch (f) { - case ProtocolFeature::ValidatorListPropagation: - return protocol_ >= makeProtocol(2, 1); - case ProtocolFeature::ValidatorList2Propagation: - return protocol_ >= makeProtocol(2, 2); case ProtocolFeature::LedgerNodeDepth: return protocol_ >= makeProtocol(2, 3); case ProtocolFeature::LedgerReplay: @@ -885,7 +881,7 @@ PeerImp::doProtocolStart() onReadMessage(error_code(), 0); // Send all the validator lists that have been loaded - if (inbound_ && supportsFeature(ProtocolFeature::ValidatorListPropagation)) + if (inbound_) { app_.getValidators().forEachAvailable( [&](std::string const& manifest, @@ -2422,43 +2418,11 @@ PeerImp::onValidatorListMessage( } } -void -PeerImp::onMessage(std::shared_ptr const& m) -{ - try - { - if (!supportsFeature(ProtocolFeature::ValidatorListPropagation)) - { - JLOG(pJournal_.debug()) << "ValidatorList: received validator list from peer using " - << "protocol version " << to_string(protocol_) - << " which shouldn't support this feature."; - fee_.update(resource::kFeeUselessData, "unsupported peer"); - return; - } - onValidatorListMessage( - "ValidatorList", m->manifest(), m->version(), ValidatorList::parseBlobs(*m)); - } - catch (std::exception const& e) - { - JLOG(pJournal_.warn()) << "ValidatorList: Exception, " << e.what(); - using namespace std::string_literals; - fee_.update(resource::kFeeInvalidData, e.what()); - } -} - void PeerImp::onMessage(std::shared_ptr const& m) { try { - if (!supportsFeature(ProtocolFeature::ValidatorList2Propagation)) - { - JLOG(pJournal_.debug()) << "ValidatorListCollection: received validator list from peer " - << "using protocol version " << to_string(protocol_) - << " which shouldn't support this feature."; - fee_.update(resource::kFeeUselessData, "unsupported peer"); - return; - } if (m->version() < 2) { JLOG(pJournal_.debug()) diff --git a/src/xrpld/overlay/detail/PeerImp.h b/src/xrpld/overlay/detail/PeerImp.h index 7078d6fb56..0f229bf9d8 100644 --- a/src/xrpld/overlay/detail/PeerImp.h +++ b/src/xrpld/overlay/detail/PeerImp.h @@ -623,8 +623,6 @@ public: void onMessage(std::shared_ptr const& m); void - onMessage(std::shared_ptr const& m); - void onMessage(std::shared_ptr const& m); void onMessage(std::shared_ptr const& m); diff --git a/src/xrpld/overlay/detail/ProtocolMessage.h b/src/xrpld/overlay/detail/ProtocolMessage.h index f7d5e26272..88f50e1e2e 100644 --- a/src/xrpld/overlay/detail/ProtocolMessage.h +++ b/src/xrpld/overlay/detail/ProtocolMessage.h @@ -71,8 +71,6 @@ protocolMessageName(int type) return "status"; case protocol::mtHAVE_SET: return "have_set"; - case protocol::mtVALIDATOR_LIST: - return "validator_list"; case protocol::mtVALIDATOR_LIST_COLLECTION: return "validator_list_collection"; case protocol::mtVALIDATION: @@ -424,9 +422,6 @@ invokeProtocolMessage(Buffers const& buffers, Handler& handler, std::size_t& hin case protocol::mtVALIDATION: success = detail::invoke(*header, buffers, handler); break; - case protocol::mtVALIDATOR_LIST: - success = detail::invoke(*header, buffers, handler); - break; case protocol::mtVALIDATOR_LIST_COLLECTION: success = detail::invoke(*header, buffers, handler); diff --git a/src/xrpld/overlay/detail/ProtocolVersion.cpp b/src/xrpld/overlay/detail/ProtocolVersion.cpp index 93d4fae156..1296041ad5 100644 --- a/src/xrpld/overlay/detail/ProtocolVersion.cpp +++ b/src/xrpld/overlay/detail/ProtocolVersion.cpp @@ -28,7 +28,6 @@ namespace xrpl { */ constexpr ProtocolVersion const kSupportedProtocolList[]{ - {2, 1}, {2, 2}, {2, 3}, }; diff --git a/src/xrpld/overlay/detail/TrafficCount.cpp b/src/xrpld/overlay/detail/TrafficCount.cpp index bdce9e68f0..90d5c0b4ff 100644 --- a/src/xrpld/overlay/detail/TrafficCount.cpp +++ b/src/xrpld/overlay/detail/TrafficCount.cpp @@ -14,7 +14,6 @@ std::unordered_map const kTypeLoo {protocol::mtMANIFESTS, TrafficCount::Category::Manifests}, {protocol::mtENDPOINTS, TrafficCount::Category::Overlay}, {protocol::mtTRANSACTION, TrafficCount::Category::Transaction}, - {protocol::mtVALIDATOR_LIST, TrafficCount::Category::Validatorlist}, {protocol::mtVALIDATOR_LIST_COLLECTION, TrafficCount::Category::Validatorlist}, {protocol::mtVALIDATION, TrafficCount::Category::Validation}, {protocol::mtPROPOSE_LEDGER, TrafficCount::Category::Proposal}, From 43d842926a8c7a154062a90d389e526be3e89d2e Mon Sep 17 00:00:00 2001 From: Ed Hennis Date: Fri, 14 Aug 2026 20:18:33 +0000 Subject: [PATCH 081/102] refactor: Rewrite Transactor::operator() to early return (#8003) --- src/libxrpl/tx/Transactor.cpp | 136 ++++++++++++++++++---------------- 1 file changed, 72 insertions(+), 64 deletions(-) diff --git a/src/libxrpl/tx/Transactor.cpp b/src/libxrpl/tx/Transactor.cpp index 5fc6942e20..594aa24940 100644 --- a/src/libxrpl/tx/Transactor.cpp +++ b/src/libxrpl/tx/Transactor.cpp @@ -46,6 +46,7 @@ #include #include #include +#include #include #include #include @@ -1637,85 +1638,92 @@ Transactor::operator()() if (auto stream = j_.trace()) stream << "preclaim result: " << transToken(result); - bool applied = isTesSuccess(result); auto fee = ctx_.tx.getFieldAmount(sfFee).xrp(); + bool const canApply = std::invoke([&result, &fee, this] { + bool canApplyTmp = isTesSuccess(result); - if (ctx_.size() > kOversizeMetaDataCap) - result = tecOVERSIZE; + if (ctx_.size() > kOversizeMetaDataCap) + result = tecOVERSIZE; - if (isTecClaim(result) && ((view().flags() & TapFailHard) != 0u)) - { - // If the TapFailHard flag is set, a tec result - // must not do anything - ctx_.discard(); - applied = false; - } - else if ( - (result == tecOVERSIZE) || (result == tecKILLED) || (result == tecINCOMPLETE) || - (result == tecEXPIRED) || (isTecClaimHardFail(result, view().flags()))) - { - std::tie(result, fee, applied) = processPersistentChanges(result, fee); - } - - if (applied) - { - // Check invariants: if `tecINVARIANT_FAILED` is not returned, we can - // proceed to apply the tx - result = checkInvariants(result, fee); - if (result == tecINVARIANT_FAILED) + if (isTecClaim(result) && ((view().flags() & TapFailHard) != 0u)) { - // Reset to fee-claim only - auto const resetResult = reset(fee); - if (!isTesSuccess(resetResult.first)) - result = resetResult.first; - - fee = resetResult.second; - - // Check invariants again to ensure the fee claiming doesn't violate - // invariants. After reset, only protocol invariants are re-checked. - // Transaction invariants are not meaningful here — the transaction's - // effects have been rolled back. - if (isTesSuccess(result) || isTecClaim(result)) - result = ctx_.checkInvariants(result, fee); + // If the TapFailHard flag is set, a tec result + // must not do anything + ctx_.discard(); + canApplyTmp = false; } + else if ( + (result == tecOVERSIZE) || (result == tecKILLED) || (result == tecINCOMPLETE) || + (result == tecEXPIRED) || (isTecClaimHardFail(result, view().flags()))) + { + // This is and must remain the only place where `canApplyTmp` can change from false to + // true. Changing from true to false is no problem. + std::tie(result, fee, canApplyTmp) = processPersistentChanges(result, fee); + } + return canApplyTmp; + }); - // We ran through the invariant checker, which can, in some cases, - // return a tef error code. Don't apply the transaction in that case. - if (!isTecClaim(result) && !isTesSuccess(result)) - applied = false; + auto const logger = [this]( + TER result, + bool canApply, + std::optional&& metadata = std::nullopt) -> ApplyResult { + JLOG(j_.trace()) << (canApply ? "applied " : "not applied ") << transToken(result); + return {result, canApply, std::move(metadata)}; + }; + + if (!canApply) + return logger(result, canApply); + + // Check invariants: if `tecINVARIANT_FAILED` is not returned, we can + // proceed to apply the tx + result = checkInvariants(result, fee); + if (result == tecINVARIANT_FAILED) + { + // Reset to fee-claim only + auto const resetResult = reset(fee); + if (!isTesSuccess(resetResult.first)) + result = resetResult.first; + + fee = resetResult.second; + + // Check invariants again to ensure the fee claiming doesn't violate + // invariants. After reset, only protocol invariants are re-checked. + // Transaction invariants are not meaningful here — the transaction's + // effects have been rolled back. + if (isTesSuccess(result) || isTecClaim(result)) + result = ctx_.checkInvariants(result, fee); } + // We ran through the invariant checker, which can, in some cases, + // return a tef error code. Don't apply the transaction in that case. + if (!isTecClaim(result) && !isTesSuccess(result)) + return logger(result, false); + std::optional metadata; - if (applied) - { - // Transaction succeeded fully or (retries are not allowed and the - // transaction could claim a fee) - // The transactor and invariant checkers guarantee that this will - // *never* trigger but if it, somehow, happens, don't allow a tx - // that charges a negative fee. - if (fee < beast::kZero) - Throw("fee charged is negative!"); + // Transaction succeeded fully or (retries are not allowed and the + // transaction could claim a fee) - // Charge whatever fee they specified. The fee has already been - // deducted from the balance of the account that issued the - // transaction. We just need to account for it in the ledger - // header. - if (!view().open() && fee != beast::kZero) - ctx_.destroyXRP(fee); + // The transactor and invariant checkers guarantee that this will + // *never* trigger but if it, somehow, happens, don't allow a tx + // that charges a negative fee. + if (fee < beast::kZero) + Throw("fee charged is negative!"); - // Once we call apply, we will no longer be able to look at view() - metadata = ctx_.apply(result); - } + // Charge whatever fee they specified. The fee has already been + // deducted from the balance of the account that issued the + // transaction. We just need to account for it in the ledger + // header. + if (!view().open() && fee != beast::kZero) + ctx_.destroyXRP(fee); + + // Once we call apply, we will no longer be able to look at view() + metadata = ctx_.apply(result); if ((ctx_.flags() & TapDryRun) != 0u) - { - applied = false; - } + return logger(result, false, std::move(metadata)); - JLOG(j_.trace()) << (applied ? "applied " : "not applied ") << transToken(result); - - return {result, applied, metadata}; + return logger(result, canApply, std::move(metadata)); } } // namespace xrpl From 5337d028a2559bd75ec46b60b7a5539487d18d0a Mon Sep 17 00:00:00 2001 From: Bart Date: Mon, 17 Aug 2026 10:07:14 +0000 Subject: [PATCH 082/102] refactor: Use unsigned int for branch-related operations (#7938) Co-authored-by: Bart <11445373+bthomee@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 --- include/xrpl/shamap/SHAMap.h | 35 +++++---- include/xrpl/shamap/SHAMapInnerNode.h | 26 +++---- include/xrpl/shamap/SHAMapNodeID.h | 4 +- include/xrpl/shamap/detail/TaggedPointer.h | 12 +-- include/xrpl/shamap/detail/TaggedPointer.ipp | 59 +++++++------- src/libxrpl/shamap/SHAMap.cpp | 77 +++++++++---------- src/libxrpl/shamap/SHAMapDelta.cpp | 22 +++--- src/libxrpl/shamap/SHAMapInnerNode.cpp | 69 ++++++++--------- src/libxrpl/shamap/SHAMapNodeID.cpp | 15 ++-- src/libxrpl/shamap/SHAMapSync.cpp | 58 ++++++++------ .../app/ledger/detail/LedgerNodeHelpers.cpp | 2 +- 11 files changed, 195 insertions(+), 184 deletions(-) diff --git a/include/xrpl/shamap/SHAMap.h b/include/xrpl/shamap/SHAMap.h index 97ab2e9f7a..05de33ddf3 100644 --- a/include/xrpl/shamap/SHAMap.h +++ b/include/xrpl/shamap/SHAMap.h @@ -484,31 +484,36 @@ private: // returns the first item at or below this node SHAMapLeafNode* - firstBelow(SHAMapTreeNodePtr node, SharedPtrNodeStack& stack, int branch = 0) const; + firstBelow(SHAMapTreeNodePtr node, SharedPtrNodeStack& stack, unsigned int branch = 0u) const; // returns the last item at or below this node SHAMapLeafNode* - lastBelow(SHAMapTreeNodePtr node, SharedPtrNodeStack& stack, int branch = kBranchFactor) const; + lastBelow( + SHAMapTreeNodePtr node, + SharedPtrNodeStack& stack, + unsigned int branch = kBranchFactor) const; + + // direction in which belowHelper scans an inner node's branches + enum class BelowDirection { First, Last }; // helper function for firstBelow and lastBelow SHAMapLeafNode* belowHelper( SHAMapTreeNodePtr node, SharedPtrNodeStack& stack, - int branch, - std::tuple, std::function> const& loopParams) - const; + unsigned int branch, + BelowDirection direction) const; // Simple descent // Get a child of the specified node SHAMapTreeNode* - descend(SHAMapInnerNode*, int branch) const; + descend(SHAMapInnerNode*, unsigned int branch) const; SHAMapTreeNode* - descendThrow(SHAMapInnerNode*, int branch) const; + descendThrow(SHAMapInnerNode*, unsigned int branch) const; SHAMapTreeNodePtr - descend(SHAMapInnerNode&, int branch) const; + descend(SHAMapInnerNode&, unsigned int branch) const; SHAMapTreeNodePtr - descendThrow(SHAMapInnerNode&, int branch) const; + descendThrow(SHAMapInnerNode&, unsigned int branch) const; // Descend with filter // If pending, callback is called as if it called fetchNodeNT @@ -516,7 +521,7 @@ private: SHAMapTreeNode* descendAsync( SHAMapInnerNode* parent, - int branch, + unsigned int branch, SHAMapSyncFilter const* filter, bool& pending, descendCallback&&) const; @@ -525,13 +530,13 @@ private: descend( SHAMapInnerNode* parent, SHAMapNodeID const& parentID, - int branch, + unsigned int branch, SHAMapSyncFilter const* filter) const; // Non-storing // Does not hook the returned node to its parent SHAMapTreeNodePtr - descendNoStore(SHAMapInnerNode&, int branch) const; + descendNoStore(SHAMapInnerNode&, unsigned int branch) const; /** * If there is only one leaf below this node, get its contents @@ -581,8 +586,8 @@ private: using StackEntry = std::tuple< SHAMapInnerNode*, // pointer to the node SHAMapNodeID, // the node's ID - int, // while child we check first - int, // which child we check next + unsigned int, // which child we check first + unsigned int, // which child we check next bool>; // whether we've found any missing children yet // We explicitly choose to specify the use of std::deque here, because @@ -596,7 +601,7 @@ private: using DeferredNode = std::tuple< SHAMapInnerNode*, // parent node SHAMapNodeID, // parent node ID - int, // branch + unsigned int, // branch SHAMapTreeNodePtr>; // node int deferred; diff --git a/include/xrpl/shamap/SHAMapInnerNode.h b/include/xrpl/shamap/SHAMapInnerNode.h index 44d3bd6279..83d039172f 100644 --- a/include/xrpl/shamap/SHAMapInnerNode.h +++ b/include/xrpl/shamap/SHAMapInnerNode.h @@ -62,8 +62,8 @@ private: * * @param i index of the requested child */ - std::optional - getChildIndex(int i) const; + std::optional + getChildIndex(unsigned int i) const; /** * Call the `f` callback for all 16 (branchFactor) branches - even if @@ -125,28 +125,28 @@ public: isEmpty() const; bool - isEmptyBranch(int m) const; + isEmptyBranch(unsigned int branch) const; - int + unsigned int getBranchCount() const; SHAMapHash const& - getChildHash(int m) const; + getChildHash(unsigned int branch) const; void - setChild(int m, SHAMapTreeNodePtr child); + setChild(unsigned int branch, SHAMapTreeNodePtr child); void - shareChild(int m, SHAMapTreeNodePtr const& child); + shareChild(unsigned int branch, SHAMapTreeNodePtr const& child); SHAMapTreeNode* - getChildPointer(int branch); + getChildPointer(unsigned int branch); SHAMapTreeNodePtr - getChild(int branch); + getChild(unsigned int branch); SHAMapTreeNodePtr - canonicalizeChild(int branch, SHAMapTreeNodePtr node); + canonicalizeChild(unsigned int branch, SHAMapTreeNodePtr node); // sync functions bool @@ -190,12 +190,12 @@ SHAMapInnerNode::isEmpty() const } inline bool -SHAMapInnerNode::isEmptyBranch(int m) const +SHAMapInnerNode::isEmptyBranch(unsigned int branch) const { - return (isBranch_ & (1 << m)) == 0; + return (isBranch_ & (1u << branch)) == 0u; } -inline int +inline unsigned int SHAMapInnerNode::getBranchCount() const { return popcnt16(isBranch_); diff --git a/include/xrpl/shamap/SHAMapNodeID.h b/include/xrpl/shamap/SHAMapNodeID.h index 1189304aa7..fcd5a4d00e 100644 --- a/include/xrpl/shamap/SHAMapNodeID.h +++ b/include/xrpl/shamap/SHAMapNodeID.h @@ -53,7 +53,7 @@ public: } [[nodiscard]] SHAMapNodeID - getChildNodeID(unsigned int m) const; + getChildNodeID(unsigned int branch) const; /** * Create a SHAMapNodeID of a node with the depth of the node and @@ -64,7 +64,7 @@ public: * @return SHAMapNodeID of the node */ static SHAMapNodeID - createID(int depth, uint256 const& key); + createID(unsigned int depth, uint256 const& key); /** * Comparison operators diff --git a/include/xrpl/shamap/detail/TaggedPointer.h b/include/xrpl/shamap/detail/TaggedPointer.h index 509e6cc58d..705681be1d 100644 --- a/include/xrpl/shamap/detail/TaggedPointer.h +++ b/include/xrpl/shamap/detail/TaggedPointer.h @@ -219,11 +219,11 @@ public: * * @param i index of the requested child */ - [[nodiscard]] std::optional - getChildIndex(std::uint16_t isBranch, int i) const; + [[nodiscard]] std::optional + getChildIndex(std::uint16_t isBranch, unsigned int i) const; }; -[[nodiscard]] inline int +[[nodiscard]] inline unsigned int popcnt16(std::uint16_t a) { #if __cpp_lib_bitops @@ -234,11 +234,11 @@ popcnt16(std::uint16_t a) // fallback to table lookup static constexpr auto tbl = []() { std::array ret{}; - for (int i = 0; i != 256; ++i) + for (auto i = 0u; i != 256u; ++i) { - for (int j = 0; j != 8; ++j) + for (auto j = 0u; j != 8u; ++j) { - if (i & (1 << j)) + if (i & (1u << j)) ret[i]++; } } diff --git a/include/xrpl/shamap/detail/TaggedPointer.ipp b/include/xrpl/shamap/detail/TaggedPointer.ipp index 9275f3d15a..7db101b3cb 100644 --- a/include/xrpl/shamap/detail/TaggedPointer.ipp +++ b/include/xrpl/shamap/detail/TaggedPointer.ipp @@ -22,6 +22,11 @@ static_assert( static_assert( kBoundaries.back() == SHAMapInnerNode::kBranchFactor, "Last element of boundaries must be number of children in a dense array"); +static_assert( + kBoundaries.front() >= 1, + "TaggedPointer.ipp subtracts 1 from a numAllocated value derived from " + "kBoundaries, as an unsigned quantity, in several places; the smallest " + "boundary must stay non-zero or those subtractions underflow."); // Terminology: A chunk is the memory being allocated from a block. A block // contains multiple chunks. This is the terminology the boost documentation @@ -148,16 +153,16 @@ TaggedPointer::iterChildren(std::uint16_t isBranch, F&& f) const if (numAllocated == SHAMapInnerNode::kBranchFactor) { // dense case - for (int i = 0; i < SHAMapInnerNode::kBranchFactor; ++i) + for (auto i = 0u; i < SHAMapInnerNode::kBranchFactor; ++i) f(hashes[i]); } else { // sparse case - int curHashI = 0; - for (int i = 0; i < SHAMapInnerNode::kBranchFactor; ++i) + auto curHashI = 0u; + for (auto i = 0u; i < SHAMapInnerNode::kBranchFactor; ++i) { - if ((1 << i) & isBranch) + if ((1u << i) & isBranch) { f(hashes[curHashI++]); } @@ -176,9 +181,9 @@ TaggedPointer::iterNonEmptyChildIndexes(std::uint16_t isBranch, F&& f) const if (capacity() == SHAMapInnerNode::kBranchFactor) { // dense case - for (int i = 0; i < SHAMapInnerNode::kBranchFactor; ++i) + for (auto i = 0u; i < SHAMapInnerNode::kBranchFactor; ++i) { - if ((1 << i) & isBranch) + if ((1u << i) & isBranch) { f(i, i); } @@ -187,10 +192,10 @@ TaggedPointer::iterNonEmptyChildIndexes(std::uint16_t isBranch, F&& f) const else { // sparse case - int curHashI = 0; - for (int i = 0; i < SHAMapInnerNode::kBranchFactor; ++i) + auto curHashI = 0u; + for (auto i = 0u; i < SHAMapInnerNode::kBranchFactor; ++i) { - if ((1 << i) & isBranch) + if ((1u << i) & isBranch) { f(i, curHashI++); } @@ -216,14 +221,14 @@ TaggedPointer::destroyHashesAndChildren() deallocateArrays(tag, ptr); } -inline std::optional -TaggedPointer::getChildIndex(std::uint16_t isBranch, int i) const +inline std::optional +TaggedPointer::getChildIndex(std::uint16_t isBranch, unsigned int i) const { if (isDense()) return i; // Sparse case - if ((isBranch & (1 << i)) == 0) + if ((isBranch & (1u << i)) == 0u) { // Empty branch. Sparse children do not store empty branches return {}; @@ -273,10 +278,10 @@ inline TaggedPointer::TaggedPointer( *this = std::move(other); auto [srcDstNumAllocated, srcDstHashes, srcDstChildren] = getHashesAndChildren(); bool const srcDstIsDense = isDense(); - int srcDstIndex = 0; - for (int i = 0; i < SHAMapInnerNode::kBranchFactor; ++i) + auto srcDstIndex = 0u; + for (auto i = 0u; i < SHAMapInnerNode::kBranchFactor; ++i) { - auto const mask = (1 << i); + auto const mask = (1u << i); bool const inSrc = (srcBranches & mask) != 0; bool const inDst = (dstBranches & mask) != 0; if (inSrc && inDst) @@ -298,13 +303,13 @@ inline TaggedPointer::TaggedPointer( // sparse // need to shift all the elements to the left by // one - for (int c = srcDstIndex; c < srcDstNumAllocated - 1; ++c) + for (auto c = srcDstIndex; c + 1 < srcDstNumAllocated; ++c) { srcDstHashes[c] = srcDstHashes[c + 1]; srcDstChildren[c] = std::move(srcDstChildren[c + 1]); } - srcDstHashes[srcDstNumAllocated - 1].zero(); - srcDstChildren[srcDstNumAllocated - 1].reset(); + srcDstHashes[srcDstNumAllocated - 1u].zero(); + srcDstChildren[srcDstNumAllocated - 1u].reset(); // do not increment the index } } @@ -321,7 +326,7 @@ inline TaggedPointer::TaggedPointer( // sparse // need to create a hole by shifting all the elements to the // right by one - for (int c = srcDstNumAllocated - 1; c > srcDstIndex; --c) + for (auto c = srcDstNumAllocated - 1u; c > srcDstIndex; --c) { srcDstHashes[c] = srcDstHashes[c - 1]; srcDstChildren[c] = std::move(srcDstChildren[c - 1]); @@ -352,10 +357,10 @@ inline TaggedPointer::TaggedPointer( auto [srcNumAllocated, srcHashes, srcChildren] = src.getHashesAndChildren(); bool const srcIsDense = src.isDense(); bool const dstIsDense = dst.isDense(); - int srcIndex = 0, dstIndex = 0; - for (int i = 0; i < SHAMapInnerNode::kBranchFactor; ++i) + auto srcIndex = 0u, dstIndex = 0u; + for (auto i = 0u; i < SHAMapInnerNode::kBranchFactor; ++i) { - auto const mask = (1 << i); + auto const mask = (1u << i); bool const inSrc = (srcBranches & mask) != 0; bool const inDst = (dstBranches & mask) != 0; if (inSrc && inDst) @@ -409,7 +414,7 @@ inline TaggedPointer::TaggedPointer( !dstIsDense || dstIndex == dstNumAllocated, "xrpl::TaggedPointer::TaggedPointer(TaggedPointer&& ...) : " "non-sparse or valid sparse"); - for (int i = dstIndex; i < dstNumAllocated; ++i) + for (auto i = dstIndex; i < dstNumAllocated; ++i) { new (&dstHashes[i]) SHAMapHash{}; new (&dstChildren[i]) SHAMapTreeNodePtr{}; @@ -448,9 +453,9 @@ inline TaggedPointer::TaggedPointer( new (&newChildren[branchNum]) SHAMapTreeNodePtr{std::move(oldChildren[indexNum])}; }); // Run the constructors for the remaining elements - for (int i = 0; i < SHAMapInnerNode::kBranchFactor; ++i) + for (auto i = 0u; i < SHAMapInnerNode::kBranchFactor; ++i) { - if (((1 << i) & isBranch) != 0) + if (((1u << i) & isBranch) != 0u) continue; new (&newHashes[i]) SHAMapHash{}; new (&newChildren[i]) SHAMapTreeNodePtr{}; @@ -459,7 +464,7 @@ inline TaggedPointer::TaggedPointer( else { // new arrays are sparse, old arrays may be sparse or dense - int curCompressedIndex = 0; + auto curCompressedIndex = 0u; iterNonEmptyChildIndexes(isBranch, [&](auto branchNum, auto indexNum) { new (&newHashes[curCompressedIndex]) SHAMapHash{oldHashes[indexNum]}; new (&newChildren[curCompressedIndex]) @@ -467,7 +472,7 @@ inline TaggedPointer::TaggedPointer( ++curCompressedIndex; }); // Run the constructors for the remaining elements - for (int i = curCompressedIndex; i < newNumAllocated; ++i) + for (auto i = curCompressedIndex; i < newNumAllocated; ++i) { new (&newHashes[i]) SHAMapHash{}; new (&newChildren[i]) SHAMapTreeNodePtr{}; diff --git a/src/libxrpl/shamap/SHAMap.cpp b/src/libxrpl/shamap/SHAMap.cpp index 2483e6f6e1..3fa8d66be0 100644 --- a/src/libxrpl/shamap/SHAMap.cpp +++ b/src/libxrpl/shamap/SHAMap.cpp @@ -116,8 +116,7 @@ SHAMap::dirtyUp(SharedPtrNodeStack& stack, uint256 const& target, SHAMapTreeNode stack.pop(); XRPL_ASSERT(node, "xrpl::SHAMap::dirtyUp : non-null node"); - int const branch = selectBranch(nodeID, target); - XRPL_ASSERT(branch >= 0, "xrpl::SHAMap::dirtyUp : valid branch"); + auto const branch = selectBranch(nodeID, target); node = unshareNode(std::move(node), nodeID); node->setChild(branch, std::move(child)); @@ -278,7 +277,7 @@ SHAMap::fetchNode(SHAMapHash const& hash) const } SHAMapTreeNode* -SHAMap::descendThrow(SHAMapInnerNode* parent, int branch) const +SHAMap::descendThrow(SHAMapInnerNode* parent, unsigned int branch) const { SHAMapTreeNode* ret = descend(parent, branch); // NOLINT(misc-const-correctness) @@ -289,7 +288,7 @@ SHAMap::descendThrow(SHAMapInnerNode* parent, int branch) const } SHAMapTreeNodePtr -SHAMap::descendThrow(SHAMapInnerNode& parent, int branch) const +SHAMap::descendThrow(SHAMapInnerNode& parent, unsigned int branch) const { SHAMapTreeNodePtr ret = descend(parent, branch); @@ -300,7 +299,7 @@ SHAMap::descendThrow(SHAMapInnerNode& parent, int branch) const } SHAMapTreeNode* -SHAMap::descend(SHAMapInnerNode* parent, int branch) const +SHAMap::descend(SHAMapInnerNode* parent, unsigned int branch) const { SHAMapTreeNode* ret = parent->getChildPointer(branch); // NOLINT(misc-const-correctness) if ((ret != nullptr) || !backed_) @@ -315,7 +314,7 @@ SHAMap::descend(SHAMapInnerNode* parent, int branch) const } SHAMapTreeNodePtr -SHAMap::descend(SHAMapInnerNode& parent, int branch) const +SHAMap::descend(SHAMapInnerNode& parent, unsigned int branch) const { SHAMapTreeNodePtr node = parent.getChild(branch); if (node || !backed_) @@ -332,7 +331,7 @@ SHAMap::descend(SHAMapInnerNode& parent, int branch) const // Gets the node that would be hooked to this branch, // but doesn't hook it up. SHAMapTreeNodePtr -SHAMap::descendNoStore(SHAMapInnerNode& parent, int branch) const +SHAMap::descendNoStore(SHAMapInnerNode& parent, unsigned int branch) const { SHAMapTreeNodePtr ret = parent.getChild(branch); if (!ret && backed_) @@ -344,12 +343,11 @@ std::pair SHAMap::descend( SHAMapInnerNode* parent, SHAMapNodeID const& parentID, - int branch, + unsigned int branch, SHAMapSyncFilter const* filter) const { XRPL_ASSERT(parent->isInner(), "xrpl::SHAMap::descend : valid parent input"); - XRPL_ASSERT( - (branch >= 0) && (branch < kBranchFactor), "xrpl::SHAMap::descend : valid branch input"); + XRPL_ASSERT(branch < kBranchFactor, "xrpl::SHAMap::descend : valid branch input"); XRPL_ASSERT( !parent->isEmptyBranch(branch), "xrpl::SHAMap::descend : parent branch is non-empty"); @@ -373,7 +371,7 @@ SHAMap::descend( SHAMapTreeNode* SHAMap::descendAsync( SHAMapInnerNode* parent, - int branch, + unsigned int branch, SHAMapSyncFilter const* filter, bool& pending, descendCallback&& callback) const @@ -433,10 +431,9 @@ SHAMapLeafNode* SHAMap::belowHelper( SHAMapTreeNodePtr node, SharedPtrNodeStack& stack, - int branch, - std::tuple, std::function> const& loopParams) const + unsigned int branch, + BelowDirection direction) const { - auto& [init, cmp, incr] = loopParams; if (node->isLeaf()) { auto n = intr_ptr::staticPointerCast(node); @@ -452,11 +449,16 @@ SHAMap::belowHelper( { stack.emplace(inner, stack.top().second.getChildNodeID(branch)); } - for (int i = init; cmp(i);) + // `scanned` counts how many branches of `inner` we have examined; the branch we look at is + // derived from it, so no index ever goes out of range. + for (auto scanned = 0u; scanned < kBranchFactor;) { - if (!inner->isEmptyBranch(i)) + auto const childBranch = + (direction == BelowDirection::Last) ? (kBranchFactor - 1u - scanned) : scanned; + + if (!inner->isEmptyBranch(childBranch)) { - node.adopt(descendThrow(inner.get(), i)); + node.adopt(descendThrow(inner.get(), childBranch)); XRPL_ASSERT(!stack.empty(), "xrpl::SHAMap::belowHelper : non-empty stack"); if (node->isLeaf()) { @@ -466,32 +468,24 @@ SHAMap::belowHelper( } inner = intr_ptr::staticPointerCast(node); stack.emplace(inner, stack.top().second.getChildNodeID(branch)); - i = init; // descend and reset loop + scanned = 0u; // descend and restart the scan on the new node } else { - incr(i); // scan next branch + ++scanned; // scan next branch } } return nullptr; } SHAMapLeafNode* -SHAMap::lastBelow(SHAMapTreeNodePtr node, SharedPtrNodeStack& stack, int branch) const +SHAMap::lastBelow(SHAMapTreeNodePtr node, SharedPtrNodeStack& stack, unsigned int branch) const { - auto init = kBranchFactor - 1; - auto cmp = [](int i) { return i >= 0; }; - auto incr = [](int& i) { --i; }; - - return belowHelper(node, stack, branch, {init, cmp, incr}); + return belowHelper(node, stack, branch, BelowDirection::Last); } SHAMapLeafNode* -SHAMap::firstBelow(SHAMapTreeNodePtr node, SharedPtrNodeStack& stack, int branch) const +SHAMap::firstBelow(SHAMapTreeNodePtr node, SharedPtrNodeStack& stack, unsigned int branch) const { - auto init = 0; - auto cmp = [](int i) { return i <= kBranchFactor; }; - auto incr = [](int& i) { ++i; }; - - return belowHelper(node, stack, branch, {init, cmp, incr}); + return belowHelper(node, stack, branch, BelowDirection::First); } static boost::intrusive_ptr const kNoItem; @@ -504,7 +498,7 @@ SHAMap::onlyBelow(SHAMapTreeNode* node) const { SHAMapTreeNode* nextNode = nullptr; auto inner = safeDowncast(node); - for (int i = 0; i < kBranchFactor; ++i) + for (auto i = 0u; i < kBranchFactor; ++i) { if (!inner->isEmptyBranch(i)) { @@ -650,8 +644,9 @@ SHAMap::lowerBound(uint256 const& id) const else { auto inner = intr_ptr::staticPointerCast(node); - for (int branch = selectBranch(nodeID, id) - 1; branch >= 0; --branch) + for (auto branch = selectBranch(nodeID, id); branch > 0u;) { + --branch; if (!inner->isEmptyBranch(branch)) { node = descendThrow(*inner, branch); @@ -715,7 +710,7 @@ SHAMap::delItem(uint256 const& id) { // we may have made this a node with 1 or 0 children // And, if so, we need to remove this branch - int const bc = node->getBranchCount(); + auto const bc = node->getBranchCount(); if (bc == 0) { // no children below this branch @@ -730,7 +725,7 @@ SHAMap::delItem(uint256 const& id) if (item) { - for (int i = 0; i < kBranchFactor; ++i) + for (auto i = 0u; i < kBranchFactor; ++i) { if (!node->isEmptyBranch(i)) { @@ -786,7 +781,7 @@ SHAMap::addGiveItem(SHAMapNodeType type, boost::intrusive_ptr { // easy case, we end on an inner node auto inner = intr_ptr::staticPointerCast(node); - int const branch = selectBranch(nodeID, tag); + auto const branch = selectBranch(nodeID, tag); XRPL_ASSERT( inner->isEmptyBranch(branch), "xrpl::SHAMap::addGiveItem : inner branch is empty"); inner->setChild(branch, makeTypedLeaf(type, std::move(item), cowid_)); @@ -802,7 +797,7 @@ SHAMap::addGiveItem(SHAMapNodeType type, boost::intrusive_ptr node = intr_ptr::makeShared(node->cowid()); - unsigned int b1 = 0, b2 = 0; + auto b1 = 0u, b2 = 0u; while ((b1 = selectBranch(nodeID, tag)) == (b2 = selectBranch(nodeID, otherItem->key()))) { @@ -1012,12 +1007,12 @@ SHAMap::walkSubTree(bool doWrite, NodeObjectType t) // Stack of {parent,index,child} pointers representing // inner nodes we are in the process of flushing - using StackEntry = std::pair, int>; + using StackEntry = std::pair, unsigned int>; std::stack> stack; node = preFlushNode(std::move(node)); - int pos = 0; + auto pos = 0u; // We can't flush an inner node until we flush its children while (true) @@ -1032,7 +1027,7 @@ SHAMap::walkSubTree(bool doWrite, NodeObjectType t) { // No need to do I/O. If the node isn't linked, // it can't need to be flushed - int const branch = pos; + auto const branch = pos; auto child = node->getChild(pos++); if (child && (child->cowid() != 0)) @@ -1126,7 +1121,7 @@ SHAMap::dump(bool hash) const if (node->isInner()) { auto inner = safeDowncast(node); - for (int i = 0; i < kBranchFactor; ++i) + for (auto i = 0u; i < kBranchFactor; ++i) { if (!inner->isEmptyBranch(i)) { diff --git a/src/libxrpl/shamap/SHAMapDelta.cpp b/src/libxrpl/shamap/SHAMapDelta.cpp index 8336ce5481..1306fe6990 100644 --- a/src/libxrpl/shamap/SHAMapDelta.cpp +++ b/src/libxrpl/shamap/SHAMapDelta.cpp @@ -54,7 +54,7 @@ SHAMap::walkBranch( { // This is an inner node, add all non-empty branches auto inner = safeDowncast(node); - for (int i = 0; i < 16; ++i) + for (auto i = 0u; i < SHAMapInnerNode::kBranchFactor; ++i) { if (!inner->isEmptyBranch(i)) nodeStack.push({descendThrow(inner, i)}); @@ -205,7 +205,7 @@ SHAMap::compare(SHAMap const& otherMap, Delta& differences, int maxCount) const { auto ours = safeDowncast(ourNode); auto other = safeDowncast(otherNode); - for (int i = 0; i < 16; ++i) + for (auto i = 0u; i < SHAMapInnerNode::kBranchFactor; ++i) { if (ours->getChildHash(i) != other->getChildHash(i)) { @@ -257,7 +257,7 @@ SHAMap::walkMap(std::vector& missingNodes, int maxMissing) co intr_ptr::SharedPtr const node = std::move(nodeStack.top()); nodeStack.pop(); - for (int i = 0; i < 16; ++i) + for (auto i = 0u; i < SHAMapInnerNode::kBranchFactor; ++i) { if (!node->isEmptyBranch(i)) { @@ -286,27 +286,29 @@ SHAMap::walkMapParallel(std::vector& missingNodes, int maxMis return false; using StackEntry = intr_ptr::SharedPtr; - std::array topChildren; + std::array topChildren; { auto const& innerRoot = intr_ptr::staticPointerCast(root_); - for (int i = 0; i < 16; ++i) + for (auto i = 0u; i < SHAMapInnerNode::kBranchFactor; ++i) { if (!innerRoot->isEmptyBranch(i)) topChildren[i] = descendNoStore(*innerRoot, i); } } std::vector workers; - workers.reserve(16); + workers.reserve(SHAMapInnerNode::kBranchFactor); std::vector exceptions; - exceptions.reserve(16); + exceptions.reserve(SHAMapInnerNode::kBranchFactor); - std::array>, 16> nodeStacks; + std::array>, SHAMapInnerNode::kBranchFactor> + nodeStacks; // This mutex is used inside the worker threads to protect `missingNodes` // and `maxMissing` from race conditions std::mutex m; - for (int rootChildIndex = 0; rootChildIndex < 16; ++rootChildIndex) + for (auto rootChildIndex = 0u; rootChildIndex < SHAMapInnerNode::kBranchFactor; + ++rootChildIndex) { auto const& child = topChildren[rootChildIndex]; if (!child || !child->isInner()) @@ -327,7 +329,7 @@ SHAMap::walkMapParallel(std::vector& missingNodes, int maxMis XRPL_ASSERT(node, "xrpl::SHAMap::walkMapParallel : non-null node"); nodeStack.pop(); - for (int i = 0; i < 16; ++i) + for (auto i = 0u; i < SHAMapInnerNode::kBranchFactor; ++i) { if (node->isEmptyBranch(i)) continue; diff --git a/src/libxrpl/shamap/SHAMapInnerNode.cpp b/src/libxrpl/shamap/SHAMapInnerNode.cpp index 74a0e4515f..bdd89388b2 100644 --- a/src/libxrpl/shamap/SHAMapInnerNode.cpp +++ b/src/libxrpl/shamap/SHAMapInnerNode.cpp @@ -63,8 +63,8 @@ SHAMapInnerNode::resizeChildArrays(std::uint8_t toAllocate) hashesAndChildren_ = TaggedPointer(std::move(hashesAndChildren_), isBranch_, toAllocate); } -std::optional -SHAMapInnerNode::getChildIndex(int i) const +std::optional +SHAMapInnerNode::getChildIndex(unsigned int i) const { return hashesAndChildren_.getChildIndex(isBranch_, i); } @@ -89,7 +89,7 @@ SHAMapInnerNode::clone(std::uint32_t cowid) const if (thisIsSparse) { - int cloneChildIndex = 0; + auto cloneChildIndex = 0u; iterNonEmptyChildIndexes([&](auto branchNum, auto indexNum) { cloneHashes[cloneChildIndex++] = thisHashes[indexNum]; }); @@ -105,7 +105,7 @@ SHAMapInnerNode::clone(std::uint32_t cowid) const if (thisIsSparse) { - int cloneChildIndex = 0; + auto cloneChildIndex = 0u; iterNonEmptyChildIndexes([&](auto branchNum, auto indexNum) { cloneChildren[cloneChildIndex++] = thisChildren[indexNum]; }); @@ -133,12 +133,12 @@ SHAMapInnerNode::makeFullInner(Slice data, SHAMapHash const& hash, bool hashVali auto hashes = ret->hashesAndChildren_.getHashes(); - for (int i = 0; i < kBranchFactor; ++i) + for (auto i = 0u; i < kBranchFactor; ++i) { hashes[i].asUInt256() = si.getBitString<256>(); if (hashes[i].isNonZero()) - ret->isBranch_ |= (1 << i); + ret->isBranch_ |= (1u << i); } ret->resizeChildArrays(ret->getBranchCount()); @@ -182,7 +182,7 @@ SHAMapInnerNode::makeCompressedInner(Slice data) hashes[pos].asUInt256() = hash; if (hashes[pos].isNonZero()) - ret->isBranch_ |= (1 << pos); + ret->isBranch_ |= (1u << pos); } ret->resizeChildArrays(ret->getBranchCount()); @@ -267,20 +267,19 @@ SHAMapInnerNode::getString(SHAMapNodeID const& id) const // We are modifying an inner node void -SHAMapInnerNode::setChild(int m, SHAMapTreeNodePtr child) +SHAMapInnerNode::setChild(unsigned int branch, SHAMapTreeNodePtr child) { - XRPL_ASSERT( - (m >= 0) && (m < kBranchFactor), "xrpl::SHAMapInnerNode::setChild : valid branch input"); + XRPL_ASSERT(branch < kBranchFactor, "xrpl::SHAMapInnerNode::setChild : valid branch input"); XRPL_ASSERT(cowid_, "xrpl::SHAMapInnerNode::setChild : nonzero cowid"); XRPL_ASSERT(child.get() != this, "xrpl::SHAMapInnerNode::setChild : valid child input"); auto const dstIsBranch = [&] { if (child) { - return isBranch_ | (1u << m); + return isBranch_ | (1u << branch); } - return isBranch_ & ~(1u << m); + return isBranch_ & ~(1u << branch); }(); auto const dstToAllocate = popcnt16(dstIsBranch); @@ -293,8 +292,8 @@ SHAMapInnerNode::setChild(int m, SHAMapTreeNodePtr child) if (child) { - auto const childIndex = - *getChildIndex(m); // NOLINT(bugprone-unchecked-optional-access) isBranch_ set above + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) isBranch_ set above + auto const childIndex = *getChildIndex(branch); auto [_, hashes, children] = hashesAndChildren_.getHashesAndChildren(); hashes[childIndex].zero(); children[childIndex] = std::move(child); @@ -309,25 +308,24 @@ SHAMapInnerNode::setChild(int m, SHAMapTreeNodePtr child) // finished modifying, now make shareable void -SHAMapInnerNode::shareChild(int m, SHAMapTreeNodePtr const& child) +SHAMapInnerNode::shareChild(unsigned int branch, SHAMapTreeNodePtr const& child) { - XRPL_ASSERT( - (m >= 0) && (m < kBranchFactor), "xrpl::SHAMapInnerNode::shareChild : valid branch input"); + XRPL_ASSERT(branch < kBranchFactor, "xrpl::SHAMapInnerNode::shareChild : valid branch input"); XRPL_ASSERT(cowid_, "xrpl::SHAMapInnerNode::shareChild : nonzero cowid"); XRPL_ASSERT(child, "xrpl::SHAMapInnerNode::shareChild : non-null child input"); XRPL_ASSERT(child.get() != this, "xrpl::SHAMapInnerNode::shareChild : valid child input"); - XRPL_ASSERT(!isEmptyBranch(m), "xrpl::SHAMapInnerNode::shareChild : non-empty branch input"); + XRPL_ASSERT( + !isEmptyBranch(branch), "xrpl::SHAMapInnerNode::shareChild : non-empty branch input"); // NOLINTNEXTLINE(bugprone-unchecked-optional-access) assert above - hashesAndChildren_.getChildren()[*getChildIndex(m)] = child; + hashesAndChildren_.getChildren()[*getChildIndex(branch)] = child; } SHAMapTreeNode* -SHAMapInnerNode::getChildPointer(int branch) +SHAMapInnerNode::getChildPointer(unsigned int branch) { XRPL_ASSERT( - branch >= 0 && branch < kBranchFactor, - "xrpl::SHAMapInnerNode::getChildPointer : valid branch input"); + branch < kBranchFactor, "xrpl::SHAMapInnerNode::getChildPointer : valid branch input"); XRPL_ASSERT( !isEmptyBranch(branch), "xrpl::SHAMapInnerNode::getChildPointer : non-empty branch input"); @@ -340,11 +338,9 @@ SHAMapInnerNode::getChildPointer(int branch) } SHAMapTreeNodePtr -SHAMapInnerNode::getChild(int branch) +SHAMapInnerNode::getChild(unsigned int branch) { - XRPL_ASSERT( - branch >= 0 && branch < kBranchFactor, - "xrpl::SHAMapInnerNode::getChild : valid branch input"); + XRPL_ASSERT(branch < kBranchFactor, "xrpl::SHAMapInnerNode::getChild : valid branch input"); XRPL_ASSERT(!isEmptyBranch(branch), "xrpl::SHAMapInnerNode::getChild : non-empty branch input"); auto const index = @@ -356,23 +352,20 @@ SHAMapInnerNode::getChild(int branch) } SHAMapHash const& -SHAMapInnerNode::getChildHash(int m) const +SHAMapInnerNode::getChildHash(unsigned int branch) const { - XRPL_ASSERT( - (m >= 0) && (m < kBranchFactor), - "xrpl::SHAMapInnerNode::getChildHash : valid branch input"); - if (auto const i = getChildIndex(m)) + XRPL_ASSERT(branch < kBranchFactor, "xrpl::SHAMapInnerNode::getChildHash : valid branch input"); + if (auto const i = getChildIndex(branch)) return hashesAndChildren_.getHashes()[*i]; return kZeroShaMapHash; } SHAMapTreeNodePtr -SHAMapInnerNode::canonicalizeChild(int branch, SHAMapTreeNodePtr node) +SHAMapInnerNode::canonicalizeChild(unsigned int branch, SHAMapTreeNodePtr node) { XRPL_ASSERT( - branch >= 0 && branch < kBranchFactor, - "xrpl::SHAMapInnerNode::canonicalizeChild : valid branch input"); + branch < kBranchFactor, "xrpl::SHAMapInnerNode::canonicalizeChild : valid branch input"); XRPL_ASSERT(node != nullptr, "xrpl::SHAMapInnerNode::canonicalizeChild : valid node input"); XRPL_ASSERT( !isEmptyBranch(branch), @@ -410,7 +403,7 @@ SHAMapInnerNode::invariants(bool isRoot) const if (numAllocated != kBranchFactor) { auto const branchCount = getBranchCount(); - for (int i = 0; i < branchCount; ++i) + for (auto i = 0u; i < branchCount; ++i) { XRPL_ASSERT( hashes[i].isNonZero(), @@ -422,12 +415,12 @@ SHAMapInnerNode::invariants(bool isRoot) const } else { - for (int i = 0; i < kBranchFactor; ++i) + for (auto i = 0u; i < kBranchFactor; ++i) { if (hashes[i].isNonZero()) { XRPL_ASSERT( - (isBranch_ & (1 << i)), + (isBranch_ & (1u << i)), "xrpl::SHAMapInnerNode::invariants : valid branch when " "nonzero hash"); if (children[i] != nullptr) @@ -437,7 +430,7 @@ SHAMapInnerNode::invariants(bool isRoot) const else { XRPL_ASSERT( - (isBranch_ & (1 << i)) == 0, + (isBranch_ & (1u << i)) == 0u, "xrpl::SHAMapInnerNode::invariants : valid branch when " "zero hash"); } diff --git a/src/libxrpl/shamap/SHAMapNodeID.cpp b/src/libxrpl/shamap/SHAMapNodeID.cpp index a511fc038c..ecde22a63d 100644 --- a/src/libxrpl/shamap/SHAMapNodeID.cpp +++ b/src/libxrpl/shamap/SHAMapNodeID.cpp @@ -16,7 +16,7 @@ namespace xrpl { static uint256 const& depthMask(unsigned int depth) { - static constexpr auto kMaskSize = 65; + static constexpr auto kMaskSize = SHAMap::kLeafDepth + 1; struct MasksT { @@ -25,7 +25,7 @@ depthMask(unsigned int depth) MasksT() { uint256 selector; - for (int i = 0; i < kMaskSize - 1; i += 2) + for (auto i = 0u; i < kMaskSize - 1; i += 2) { entry[i] = selector; *(selector.begin() + (i / 2)) = 0xF0; @@ -60,10 +60,10 @@ SHAMapNodeID::getRawString() const } SHAMapNodeID -SHAMapNodeID::getChildNodeID(unsigned int m) const +SHAMapNodeID::getChildNodeID(unsigned int branch) const { XRPL_ASSERT( - m < SHAMap::kBranchFactor, "xrpl::SHAMapNodeID::getChildNodeID : valid branch input"); + branch < SHAMap::kBranchFactor, "xrpl::SHAMapNodeID::getChildNodeID : valid branch input"); // A SHAMap has exactly 65 levels, so nodes must not exceed that // depth; if they do, this breaks the invariant of never allowing @@ -83,7 +83,7 @@ SHAMapNodeID::getChildNodeID(unsigned int m) const Throw("Incorrect mask for " + to_string(*this)); SHAMapNodeID node{depth_ + 1, id_}; - node.id_.begin()[depth_ / 2] |= ((depth_ & 1) != 0u) ? m : (m << 4); + node.id_.begin()[depth_ / 2] |= ((depth_ & 1) != 0u) ? branch : (branch << 4); return node; } @@ -127,10 +127,9 @@ selectBranch(SHAMapNodeID const& id, uint256 const& hash) } SHAMapNodeID -SHAMapNodeID::createID(int depth, uint256 const& key) +SHAMapNodeID::createID(unsigned int depth, uint256 const& key) { - XRPL_ASSERT( - depth >= 0 && depth <= SHAMap::kLeafDepth, "xrpl::SHAMapNodeID::createID : valid depth"); + XRPL_ASSERT(depth <= SHAMap::kLeafDepth, "xrpl::SHAMapNodeID::createID : valid depth"); return SHAMapNodeID(depth, key & depthMask(depth)); } diff --git a/src/libxrpl/shamap/SHAMapSync.cpp b/src/libxrpl/shamap/SHAMapSync.cpp index cbed6885c9..e6948ec3ac 100644 --- a/src/libxrpl/shamap/SHAMapSync.cpp +++ b/src/libxrpl/shamap/SHAMapSync.cpp @@ -54,15 +54,15 @@ SHAMap::visitNodes(std::function const& function) const if (!root_->isInner()) return; - using StackEntry = std::pair>; + using StackEntry = std::pair>; std::stack> stack; auto node = intr_ptr::staticPointerCast(root_); - int pos = 0; + auto pos = 0u; while (true) { - while (pos < 16) + while (pos < kBranchFactor) { if (!node->isEmptyBranch(pos)) { @@ -77,10 +77,10 @@ SHAMap::visitNodes(std::function const& function) const else { // If there are no more children, don't push this node - while ((pos != 15) && (node->isEmptyBranch(pos + 1))) + while ((pos != kBranchFactor - 1u) && (node->isEmptyBranch(pos + 1))) ++pos; - if (pos != 15) + if (pos != kBranchFactor - 1u) { // save next position to resume at stack.emplace(pos + 1, std::move(node)); @@ -144,7 +144,7 @@ SHAMap::visitDifferences( return; // 2) push non-matching child inner nodes - for (int i = 0; i < 16; ++i) + for (auto i = 0u; i < kBranchFactor; ++i) { if (!node->isEmptyBranch(i)) { @@ -176,13 +176,13 @@ SHAMap::gmnProcessNodes(MissingNodes& mn, MissingNodes::StackEntry& se) { SHAMapInnerNode*& node = std::get<0>(se); SHAMapNodeID& nodeID = std::get<1>(se); - int& firstChild = std::get<2>(se); - int& currentChild = std::get<3>(se); + auto& firstChild = std::get<2>(se); + auto& currentChild = std::get<3>(se); bool& fullBelow = std::get<4>(se); - while (currentChild < 16) + while (currentChild < kBranchFactor) { - int const branch = (firstChild + currentChild++) % 16; + auto const branch = (firstChild + currentChild++) % kBranchFactor; if (node->isEmptyBranch(branch)) continue; @@ -262,7 +262,7 @@ SHAMap::gmnProcessDeferredReads(MissingNodes& mn) int complete = 0; while (complete != mn.deferred) { - std::tuple deferredNode; + MissingNodes::DeferredNode deferredNode; { std::unique_lock lock{mn.deferLock}; @@ -423,7 +423,7 @@ SHAMap::getNodeFat( while ((node != nullptr) && node->isInner() && (nodeID.getDepth() < wanted.getDepth())) { - int const branch = selectBranch(nodeID, wanted.getNodeID()); + auto const branch = selectBranch(nodeID, wanted.getNodeID()); auto inner = safeDowncast(node); if (inner->isEmptyBranch(branch)) return false; @@ -444,7 +444,7 @@ SHAMap::getNodeFat( return false; } - std::stack> stack; + std::stack> stack; stack.emplace(node, nodeID, depth); Serializer s(8192); @@ -464,12 +464,12 @@ SHAMap::getNodeFat( // We descend inner nodes with only a single child // without decrementing the depth auto inner = safeDowncast(node); - int const bc = inner->getBranchCount(); + auto const bc = inner->getBranchCount(); if ((depth > 0) || (bc == 1)) { // We need to process this node's children - for (int i = 0; i < 16; ++i) + for (auto i = 0u; i < kBranchFactor; ++i) { if (!inner->isEmptyBranch(i)) { @@ -575,8 +575,7 @@ SHAMap::addKnownNode( !safeDowncast(currNode)->isFullBelow(generation) && (currNodeID.getDepth() < nodeID.getDepth())) { - int const branch = selectBranch(currNodeID, nodeID.getNodeID()); - XRPL_ASSERT(branch >= 0, "xrpl::SHAMap::addKnownNode : valid branch"); + auto const branch = selectBranch(currNodeID, nodeID.getNodeID()); auto inner = safeDowncast(currNode); if (inner->isEmptyBranch(branch)) { @@ -686,7 +685,7 @@ SHAMap::deepCompare(SHAMap& other) const return false; auto nodeInner = safeDowncast(node); auto otherInner = safeDowncast(otherNode); - for (int i = 0; i < 16; ++i) + for (auto i = 0u; i < kBranchFactor; ++i) { if (nodeInner->isEmptyBranch(i)) { @@ -725,7 +724,7 @@ SHAMap::hasInnerNode(SHAMapNodeID const& targetNodeID, SHAMapHash const& targetN while (node->isInner() && (nodeID.getDepth() < targetNodeID.getDepth())) { - int const branch = selectBranch(nodeID, targetNodeID.getNodeID()); + auto const branch = selectBranch(nodeID, targetNodeID.getNodeID()); auto inner = safeDowncast(node); if (inner->isEmptyBranch(branch)) return false; @@ -751,7 +750,20 @@ SHAMap::hasLeafNode(uint256 const& tag, SHAMapHash const& targetNodeHash) const do { - int const branch = selectBranch(nodeID, tag); + // An inner node is only reachable here at a depth below kLeafDepth in a well-formed map, + // where the loop always finds a leaf first. A malformed map could still have an inner + // node claiming kLeafDepth, and getChildNodeID below throws in that case: reject rather + // than let the throw escape uncaught. Not reachable through any public entry point, + // since addKnownNode already marks such a map invalid, so no test can cover this. + if (nodeID.getDepth() >= kLeafDepth) + { + // LCOV_EXCL_START + UNREACHABLE("xrpl::SHAMap::hasLeafNode : inner node at leaf depth"); + return false; + // LCOV_EXCL_STOP + } + + auto const branch = selectBranch(nodeID, tag); auto inner = safeDowncast(node); if (inner->isEmptyBranch(branch)) return false; // Dead end, node must not be here @@ -803,7 +815,7 @@ SHAMap::getProofPath(uint256 const& key) const bool SHAMap::verifyProofPath(uint256 const& rootHash, uint256 const& key, std::vector const& path) { - if (path.empty() || path.size() > 65) + if (path.empty() || path.size() > kLeafDepth + 1u) return false; SHAMapHash hash{rootHash}; @@ -819,10 +831,10 @@ SHAMap::verifyProofPath(uint256 const& rootHash, uint256 const& key, std::vector if (node->getHash() != hash) return false; - auto depth = std::distance(path.rbegin(), rit); + auto const depth = std::distance(path.rbegin(), rit); if (node->isInner()) { - auto nodeId = SHAMapNodeID::createID(depth, key); + auto nodeId = SHAMapNodeID::createID(static_cast(depth), key); hash = safeDowncast(node.get()) ->getChildHash(selectBranch(nodeId, key)); } diff --git a/src/xrpld/app/ledger/detail/LedgerNodeHelpers.cpp b/src/xrpld/app/ledger/detail/LedgerNodeHelpers.cpp index 531dba59f9..abd669d446 100644 --- a/src/xrpld/app/ledger/detail/LedgerNodeHelpers.cpp +++ b/src/xrpld/app/ledger/detail/LedgerNodeHelpers.cpp @@ -75,7 +75,7 @@ getSHAMapNodeID(protocol::TMLedgerNode const& ledgerNode, SHAMapTreeNode const& if (treeNode.isLeaf()) { auto const key = leafKey(treeNode); - auto const expectedID = SHAMapNodeID::createID(static_cast(nodeID->getDepth()), key); + auto const expectedID = SHAMapNodeID::createID(nodeID->getDepth(), key); SOMETIMES( nodeID->getNodeID() != expectedID.getNodeID(), "xrpl::getSHAMapNodeID : legacy leaf ID inconsistent with key"); From c49789086ad3b031cd527fa7ed2e687e81fdfd4a Mon Sep 17 00:00:00 2001 From: Gregory Tsipenyuk Date: Mon, 17 Aug 2026 12:52:20 +0000 Subject: [PATCH 083/102] fix: Extend locked-MPToken unauthorize check to fixCleanup3_4_0 (#8004) --- .../tx/transactors/token/MPTokenAuthorize.cpp | 27 ++++----- src/test/app/MPToken_test.cpp | 58 ++++++++++++++++++- 2 files changed, 69 insertions(+), 16 deletions(-) diff --git a/src/libxrpl/tx/transactors/token/MPTokenAuthorize.cpp b/src/libxrpl/tx/transactors/token/MPTokenAuthorize.cpp index 0aeb6f33d1..c19b8f64d7 100644 --- a/src/libxrpl/tx/transactors/token/MPTokenAuthorize.cpp +++ b/src/libxrpl/tx/transactors/token/MPTokenAuthorize.cpp @@ -37,6 +37,7 @@ MPTokenAuthorize::preclaim(PreclaimContext const& ctx) { auto const accountID = ctx.tx[sfAccount]; auto const holderID = ctx.tx[~sfHolder]; + auto const sleMptIssuance = ctx.view.read(keylet::mptokenIssuance(ctx.tx[sfMPTokenIssuanceID])); // if non-issuer account submits this tx, then they are trying either: // 1. Unauthorize/delete MPToken @@ -51,9 +52,8 @@ MPTokenAuthorize::preclaim(PreclaimContext const& ctx) // There is an edge case where all holders have zero balance, issuance // is legally destroyed, then outstanding MPT(s) are deleted afterwards. - // Thus, there is no need to check for the existence of the issuance if - // the MPT is being deleted with a zero balance. Check for unauthorize - // before fetching the MPTIssuance object. + // Thus, the unauthorize/delete path below does not require the issuance + // to exist when the MPT is being deleted with a zero balance. // if holder wants to delete/unauthorize a mpt if (ctx.tx.isFlag(tfMPTUnauthorize)) @@ -63,8 +63,6 @@ MPTokenAuthorize::preclaim(PreclaimContext const& ctx) if ((*sleMpt)[sfMPTAmount] != 0) { - auto const sleMptIssuance = - ctx.view.read(keylet::mptokenIssuance(ctx.tx[sfMPTokenIssuanceID])); if (!sleMptIssuance) return tefINTERNAL; // LCOV_EXCL_LINE @@ -73,21 +71,24 @@ MPTokenAuthorize::preclaim(PreclaimContext const& ctx) if ((*sleMpt)[~sfLockedAmount].value_or(0) != 0) { - auto const sleMptIssuance = - ctx.view.read(keylet::mptokenIssuance(ctx.tx[sfMPTokenIssuanceID])); if (!sleMptIssuance) return tefINTERNAL; // LCOV_EXCL_LINE return tecHAS_OBLIGATIONS; } - if (ctx.view.rules().enabled(featureSingleAssetVault) && sleMpt->isFlag(lsfMPTLocked)) + if (ctx.view.rules().enabled(fixCleanup3_4_0)) + { + if (sleMptIssuance && sleMpt->isFlag(lsfMPTLocked)) + return tecNO_PERMISSION; + } + else if ( + ctx.view.rules().enabled(featureSingleAssetVault) && sleMpt->isFlag(lsfMPTLocked)) + { return tecNO_PERMISSION; + } if (ctx.view.rules().enabled(featureConfidentialTransfer)) { - auto const sleMptIssuance = - ctx.view.read(keylet::mptokenIssuance(ctx.tx[sfMPTokenIssuanceID])); - // if there still existing encrypted balances of MPT in // circulation if (sleMptIssuance && @@ -106,9 +107,6 @@ MPTokenAuthorize::preclaim(PreclaimContext const& ctx) } // Now test when the holder wants to hold/create/authorize a new MPT - auto const sleMptIssuance = - ctx.view.read(keylet::mptokenIssuance(ctx.tx[sfMPTokenIssuanceID])); - if (!sleMptIssuance) return tecOBJECT_NOT_FOUND; @@ -126,7 +124,6 @@ MPTokenAuthorize::preclaim(PreclaimContext const& ctx) if (!sleHolder) return tecNO_DST; - auto const sleMptIssuance = ctx.view.read(keylet::mptokenIssuance(ctx.tx[sfMPTokenIssuanceID])); if (!sleMptIssuance) return tecOBJECT_NOT_FOUND; diff --git a/src/test/app/MPToken_test.cpp b/src/test/app/MPToken_test.cpp index b392dca758..7086adf743 100644 --- a/src/test/app/MPToken_test.cpp +++ b/src/test/app/MPToken_test.cpp @@ -789,7 +789,7 @@ class MPToken_test : public beast::unit_test::Suite // locks up bob's mptoken again mptAlice.set({.account = alice, .holder = bob, .flags = tfMPTLock}); - if (!features[featureSingleAssetVault]) + if (!features[featureSingleAssetVault] && !features[fixCleanup3_4_0]) { // Delete bob's mptoken even though it is locked mptAlice.authorize({.account = bob, .flags = tfMPTUnauthorize}); @@ -7657,6 +7657,56 @@ class MPToken_test : public beast::unit_test::Suite 0, tecNO_PERMISSION, tecNO_PERMISSION, tecNO_PERMISSION, tecNO_PERMISSION); } + void + testLockedMPTokenDestroyedIssuance(FeatureBitset features) + { + testcase("Locked MPToken with destroyed issuance"); + + using namespace test::jtx; + Account const alice("alice"); // issuer + Account const bob("bob"); // holder + + Env env{*this, features}; + env.fund(XRP(1'000), alice, bob); + env.close(); + MPTTester mptAlice( + {.env = env, .issuer = alice, .holders = {bob}, .flags = kMptDexFlags | tfMPTCanLock}); + + // alice locks bob's mptoken individually + mptAlice.set({.account = alice, .holder = bob, .flags = tfMPTLock}); + + // alice destroys her issuance. This succeeds: MPTokenIssuanceDestroy + // only requires that the issuance has no outstanding balance; it does + // not require that all holder MPTokens have been deleted first. + mptAlice.destroy({.ownerCount = 0}); + + if (!features[featureSingleAssetVault] || features[fixCleanup3_4_0]) + { + // pre SAV or post Cleanup340 amendment: bob deletes the dangling locked MPToken + mptAlice.authorize({.account = bob, .holderCount = 0, .flags = tfMPTUnauthorize}); + BEAST_EXPECT(ownerCount(env, bob) == 0); + } + else + { + // bob cannot delete his locked MPToken, even though the issuance + // no longer exists. + mptAlice.authorize( + {.account = bob, .flags = tfMPTUnauthorize, .err = tecNO_PERMISSION}); + + // and the lock can never be cleared, because unlocking + // requires the (destroyed) issuance + mptAlice.set( + {.account = alice, + .holder = bob, + .flags = tfMPTUnlock, + .err = tecOBJECT_NOT_FOUND}); + + // the dangling locked MPToken survives + BEAST_EXPECT(env.current()->exists(keylet::mptoken(mptAlice.issuanceID(), bob.id()))); + BEAST_EXPECT(ownerCount(env, bob) == 1); + } + } + public: void run() override @@ -7703,7 +7753,9 @@ public: testSetValidation(all - featurePermissionedDomains); testSetValidation(all); + testSetEnabled(all - featureSingleAssetVault - fixCleanup3_4_0); testSetEnabled(all - featureSingleAssetVault); + testSetEnabled(all - fixCleanup3_4_0); testSetEnabled(all); // MPT clawback @@ -7770,6 +7822,10 @@ public: // Fixes testFixDoubleOwnerCount(all); + testLockedMPTokenDestroyedIssuance(all); + testLockedMPTokenDestroyedIssuance(all - fixCleanup3_4_0); + testLockedMPTokenDestroyedIssuance(all - featureSingleAssetVault); + testLockedMPTokenDestroyedIssuance(all - featureSingleAssetVault - fixCleanup3_4_0); } }; From ca6121c5b34520f304796fdc1a57c2bac5806a83 Mon Sep 17 00:00:00 2001 From: Gregory Tsipenyuk Date: Mon, 17 Aug 2026 20:58:46 +0000 Subject: [PATCH 084/102] feat: Enforce MPT CanTransfer on AMM LPTokens transfers (#7418) --- include/xrpl/ledger/View.h | 20 +++ src/libxrpl/ledger/View.cpp | 27 ++++ src/libxrpl/ledger/helpers/TokenHelpers.cpp | 9 ++ src/libxrpl/tx/paths/DirectStep.cpp | 11 +- src/test/app/LPTokenTransfer_test.cpp | 135 ++++++++++++++++++++ 5 files changed, 200 insertions(+), 2 deletions(-) diff --git a/include/xrpl/ledger/View.h b/include/xrpl/ledger/View.h index e8b4a932d0..0893612bac 100644 --- a/include/xrpl/ledger/View.h +++ b/include/xrpl/ledger/View.h @@ -85,6 +85,26 @@ isLPTokenFrozen( Asset const& asset, Asset const& asset2); +/** + * Check whether an AMM LPToken may be transferred between @p from and @p to. + * + * @p lpTokenIssuer is the issuer of the LPToken being moved. If it is not an + * AMM account the token is not an LPToken and the transfer is unconditionally + * permitted. Otherwise, for each MPT pool asset of that AMM, canTransfer() must + * permit the transfer (which exempts the MPT issuer). Non-MPT pool assets are + * always transferable by this check, so it is implicitly gated by + * featureMPTokensV2 (MPTs can only be AMM pool assets once V2 is enabled). + * + * @return tesSUCCESS if permitted, otherwise the canTransfer() failure code + * (e.g. tecNO_AUTH) of the first MPT pool asset that disallows it. + */ +[[nodiscard]] TER +canTransferLPToken( + ReadView const& view, + AccountID const& from, + AccountID const& to, + AccountID const& lpTokenIssuer); + // Return the list of enabled amendments [[nodiscard]] std::set getEnabledAmendments(ReadView const& view); diff --git a/src/libxrpl/ledger/View.cpp b/src/libxrpl/ledger/View.cpp index 2dd70e2950..0544771973 100644 --- a/src/libxrpl/ledger/View.cpp +++ b/src/libxrpl/ledger/View.cpp @@ -138,6 +138,33 @@ isLPTokenFrozen( return isFrozen(view, account, asset) || isFrozen(view, account, asset2); } +TER +canTransferLPToken( + ReadView const& view, + AccountID const& from, + AccountID const& to, + AccountID const& lpTokenIssuer) +{ + // Only AMM-issued LPTokens are subject to this check. The LPToken's issuer + // is the AMM account; if it is not an AMM, this is not an LPToken. + auto const sleIssuer = view.read(keylet::account(lpTokenIssuer)); + if (!sleIssuer || !sleIssuer->isFieldPresent(sfAMMID)) + return tesSUCCESS; + + auto const sleAmm = view.read(keylet::amm((*sleIssuer)[sfAMMID])); + if (!sleAmm) + return tecINTERNAL; // LCOV_EXCL_LINE + + auto const transferable = [&](Asset const& a) -> TER { + if (!a.holds()) + return tesSUCCESS; + return canTransfer(view, a.get(), from, to); + }; + if (auto const err = transferable((*sleAmm)[sfAsset]); !isTesSuccess(err)) + return err; + return transferable((*sleAmm)[sfAsset2]); +} + bool areCompatible( ReadView const& validLedger, diff --git a/src/libxrpl/ledger/helpers/TokenHelpers.cpp b/src/libxrpl/ledger/helpers/TokenHelpers.cpp index 79e10cdf79..9e3452ccae 100644 --- a/src/libxrpl/ledger/helpers/TokenHelpers.cpp +++ b/src/libxrpl/ledger/helpers/TokenHelpers.cpp @@ -309,6 +309,15 @@ getLineIfUsable( } } } + + // An LPToken whose AMM pool contains an MPT that forbids transfers is not + // spendable. Issuer is the LPToken's AMM account; canTransferLPToken is + // a no-op for non-AMM issuers and non-MPT pool assets, so this is implicitly + // gated by featureMPTokensV2. + if (!isTesSuccess(canTransferLPToken(view, account, account, issuer))) + { + return nullptr; + } } return sle; diff --git a/src/libxrpl/tx/paths/DirectStep.cpp b/src/libxrpl/tx/paths/DirectStep.cpp index f8f12bd421..1854bd3632 100644 --- a/src/libxrpl/tx/paths/DirectStep.cpp +++ b/src/libxrpl/tx/paths/DirectStep.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -845,8 +846,14 @@ DirectStepI::check(StrandContext const& ctx) const // pure issue/redeem can't be frozen if (!(ctx.isLast && ctx.isFirst)) { - auto const ter = checkFreeze(ctx.view, src_, dst_, currency_); - if (!isTesSuccess(ter)) + if (auto const ter = checkFreeze(ctx.view, src_, dst_, currency_); !isTesSuccess(ter)) + return ter; + + // An LPToken redeemed against its AMM (dst_ is the LPToken issuer on + // this hop) cannot move if a pool asset is an MPT that forbids + // transfers between these accounts. A no-op unless dst_ is an AMM whose + // pool holds such an MPT (so it is implicitly gated by featureMPTokensV2). + if (auto const ter = canTransferLPToken(ctx.view, src_, dst_, dst_); !isTesSuccess(ter)) return ter; } diff --git a/src/test/app/LPTokenTransfer_test.cpp b/src/test/app/LPTokenTransfer_test.cpp index e30e37ed98..3e72094eb3 100644 --- a/src/test/app/LPTokenTransfer_test.cpp +++ b/src/test/app/LPTokenTransfer_test.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include // IWYU pragma: keep #include @@ -21,6 +22,8 @@ #include #include +#include + namespace xrpl::test { class LPTokenTransfer_test : public jtx::AMMTest @@ -433,6 +436,136 @@ class LPTokenTransfer_test : public jtx::AMMTest } } + void + testMPTCanTransferDirectStep(FeatureBitset features) + { + testcase("MPT CanTransfer DirectStep"); + + using namespace jtx; + + // An MPT can only be an AMM pool asset once featureMPTokensV2 is + // enabled, so this behavior is only meaningful when V2 is present, and + // is independent of fixFrozenLPTokenTransfer. + if (!features[featureMPTokensV2]) + return; + + // gw issues an MPT used as one of the AMM pool assets. gw (the MPT + // issuer) seeds the pool and hands LP tokens to alice. Transferring LP + // tokens between two non-issuer holders is only permitted when the + // pool MPT allows transfers (lsfMPTCanTransfer); issuer-involving + // transfers are always permitted. The check fires on the redeem step + // against the AMM account via canTransferLPToken(). + auto testLPTokenTransfer = [&](std::uint32_t mptFlags, bool poolXrpToBtc) { + Env env{*this, features}; + env.fund(XRP(30'000), gw_, alice_, bob_); + env.close(); + + // gw is the MPT issuer, so it may seed the pool regardless of + // whether the MPT permits third-party transfers. + MPT const btc = MPTTester( + {.env = env, .issuer = gw_, .holders = {alice_}, .pay = 1'000, .flags = mptFlags}); + + auto const asset1 = poolXrpToBtc ? XRP(10'000) : btc(10'000); + auto const asset2 = poolXrpToBtc ? btc(10'000) : XRP(10'000); + AMM const amm(env, gw_, asset1, asset2); + auto const lpIssue = amm.lptIssue(); + + env.trust(STAmount{lpIssue, 100'000}, alice_); + env.trust(STAmount{lpIssue, 100'000}, bob_); + env.close(); + + // Issuer-involving LP token transfer is always allowed (gw is the + // pool MPT's issuer), even when the MPT lacks CanTransfer. + env(pay(gw_, alice_, STAmount{lpIssue, 1'000})); + env.close(); + + // Transfer between two non-issuer holders is allowed only if the + // pool MPT has CanTransfer set; otherwise the redeem step against + // the AMM account blocks it with tecNO_AUTH. + if ((mptFlags & tfMPTCanTransfer) != 0u) + { + env(pay(alice_, bob_, STAmount{lpIssue, 100})); + } + else + { + env(pay(alice_, bob_, STAmount{lpIssue, 100}), Ter(tecNO_AUTH)); + } + env.close(); + }; + + // Pool MPT without CanTransfer blocks third-party LP token transfers. + testLPTokenTransfer(tfMPTCanTrade, true); + testLPTokenTransfer(tfMPTCanTrade, false); + + // Pool MPT with CanTransfer allows them. + testLPTokenTransfer(tfMPTCanTrade | tfMPTCanTransfer, true); + testLPTokenTransfer(tfMPTCanTrade | tfMPTCanTransfer, false); + } + + void + testMPTCanTransferOffer(FeatureBitset features) + { + testcase("MPT CanTransfer Offer"); + + using namespace jtx; + + if (!features[featureMPTokensV2]) + return; + + // Parity with frozen LP tokens for the order book: a non-transferable + // pool MPT makes the LP token un-spendable (canTransferLPToken zeroes + // the spendable balance in accountHolds, just as isLPTokenFrozen does), + // so an offer to sell it cannot be funded - the same tecUNFUNDED_OFFER + // outcome as freezing a pool asset (see testOfferCreation). + auto testLPTokenTransfer = [&](std::uint32_t mptFlags, bool poolXrpToBtc) { + Env env{*this, features}; + env.fund(XRP(30'000), gw_, carol_); + env.close(); + + MPT const btc = MPTTester( + {.env = env, .issuer = gw_, .holders = {carol_}, .pay = 1'000, .flags = mptFlags}); + + auto const asset1 = poolXrpToBtc ? XRP(10'000) : btc(10'000); + auto const asset2 = poolXrpToBtc ? btc(10'000) : XRP(10'000); + AMM const amm(env, gw_, asset1, asset2); + auto const lpIssue = amm.lptIssue(); + + env.trust(STAmount{lpIssue, 100'000}, carol_); + env.close(); + + // gw (the pool MPT issuer) seeds carol_ with LP tokens; issuer + // involving transfers are always allowed. + env(pay(gw_, carol_, STAmount{lpIssue, 1'000})); + env.close(); + + // carol_ tries to create an offer to sell the LP token. + if ((mptFlags & tfMPTCanTransfer) != 0u) + { + env(offer(carol_, XRP(10), STAmount{lpIssue, 10}), Txflags(tfPassive)); + env.close(); + BEAST_EXPECT(expectOffers(env, carol_, 1)); + } + else + { + // Non-transferable pool MPT => LP token un-spendable => the + // sell offer is unfunded, just as if a pool asset were frozen. + env(offer(carol_, XRP(10), STAmount{lpIssue, 10}), + Txflags(tfPassive), + Ter(tecUNFUNDED_OFFER)); + env.close(); + BEAST_EXPECT(expectOffers(env, carol_, 0)); + } + }; + + // Pool MPT without CanTransfer: LP token sell offer is unfunded. + testLPTokenTransfer(tfMPTCanTrade, true); + testLPTokenTransfer(tfMPTCanTrade, false); + + // Pool MPT with CanTransfer: LP token sell offer is created. + testLPTokenTransfer(tfMPTCanTrade | tfMPTCanTransfer, true); + testLPTokenTransfer(tfMPTCanTrade | tfMPTCanTransfer, false); + } + public: void run() override @@ -447,6 +580,8 @@ public: testOfferCrossing(features); testCheck(features); testNFTOffers(features); + testMPTCanTransferDirectStep(features); + testMPTCanTransferOffer(features); } } }; From 1b226c8b2eb3d08b7018738adb1cddc6f6768372 Mon Sep 17 00:00:00 2001 From: Gregory Tsipenyuk Date: Mon, 17 Aug 2026 21:15:16 +0000 Subject: [PATCH 085/102] perf: Optimize MPT freeze checks to reduce redundant state reads (#7411) Co-authored-by: Chenna Keshava B S <21219765+ckeshava@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- include/xrpl/ledger/View.h | 7 ++ include/xrpl/ledger/helpers/MPTokenHelpers.h | 35 ++++++++++ src/libxrpl/ledger/View.cpp | 69 +++++++++++++++---- src/libxrpl/ledger/helpers/AMMHelpers.cpp | 3 +- src/libxrpl/ledger/helpers/MPTokenHelpers.cpp | 64 +++++++++++++++-- src/libxrpl/ledger/helpers/TokenHelpers.cpp | 2 +- src/libxrpl/tx/invariants/MPTInvariant.cpp | 2 +- .../tx/transactors/escrow/EscrowCreate.cpp | 4 +- .../tx/transactors/escrow/EscrowFinish.cpp | 2 +- src/test/app/AMMMPT_test.cpp | 54 +++++++++++++++ 10 files changed, 216 insertions(+), 26 deletions(-) diff --git a/include/xrpl/ledger/View.h b/include/xrpl/ledger/View.h index 0893612bac..f7fd5b5a8c 100644 --- a/include/xrpl/ledger/View.h +++ b/include/xrpl/ledger/View.h @@ -78,6 +78,13 @@ isVaultPseudoAccountFrozen( MPTIssue const& mptShare, std::uint8_t depth); +[[nodiscard]] bool +isVaultPseudoAccountFrozen( + ReadView const& view, + AccountID const& account, + SLE const& issuanceSle, + std::uint8_t depth); + [[nodiscard]] bool isLPTokenFrozen( ReadView const& view, diff --git a/include/xrpl/ledger/helpers/MPTokenHelpers.h b/include/xrpl/ledger/helpers/MPTokenHelpers.h index 7babefd196..6d26cf3cbc 100644 --- a/include/xrpl/ledger/helpers/MPTokenHelpers.h +++ b/include/xrpl/ledger/helpers/MPTokenHelpers.h @@ -29,6 +29,9 @@ namespace xrpl { [[nodiscard]] bool isGlobalFrozen(ReadView const& view, MPTIssue const& mptIssue); +[[nodiscard]] bool +isGlobalFrozen(SLE const& issuanceSle); + /** * Returns true if @p account's MPToken for @p mptIssue carries the * individual-lock flag (lsfMPTLocked). @@ -40,9 +43,29 @@ isGlobalFrozen(ReadView const& view, MPTIssue const& mptIssue); * receive tokens — it combines isIndividualFrozen, isGlobalFrozen, and * isVaultPseudoAccountFrozen into a single complete check. */ + [[nodiscard]] bool isIndividualFrozen(ReadView const& view, AccountID const& account, MPTIssue const& mptIssue); +[[nodiscard]] bool +isIndividualFrozen(SLE const& mptSle); + +/** + * Returns true if @p account cannot send or receive tokens of @p mptIssue + * because a freeze applies. This is the complete check callers should use + * before moving MPT value: it combines @ref isGlobalFrozen (issuance-level + * lock), @ref isIndividualFrozen (per-holder lock bit), and the transitive + * vault pseudo-account check (if @p mptIssue is a vault share, the underlying + * asset is checked, and so on recursively up to @c maxAssetCheckDepth). + * + * The @c SLE overload takes an already-loaded ltMPTOKEN or ltMPTOKEN_ISSUANCE + * ledger entry; for ltMPTOKEN it can skip the per-holder individual-lock lookup. + * @ref isAnyFrozen answers the same question for a set of accounts and returns true + * if the freeze applies to any of them. + * + * @param depth Current recursion depth for the vault-share walk. Callers + * outside this module should leave it at the default. + */ [[nodiscard]] bool isFrozen( ReadView const& view, @@ -50,6 +73,18 @@ isFrozen( MPTIssue const& mptIssue, std::uint8_t depth = 0); +/** + * SLE overload: pass an already-loaded ltMPTOKEN (holder row) or + * ltMPTOKEN_ISSUANCE to reuse it for the freeze checks and avoid re-reading + * the same object. For an ltMPTOKEN, @p sle is used directly for the + * individual-lock check and the issuance is read once for global-freeze and + * vault-pseudo-account. For an ltMPTOKEN_ISSUANCE, @p sle is used directly + * for global-freeze and vault-pseudo-account, and the caller's holder row is + * read for the individual-lock check. + */ +[[nodiscard]] bool +isFrozen(ReadView const& view, AccountID const& account, SLE const& sle, std::uint8_t depth = 0); + [[nodiscard]] bool isAnyFrozen( ReadView const& view, diff --git a/src/libxrpl/ledger/View.cpp b/src/libxrpl/ledger/View.cpp index 0544771973..e01ae2e492 100644 --- a/src/libxrpl/ledger/View.cpp +++ b/src/libxrpl/ledger/View.cpp @@ -61,12 +61,10 @@ hasExpired( : view.parentCloseTime() > boundary; } -bool -isVaultPseudoAccountFrozen( - ReadView const& view, - AccountID const& account, - MPTIssue const& mptShare, - std::uint8_t depth) +namespace { + +std::optional +checkVaultPseudoAccountFrozenPreconditions(ReadView const& view, std::uint8_t depth) { if (!view.rules().enabled(featureSingleAssetVault)) return false; @@ -74,26 +72,37 @@ isVaultPseudoAccountFrozen( if (depth >= kMaxAssetCheckDepth) { // LCOV_EXCL_START - UNREACHABLE("xrpl::View::isVaultPseudoAccountFrozen : reached asset check depth"); + UNREACHABLE( + "xrpl::View::checkVaultPseudoAccountFrozenPreconditions : reached asset check depth"); return true; // LCOV_EXCL_STOP } - auto const mptIssuance = view.read(keylet::mptokenIssuance(mptShare.getMptID())); - if (mptIssuance == nullptr) - return false; // zero MPToken won't block deletion of MPTokenIssuance + return std::nullopt; +} - auto const issuer = mptIssuance->getAccountID(sfIssuer); +bool +isVaultPseudoAccountFrozenForIssuance( + ReadView const& view, + AccountID const& account, + SLE const& issuanceSle, + std::uint8_t depth) +{ + XRPL_ASSERT( + issuanceSle.getType() == ltMPTOKEN_ISSUANCE, + "xrpl::isVaultPseudoAccountFrozenForIssuance : MPTokenIssuance SLE"); + + auto const issuer = issuanceSle.getAccountID(sfIssuer); // Post-fixCleanup3_2_0: vault shares carry sfReferenceHolding pointing // to the vault pseudo's MPToken or RippleState for the underlying. // Read it to derive the underlying asset and recurse, skipping the // issuer-account-then-vault chain. Pre-amendment shares (no field) // fall back to the chain lookup below. - if (mptIssuance->isFieldPresent(sfReferenceHolding)) + if (issuanceSle.isFieldPresent(sfReferenceHolding)) { auto const sleHolding = - view.read(keylet::unchecked(mptIssuance->getFieldH256(sfReferenceHolding))); + view.read(keylet::unchecked(issuanceSle.getFieldH256(sfReferenceHolding))); if (!sleHolding) { // LCOV_EXCL_START @@ -102,7 +111,7 @@ isVaultPseudoAccountFrozen( // LCOV_EXCL_STOP } return isAnyFrozen( - view, {issuer, account}, assetOfHolding(*mptIssuance, *sleHolding), depth + 1); + view, {issuer, account}, assetOfHolding(issuanceSle, *sleHolding), depth + 1); } auto const mptIssuer = view.read(keylet::account(issuer)); @@ -128,6 +137,38 @@ isVaultPseudoAccountFrozen( return isAnyFrozen(view, {issuer, account}, vault->at(sfAsset), depth + 1); } +} // namespace + +bool +isVaultPseudoAccountFrozen( + ReadView const& view, + AccountID const& account, + SLE const& issuanceSle, + std::uint8_t depth) +{ + if (auto const result = checkVaultPseudoAccountFrozenPreconditions(view, depth)) + return *result; + + return isVaultPseudoAccountFrozenForIssuance(view, account, issuanceSle, depth); +} + +bool +isVaultPseudoAccountFrozen( + ReadView const& view, + AccountID const& account, + MPTIssue const& mptShare, + std::uint8_t depth) +{ + if (auto const result = checkVaultPseudoAccountFrozenPreconditions(view, depth)) + return *result; + + auto const issuanceSle = view.read(keylet::mptokenIssuance(mptShare.getMptID())); + if (issuanceSle == nullptr) + return false; // zero MPToken won't block deletion of MPTokenIssuance + + return isVaultPseudoAccountFrozenForIssuance(view, account, *issuanceSle, depth); +} + bool isLPTokenFrozen( ReadView const& view, diff --git a/src/libxrpl/ledger/helpers/AMMHelpers.cpp b/src/libxrpl/ledger/helpers/AMMHelpers.cpp index df6d335085..fcad22d2d5 100644 --- a/src/libxrpl/ledger/helpers/AMMHelpers.cpp +++ b/src/libxrpl/ledger/helpers/AMMHelpers.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -633,7 +634,7 @@ ammAccountHolds(ReadView const& view, AccountID const& ammAccountID, Asset const return asset.visit( [&](MPTIssue const& issue) { if (auto const sle = view.read(keylet::mptoken(issue, ammAccountID)); - sle && !isFrozen(view, ammAccountID, issue)) + sle && !isFrozen(view, ammAccountID, *sle)) return STAmount{issue, (*sle)[sfMPTAmount]}; return STAmount{asset}; }, diff --git a/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp b/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp index b239d0d3d1..73d5fdb1d5 100644 --- a/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp +++ b/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp @@ -42,18 +42,35 @@ bool isGlobalFrozen(ReadView const& view, MPTIssue const& mptIssue) { if (auto const sle = view.read(keylet::mptokenIssuance(mptIssue.getMptID()))) - return sle->isFlag(lsfMPTLocked); + return isGlobalFrozen(*sle); return false; } +bool +isGlobalFrozen(SLE const& issuanceSle) +{ + XRPL_ASSERT( + issuanceSle.getType() == ltMPTOKEN_ISSUANCE, "xrpl::isGlobalFrozen : MPTokenIssuance SLE"); + + return issuanceSle.isFlag(lsfMPTLocked); +} + bool isIndividualFrozen(ReadView const& view, AccountID const& account, MPTIssue const& mptIssue) { if (auto const sle = view.read(keylet::mptoken(mptIssue.getMptID(), account))) - return sle->isFlag(lsfMPTLocked); + return isIndividualFrozen(*sle); return false; } +bool +isIndividualFrozen(SLE const& mptSle) +{ + XRPL_ASSERT(mptSle.getType() == ltMPTOKEN, "xrpl::isIndividualFrozen : MPToken SLE"); + + return mptSle.isFlag(lsfMPTLocked); +} + bool isFrozen( ReadView const& view, @@ -65,6 +82,34 @@ isFrozen( isVaultPseudoAccountFrozen(view, account, mptIssue, depth); } +bool +isFrozen(ReadView const& view, AccountID const& account, SLE const& sle, std::uint8_t depth) +{ + XRPL_ASSERT( + sle.getType() == ltMPTOKEN || sle.getType() == ltMPTOKEN_ISSUANCE, + "xrpl::isFrozen : MPToken or MPTokenIssuance SLE"); + + if (sle.getType() == ltMPTOKEN) + { + XRPL_ASSERT(sle[sfAccount] == account, "xrpl::isFrozen : valid MPToken holder"); + + MPTID const mptID = sle[sfMPTokenIssuanceID]; + auto const issuanceSle = view.read(keylet::mptokenIssuance(mptID)); + + if ((issuanceSle && isGlobalFrozen(*issuanceSle)) || isIndividualFrozen(sle)) + return true; + + if (issuanceSle) + return isVaultPseudoAccountFrozen(view, account, *issuanceSle, depth); + + return isVaultPseudoAccountFrozen(view, account, MPTIssue{mptID}, depth); + } + + MPTIssue const mptIssue{sle[sfSequence], sle[sfIssuer]}; + return isGlobalFrozen(sle) || isIndividualFrozen(view, account, mptIssue) || + isVaultPseudoAccountFrozen(view, account, sle, depth); +} + [[nodiscard]] bool isAnyFrozen( ReadView const& view, @@ -72,7 +117,8 @@ isAnyFrozen( MPTIssue const& mptIssue, std::uint8_t depth) { - if (isGlobalFrozen(view, mptIssue)) + auto const issuanceSle = view.read(keylet::mptokenIssuance(mptIssue.getMptID())); + if (issuanceSle && isGlobalFrozen(*issuanceSle)) return true; for (auto const& account : accounts) @@ -81,9 +127,15 @@ isAnyFrozen( return true; } - return std::ranges::any_of(accounts, [&](auto const& account) { - return isVaultPseudoAccountFrozen(view, account, mptIssue, depth); - }); + // Pass the issuance SLE when we have it to avoid re-reading it per account; + // otherwise defer to the MPTIssue overload, which handles a missing issuance. + auto const anyVaultFrozen = [&](auto const& shareOrIssuance) { + return std::ranges::any_of(accounts, [&](auto const& account) { + return isVaultPseudoAccountFrozen(view, account, shareOrIssuance, depth); + }); + }; + + return issuanceSle ? anyVaultFrozen(*issuanceSle) : anyVaultFrozen(mptIssue); } Rate diff --git a/src/libxrpl/ledger/helpers/TokenHelpers.cpp b/src/libxrpl/ledger/helpers/TokenHelpers.cpp index 9e3452ccae..7ebfa64bcf 100644 --- a/src/libxrpl/ledger/helpers/TokenHelpers.cpp +++ b/src/libxrpl/ledger/helpers/TokenHelpers.cpp @@ -439,7 +439,7 @@ accountHolds( auto const sleMpt = view.read(keylet::mptoken(mptIssue.getMptID(), account)); if (!sleMpt || - (zeroIfFrozen == FreezeHandling::ZeroIfFrozen && isFrozen(view, account, mptIssue))) + (zeroIfFrozen == FreezeHandling::ZeroIfFrozen && isFrozen(view, account, *sleMpt))) { amount.clear(mptIssue); } diff --git a/src/libxrpl/tx/invariants/MPTInvariant.cpp b/src/libxrpl/tx/invariants/MPTInvariant.cpp index 12ec078c82..d323718bd2 100644 --- a/src/libxrpl/tx/invariants/MPTInvariant.cpp +++ b/src/libxrpl/tx/invariants/MPTInvariant.cpp @@ -900,7 +900,7 @@ ValidMPTTransfer::finalize( // Check once: if any involved account is frozen, the whole issuance transfer is // considered frozen. Only need to check for frozen if there is a transfer of funds. if (!invalidTransfer && - (isFrozen(view, account, MPTIssue{mptID}) || + (isFrozen(view, account, *sleIssuance) || !isAuthorized(view, mptID, account, reqAuth))) { invalidTransfer = true; diff --git a/src/libxrpl/tx/transactors/escrow/EscrowCreate.cpp b/src/libxrpl/tx/transactors/escrow/EscrowCreate.cpp index 212f9da075..0fe27fb3ba 100644 --- a/src/libxrpl/tx/transactors/escrow/EscrowCreate.cpp +++ b/src/libxrpl/tx/transactors/escrow/EscrowCreate.cpp @@ -304,11 +304,11 @@ escrowCreatePreclaimHelper( return ter; // If the issuer has frozen the account, return tecLOCKED - if (isFrozen(ctx.view, account, mptIssue)) + if (isFrozen(ctx.view, account, *sleIssuance)) return tecLOCKED; // If the issuer has frozen the destination, return tecLOCKED - if (isFrozen(ctx.view, dest, mptIssue)) + if (isFrozen(ctx.view, dest, *sleIssuance)) return tecLOCKED; // If the mpt cannot be transferred, return tecNO_AUTH diff --git a/src/libxrpl/tx/transactors/escrow/EscrowFinish.cpp b/src/libxrpl/tx/transactors/escrow/EscrowFinish.cpp index 32f4d9ec48..aa352d5e98 100644 --- a/src/libxrpl/tx/transactors/escrow/EscrowFinish.cpp +++ b/src/libxrpl/tx/transactors/escrow/EscrowFinish.cpp @@ -186,7 +186,7 @@ escrowFinishPreclaimHelper( return ter; // If the issuer has frozen the destination, return tecLOCKED - if (isFrozen(ctx.view, dest, mptIssue)) + if (isFrozen(ctx.view, dest, *sleIssuance)) return tecLOCKED; return tesSUCCESS; diff --git a/src/test/app/AMMMPT_test.cpp b/src/test/app/AMMMPT_test.cpp index 90a267f56f..bfd2d529b5 100644 --- a/src/test/app/AMMMPT_test.cpp +++ b/src/test/app/AMMMPT_test.cpp @@ -27,6 +27,8 @@ #include #include #include +#include +#include #include #include #include @@ -7421,6 +7423,57 @@ private: } } + void + testDanglingAMMMPTokenFreezeCheck() + { + testcase("Dangling AMM MPToken freeze check"); + + using namespace jtx; + FeatureBitset const all{testableAmendments()}; + + Env env(*this, all); + + env.fund(XRP(1'000), gw_, alice_); + MPTTester usd({.env = env, .issuer = gw_}); + MPTTester const btc({.env = env, .issuer = gw_}); + + AMM amm(env, gw_, usd(10'000), btc(10'000)); + for (auto i = 0; i < kMaxDeletableAmmTrustLines + 10; ++i) + { + Account const a{std::to_string(i)}; + env.fund(XRP(1'000), a); + env(trust(a, STAmount{amm.lptIssue(), 10'000})); + env.close(); + } + + // With too many LP-token trust lines to delete in one pass, the AMM + // remains in an empty state with zero-balance MPToken objects. + amm.withdrawAll(gw_); + BEAST_EXPECT(amm.ammExists()); + BEAST_EXPECT(amm.expectBalances(usd(0), btc(0), IOUAmount{0})); + + auto const ammToken = env.le(keylet::mptoken(usd.issuanceID(), amm.ammAccount())); + if (!BEAST_EXPECT(ammToken)) + return; + BEAST_EXPECT((*ammToken)[sfMPTAmount] == 0); + + usd.destroy(); + BEAST_EXPECT(env.le(keylet::mptokenIssuance(usd.issuanceID())) == nullptr); + BEAST_EXPECT(!isFrozen(*env.current(), amm.ammAccount(), *ammToken)); + // A Payment cannot cross this empty AMM because BookStep skips AMMs + // with zero LPTokenBalance. Probe the same ZeroIfFrozen balance read + // used by AMM accounting. + auto const balance = accountHolds( + *env.current(), + amm.ammAccount(), + MPTIssue{usd.issuanceID()}, + FreezeHandling::ZeroIfFrozen, + AuthHandling::IgnoreAuth, + env.journal); + + BEAST_EXPECT(balance == usd(0)); + } + void run() override { @@ -7461,6 +7514,7 @@ private: testDepositIntegralOverflowMPT(all); testDepositIntegralOverflowMPT(all - fixCleanup3_4_0); testWithdrawIntegralNoOverflowMPT(); + testDanglingAMMMPTokenFreezeCheck(); } }; From 820ca5b33201c67d290d5c16fa2419121ee76de0 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 17 Aug 2026 23:19:56 +0000 Subject: [PATCH 086/102] refactor: Convert boost::beast::string_view to std::string_view (#6306) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mvadari <8029314+mvadari@users.noreply.github.com> Co-authored-by: Mayukha Vadari Co-authored-by: Ayaz Salikhov Co-authored-by: xrplf-ai-reviewer[bot] <266832837+xrplf-ai-reviewer[bot]@users.noreply.github.com> Co-authored-by: Mayukha Vadari Co-authored-by: Timur Yalymov <36795566+tyalymov@users.noreply.github.com> Co-authored-by: Cursor Co-authored-by: Vito Tumas <5780819+Tapanito@users.noreply.github.com> --- include/xrpl/beast/rfc2616.h | 3 ++- include/xrpl/config/BasicConfig.h | 1 - include/xrpl/json/Output.h | 7 +++---- include/xrpl/server/detail/BaseWSPeer.h | 16 ++++++++-------- src/libxrpl/json/Writer.cpp | 5 +++-- src/xrpld/overlay/detail/ProtocolVersion.cpp | 5 +++-- src/xrpld/overlay/detail/ProtocolVersion.h | 7 +++---- src/xrpld/rpc/detail/ServerHandler.cpp | 6 +++--- 8 files changed, 25 insertions(+), 25 deletions(-) diff --git a/include/xrpl/beast/rfc2616.h b/include/xrpl/beast/rfc2616.h index 0e061845fb..87d63b0260 100644 --- a/include/xrpl/beast/rfc2616.h +++ b/include/xrpl/beast/rfc2616.h @@ -11,6 +11,7 @@ #include #include #include +#include #include namespace beast::rfc2616 { @@ -186,7 +187,7 @@ splitCommas(FwdIt first, FwdIt last) template > Result -splitCommas(boost::beast::string_view const& s) +splitCommas(std::string_view s) { return splitCommas(s.begin(), s.end()); } diff --git a/include/xrpl/config/BasicConfig.h b/include/xrpl/config/BasicConfig.h index 607a0c3e5f..2278a0fa68 100644 --- a/include/xrpl/config/BasicConfig.h +++ b/include/xrpl/config/BasicConfig.h @@ -2,7 +2,6 @@ #include -#include #include #include diff --git a/include/xrpl/json/Output.h b/include/xrpl/json/Output.h index 53d453c277..f73bd38c77 100644 --- a/include/xrpl/json/Output.h +++ b/include/xrpl/json/Output.h @@ -1,20 +1,19 @@ #pragma once -#include - #include #include +#include namespace json { class Value; -using Output = std::function; +using Output = std::function; inline Output stringOutput(std::string& s) { - return [&](boost::beast::string_view const& b) { s.append(b.data(), b.size()); }; + return [&](std::string_view b) { s.append(b.data(), b.size()); }; } /** diff --git a/include/xrpl/server/detail/BaseWSPeer.h b/include/xrpl/server/detail/BaseWSPeer.h index b1670865bd..403d7f92ee 100644 --- a/include/xrpl/server/detail/BaseWSPeer.h +++ b/include/xrpl/server/detail/BaseWSPeer.h @@ -25,6 +25,7 @@ #include #include #include +#include #include #include @@ -62,8 +63,7 @@ private: bool pingActive_ = false; boost::beast::websocket::ping_data payload_; error_code ec_; - std::function - controlCallback_; + std::function controlCallback_; public: template @@ -151,7 +151,7 @@ protected: onPing(error_code const& ec); void - onPingPong(boost::beast::websocket::frame_type kind, boost::beast::string_view payload); + onPingPong(boost::beast::websocket::frame_type kind, std::string_view payload); void onTimer(error_code ec); @@ -189,9 +189,9 @@ BaseWSPeer::run() impl().ws_.set_option(port().pmdOptions); // Must manage the control callback memory outside of the `control_callback` // function - controlCallback_ = [this]( - boost::beast::websocket::frame_type kind, - boost::beast::string_view payload) { onPingPong(kind, payload); }; + controlCallback_ = [this](boost::beast::websocket::frame_type kind, std::string_view payload) { + onPingPong(kind, payload); + }; impl().ws_.control_callback(controlCallback_); startTimer(); closeOnTimer_ = true; @@ -430,11 +430,11 @@ template void BaseWSPeer::onPingPong( boost::beast::websocket::frame_type kind, - boost::beast::string_view payload) + std::string_view payload) { if (kind == boost::beast::websocket::frame_type::pong) { - boost::beast::string_view const p(payload_.begin()); + std::string_view const p(payload_.begin(), payload_.size()); if (payload == p) { closeOnTimer_ = false; diff --git a/src/libxrpl/json/Writer.cpp b/src/libxrpl/json/Writer.cpp index 4c922a0e33..c5ce4666ef 100644 --- a/src/libxrpl/json/Writer.cpp +++ b/src/libxrpl/json/Writer.cpp @@ -9,6 +9,7 @@ #include // IWYU pragma: keep #include #include +#include #include #include @@ -87,14 +88,14 @@ public: } void - output(boost::beast::string_view const& bytes) + output(std::string_view bytes) { markStarted(); output_(bytes); } void - stringOutput(boost::beast::string_view const& bytes) + stringOutput(std::string_view bytes) { markStarted(); std::size_t position = 0, writtenUntil = 0; diff --git a/src/xrpld/overlay/detail/ProtocolVersion.cpp b/src/xrpld/overlay/detail/ProtocolVersion.cpp index 1296041ad5..74dad61828 100644 --- a/src/xrpld/overlay/detail/ProtocolVersion.cpp +++ b/src/xrpld/overlay/detail/ProtocolVersion.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include namespace xrpl { @@ -52,7 +53,7 @@ to_string(ProtocolVersion const& p) } std::vector -parseProtocolVersions(boost::beast::string_view const& value) +parseProtocolVersions(std::string_view value) { static boost::regex const kRE( "^" // start of line @@ -119,7 +120,7 @@ negotiateProtocolVersion(std::vector const& versions) } std::optional -negotiateProtocolVersion(boost::beast::string_view const& versions) +negotiateProtocolVersion(std::string_view versions) { auto const them = parseProtocolVersions(versions); diff --git a/src/xrpld/overlay/detail/ProtocolVersion.h b/src/xrpld/overlay/detail/ProtocolVersion.h index b56871318a..5c05f63e2a 100644 --- a/src/xrpld/overlay/detail/ProtocolVersion.h +++ b/src/xrpld/overlay/detail/ProtocolVersion.h @@ -1,10 +1,9 @@ #pragma once -#include - #include #include #include +#include #include #include @@ -43,7 +42,7 @@ to_string(ProtocolVersion const& p); * no duplicates and will be sorted in ascending protocol order. */ std::vector -parseProtocolVersions(boost::beast::string_view const& s); +parseProtocolVersions(std::string_view s); /** * Given a list of supported protocol versions, choose the one we prefer. @@ -55,7 +54,7 @@ negotiateProtocolVersion(std::vector const& versions); * Given a list of supported protocol versions, choose the one we prefer. */ std::optional -negotiateProtocolVersion(boost::beast::string_view const& versions); +negotiateProtocolVersion(std::string_view versions); /** * The list of all the protocol versions we support. diff --git a/src/xrpld/rpc/detail/ServerHandler.cpp b/src/xrpld/rpc/detail/ServerHandler.cpp index 827d8705fd..28e7eebd63 100644 --- a/src/xrpld/rpc/detail/ServerHandler.cpp +++ b/src/xrpld/rpc/detail/ServerHandler.cpp @@ -264,7 +264,7 @@ ServerHandler::onHandoff( static inline json::Output makeOutput(Session& session) { - return [&](boost::beast::string_view const& b) { session.write(b.data(), b.size()); }; + return [&](std::string_view b) { session.write(b.data(), b.size()); }; } static std::map @@ -564,11 +564,11 @@ ServerHandler::processSession( makeOutput(*session), coro, forwardedFor(session->request()), - [&] { + [&] -> std::string_view { auto const iter = session->request().find("X-User"); if (iter != session->request().end()) return iter->value(); - return boost::beast::string_view{}; + return {}; }()); if (beast::rfc2616::isKeepAlive(session->request())) From dd0edc19a05b62e7d3ed40d11222966a021ba4f4 Mon Sep 17 00:00:00 2001 From: Vito Tumas <5780819+Tapanito@users.noreply.github.com> Date: Tue, 18 Aug 2026 11:09:33 +0000 Subject: [PATCH 087/102] fix: Conserve funds correctly when LoanPay fee payee is below reserve (#7843) --- .../tx/transactors/lending/LoanPay.cpp | 88 +++++++------- src/test/app/lending/LoanPay_test.cpp | 107 ++++++++++++++++++ 2 files changed, 146 insertions(+), 49 deletions(-) diff --git a/src/libxrpl/tx/transactors/lending/LoanPay.cpp b/src/libxrpl/tx/transactors/lending/LoanPay.cpp index 4619540295..c5bfd8e9ee 100644 --- a/src/libxrpl/tx/transactors/lending/LoanPay.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanPay.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -9,6 +10,8 @@ #include #include #include +#include +#include #include #include #include @@ -33,6 +36,34 @@ namespace xrpl { +namespace { +// Returns the account's true, unclamped balance in `asset`, for use only in +// fund-conservation checks. accountHolds(..., SpendableHandling::FullBalance) +// cannot be used for this: for XRP it always defers to xrpLiquid, which +// subtracts the account's reserve, so a payee sitting below its own reserve +// would appear to receive nothing even though its raw ledger balance grew. +// That mismatch is exactly what a conservation check must not see. +STAmount +conservationBalance(ReadView const& view, AccountID const& id, Asset const& asset, beast::Journal j) +{ + if (isXRP(asset)) + { + auto const sle = view.read(keylet::account(id)); + if (!sle) + return STAmount{asset}; // LCOV_EXCL_LINE + return view.balanceHookIOU(id, xrpAccount(), sle->getFieldAmount(sfBalance)); + } + return accountHolds( + view, + id, + asset, + FreezeHandling::IgnoreFreeze, + AuthHandling::IgnoreAuth, + j, + SpendableHandling::FullBalance); +} +} // namespace + bool LoanPay::checkExtraFeatures(PreflightContext const& ctx) { @@ -581,34 +612,13 @@ LoanPay::doApply() } // These three values are used to check that funds are conserved after the transfers - auto const accountBalanceBefore = accountHolds( - view, - accountID_, - asset, - FreezeHandling::IgnoreFreeze, - AuthHandling::IgnoreAuth, - j_, - SpendableHandling::FullBalance); + auto const accountBalanceBefore = conservationBalance(view, accountID_, asset, j_); auto const vaultBalanceBefore = accountID_ == vaultPseudoAccount ? STAmount{asset, 0} - : accountHolds( - view, - vaultPseudoAccount, - asset, - FreezeHandling::IgnoreFreeze, - AuthHandling::IgnoreAuth, - j_, - SpendableHandling::FullBalance); + : conservationBalance(view, vaultPseudoAccount, asset, j_); auto const brokerBalanceBefore = accountID_ == brokerPayee ? STAmount{asset, 0} - : accountHolds( - view, - brokerPayee, - asset, - FreezeHandling::IgnoreFreeze, - AuthHandling::IgnoreAuth, - j_, - SpendableHandling::FullBalance); + : conservationBalance(view, brokerPayee, asset, j_); if (totalPaidToVaultRounded != beast::kZero) { @@ -664,33 +674,13 @@ LoanPay::doApply() #endif // Check that funds are conserved - auto const accountBalanceAfter = accountHolds( - view, - accountID_, - asset, - FreezeHandling::IgnoreFreeze, - AuthHandling::IgnoreAuth, - j_, - SpendableHandling::FullBalance); + auto const accountBalanceAfter = conservationBalance(view, accountID_, asset, j_); auto const vaultBalanceAfter = accountID_ == vaultPseudoAccount ? STAmount{asset, 0} - : accountHolds( - view, - vaultPseudoAccount, - asset, - FreezeHandling::IgnoreFreeze, - AuthHandling::IgnoreAuth, - j_, - SpendableHandling::FullBalance); - auto const brokerBalanceAfter = accountID_ == brokerPayee ? STAmount{asset, 0} - : accountHolds( - view, - brokerPayee, - asset, - FreezeHandling::IgnoreFreeze, - AuthHandling::IgnoreAuth, - j_, - SpendableHandling::FullBalance); + : conservationBalance(view, vaultPseudoAccount, asset, j_); + auto const brokerBalanceAfter = accountID_ == brokerPayee + ? STAmount{asset, 0} + : conservationBalance(view, brokerPayee, asset, j_); auto const balanceScale = [&]() { // Find a reasonable scale to use for the balance comparisons. // diff --git a/src/test/app/lending/LoanPay_test.cpp b/src/test/app/lending/LoanPay_test.cpp index 9d840fe1bf..93d1671feb 100644 --- a/src/test/app/lending/LoanPay_test.cpp +++ b/src/test/app/lending/LoanPay_test.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -13,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -728,6 +730,110 @@ private: } } + void + testLoanPayFundsConservedPayeeBelowReserve(FeatureBitset features) + { + // Regression test: LoanPay::doApply's fund-conservation check used to + // read XRP balances via accountHolds(..., SpendableHandling:: + // FullBalance), which for XRP always defers to xrpLiquid (balance + // minus reserve, clamped at zero). When the broker fee landed on a + // payee sitting below its own reserve, that payee's clamped balance + // stayed zero and the fee vanished from the conservation sum, + // tripping "funds are conserved (with rounding)". + testcase("LoanPay funds conserved: broker fee payee below reserve"); + + using namespace jtx; + + Env env(*this, features); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + // Broker defaults match the fuzz workload: ManagementFeeRate = 100 + // tenth-bips. The service fee guarantees feePaid > 0 on the first + // regular payment. + BrokerParameters const brokerParams; + Number const serviceFeeValue{2}; + LoanParameters const loanParams{ + .account = borrower, + .counter = lender, + .principalRequest = 1000, + .serviceFee = serviceFeeValue, + .interest = TenthBips32{percentageToTenthBips(12)}, + .payTotal = 12, + .payInterval = 3600}; + + auto const loanOpt = + createLoan(env, AssetType::XRP, brokerParams, loanParams, issuer, lender, borrower); + if (BEAST_EXPECT(loanOpt); !loanOpt.has_value()) + return; + auto const& [broker, loanKeylet, brokerPseudo] = *loanOpt; + + auto const vaultPseudo = [&]() { + auto const vaultSle = env.le(keylet::vault(broker.vaultID)); + if (!BEAST_EXPECT(vaultSle)) + return AccountID{}; + return vaultSle->at(sfAccount); + }(); + + // Raw AccountRoot balance, matching LoanPay::doApply's conservation + // check (not the reserve-clamped accountHolds()/xrpLiquid() value). + auto rawBalance = [&](AccountID const& id) -> STAmount { + auto const sle = env.le(keylet::account(id)); + if (!BEAST_EXPECT(sle)) + return STAmount{}; + return sle->getFieldAmount(sfBalance); + }; + auto lenderReserve = [&] { + return env.current()->fees().accountReserve(ownerCount(env, lender), 1); + }; + + STAmount const baseFee{env.current()->fees().base}; + + // Park the lender (broker owner, fee payee) exactly at its reserve, + // then burn part of the reserve with an oversized transaction fee. + // Fees are exempt from the reserve check, so the balance ends up + // below the reserve. + env(pay(lender, issuer, rawBalance(lender.id()) - lenderReserve() - baseFee)); + env(noop(lender), Fee(XRP(100))); + env.close(); + BEAST_EXPECT(env.balance(lender) < lenderReserve()); + + // First regular payment, exactly the amount due. + auto const state = getCurrentState(env, broker, loanKeylet); + STAmount const serviceFee = broker.asset(serviceFeeValue); + STAmount const roundedPeriodicPayment{ + broker.asset, + roundPeriodicPayment(broker.asset, state.periodicPayment, state.loanScale)}; + STAmount const totalDue = roundToScale( + roundedPeriodicPayment + serviceFee, state.loanScale, Number::RoundingMode::Upward); + + auto const borrowerBefore = rawBalance(borrower.id()); + auto const vaultBefore = rawBalance(vaultPseudo); + auto const lenderBefore = rawBalance(lender.id()); + + // Before the fix, this aborted inside LoanPay::doApply on + // XRPL_ASSERT_PARTS(goodRounding, "xrpl::LoanPay::doApply", "funds + // are conserved (with rounding)"). + env(loan::pay(borrower, loanKeylet.key, totalDue)); + env.close(); + + auto const borrowerAfter = rawBalance(borrower.id()); + auto const vaultAfter = rawBalance(vaultPseudo); + auto const lenderAfter = rawBalance(lender.id()); + + // The broker fee reached the lender's AccountRoot, even though the + // lender's balance remains below its reserve. + BEAST_EXPECT(lenderAfter > lenderBefore); + BEAST_EXPECT(lenderAfter < lenderReserve()); + + // Total funds conserved across the payer, vault, and fee payee. + BEAST_EXPECT( + borrowerBefore - baseFee + vaultBefore + lenderBefore == + borrowerAfter + vaultAfter + lenderAfter); + } + void runAmendmentIndependent() { @@ -741,6 +847,7 @@ private: #if LOAN_TODO testLoanPayLateFullPaymentBypassesPenalties(features); #endif + testLoanPayFundsConservedPayeeBelowReserve(features); testOverpaymentManagementFee(features); testDosLoanPay(features); testLoanNextPaymentDueDateOverflow(features); From ca39bff3c829add41d8191886450190e4d50e465 Mon Sep 17 00:00:00 2001 From: Bart Date: Tue, 18 Aug 2026 12:35:32 +0000 Subject: [PATCH 088/102] refactor: Add `SHAMapNodeID::isPrefixOf` (#7939) Co-authored-by: Bart <11445373+bthomee@users.noreply.github.com> --- include/xrpl/shamap/SHAMapNodeID.h | 14 ++++++++++++++ src/libxrpl/shamap/SHAMapNodeID.cpp | 11 ++++++++--- src/libxrpl/shamap/SHAMapSync.cpp | 7 +++---- src/xrpld/app/ledger/detail/LedgerNodeHelpers.cpp | 5 ++--- 4 files changed, 27 insertions(+), 10 deletions(-) diff --git a/include/xrpl/shamap/SHAMapNodeID.h b/include/xrpl/shamap/SHAMapNodeID.h index fcd5a4d00e..f35ba2d2a7 100644 --- a/include/xrpl/shamap/SHAMapNodeID.h +++ b/include/xrpl/shamap/SHAMapNodeID.h @@ -55,6 +55,20 @@ public: [[nodiscard]] SHAMapNodeID getChildNodeID(unsigned int branch) const; + /** + * Test whether this node ID lies on the path to the given leaf key + * + * A node at depth d identifies the tree path spelled by the first d + * nibbles of its key, so any leaf beneath it must agree on that prefix. + * A node ID that fails this test names a different subtree than the one + * it was built for. + * + * @param key the key of a leaf below this node + * @return whether this node ID is a prefix of the leaf key + */ + [[nodiscard]] bool + isPrefixOf(uint256 const& key) const; + /** * Create a SHAMapNodeID of a node with the depth of the node and * the key of a leaf diff --git a/src/libxrpl/shamap/SHAMapNodeID.cpp b/src/libxrpl/shamap/SHAMapNodeID.cpp index ecde22a63d..8fd7afe8fc 100644 --- a/src/libxrpl/shamap/SHAMapNodeID.cpp +++ b/src/libxrpl/shamap/SHAMapNodeID.cpp @@ -46,8 +46,7 @@ SHAMapNodeID::SHAMapNodeID(unsigned int depth, uint256 const& hash) : id_(hash), XRPL_ASSERT( depth <= SHAMap::kLeafDepth, "xrpl::SHAMapNodeID::SHAMapNodeID : maximum depth input"); XRPL_ASSERT( - id_ == (id_ & depthMask(depth)), - "xrpl::SHAMapNodeID::SHAMapNodeID : hash and depth inputs do match"); + isPrefixOf(id_), "xrpl::SHAMapNodeID::SHAMapNodeID : hash and depth inputs do match"); } std::string @@ -79,7 +78,7 @@ SHAMapNodeID::getChildNodeID(unsigned int branch) const if (depth_ >= SHAMap::kLeafDepth) Throw("Request for child node ID of " + to_string(*this)); - if (id_ != (id_ & depthMask(depth_))) + if (!isPrefixOf(id_)) Throw("Incorrect mask for " + to_string(*this)); SHAMapNodeID node{depth_ + 1, id_}; @@ -87,6 +86,12 @@ SHAMapNodeID::getChildNodeID(unsigned int branch) const return node; } +bool +SHAMapNodeID::isPrefixOf(uint256 const& key) const +{ + return (key & depthMask(depth_)) == id_; +} + [[nodiscard]] std::optional deserializeSHAMapNodeID(void const* data, std::size_t size) { diff --git a/src/libxrpl/shamap/SHAMapSync.cpp b/src/libxrpl/shamap/SHAMapSync.cpp index e6948ec3ac..a12e524a5f 100644 --- a/src/libxrpl/shamap/SHAMapSync.cpp +++ b/src/libxrpl/shamap/SHAMapSync.cpp @@ -555,10 +555,9 @@ SHAMap::addKnownNode( { XRPL_ASSERT(!nodeID.isRoot(), "xrpl::SHAMap::addKnownNode : valid node"); XRPL_ASSERT(treeNode, "xrpl::SHAMap::addKnownNode : non-null tree node"); - XRPL_ASSERT( - !treeNode->isLeaf() || - SHAMapNodeID::createID(nodeID.getDepth(), leafKey(*treeNode)).getNodeID() == - nodeID.getNodeID(), + XRPL_ASSERT_IF( + treeNode->isLeaf(), + nodeID.isPrefixOf(leafKey(*treeNode)), "xrpl::SHAMap::addKnownNode : leaf position consistent with node ID"); if (!isSynching()) diff --git a/src/xrpld/app/ledger/detail/LedgerNodeHelpers.cpp b/src/xrpld/app/ledger/detail/LedgerNodeHelpers.cpp index abd669d446..230c802022 100644 --- a/src/xrpld/app/ledger/detail/LedgerNodeHelpers.cpp +++ b/src/xrpld/app/ledger/detail/LedgerNodeHelpers.cpp @@ -75,11 +75,10 @@ getSHAMapNodeID(protocol::TMLedgerNode const& ledgerNode, SHAMapTreeNode const& if (treeNode.isLeaf()) { auto const key = leafKey(treeNode); - auto const expectedID = SHAMapNodeID::createID(nodeID->getDepth(), key); SOMETIMES( - nodeID->getNodeID() != expectedID.getNodeID(), + !nodeID->isPrefixOf(key), "xrpl::getSHAMapNodeID : legacy leaf ID inconsistent with key"); - if (nodeID->getNodeID() != expectedID.getNodeID()) + if (!nodeID->isPrefixOf(key)) return std::nullopt; } From f5f47f1cf55960d318330d41dc4b9238451657a1 Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Tue, 18 Aug 2026 15:03:45 +0000 Subject: [PATCH 089/102] chore: Publish debian/rpm packages from GitHub directly (#8031) --- .cspell.config.yaml | 3 + .github/actions/generate-version/action.yml | 44 -------- .github/actions/release-info/action.yml | 90 +++++++++++++++ .github/dependabot.yml | 2 +- .github/scripts/strategy-matrix/linux.json | 4 +- .github/workflows/on-pr.yml | 2 +- .github/workflows/on-tag.yml | 17 ++- .github/workflows/on-trigger.yml | 9 +- .../workflows/reusable-build-test-config.yml | 7 +- .github/workflows/reusable-package.yml | 49 ++++++-- .github/workflows/reusable-upload-recipe.yml | 12 +- package/README.md | 63 +++++++++-- package/build_pkg.sh | 32 +++--- package/publish_pkg.sh | 106 ++++++++++++++++++ 14 files changed, 343 insertions(+), 97 deletions(-) delete mode 100644 .github/actions/generate-version/action.yml create mode 100644 .github/actions/release-info/action.yml create mode 100755 package/publish_pkg.sh diff --git a/.cspell.config.yaml b/.cspell.config.yaml index ec9f87cfdd..e194ee21f8 100644 --- a/.cspell.config.yaml +++ b/.cspell.config.yaml @@ -250,6 +250,8 @@ words: - Raphson - rcflags - replayer + - repodata + - repomd - rerandomize - rerandomization - rerandomized @@ -290,6 +292,7 @@ words: - sles - soci - socidb + - Sonatype - sponsee - sponsees - SRPMS diff --git a/.github/actions/generate-version/action.yml b/.github/actions/generate-version/action.yml deleted file mode 100644 index 50b3166596..0000000000 --- a/.github/actions/generate-version/action.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Generate build version number -description: "Generate build version number." - -outputs: - version: - description: "The generated build version number." - value: ${{ steps.version.outputs.version }} - -runs: - using: composite - steps: - # When a tag is pushed, the version is used as-is. - - name: Generate version for tag event - if: ${{ startsWith(github.ref, 'refs/tags/') }} - shell: bash - env: - VERSION: ${{ github.ref_name }} - run: echo "VERSION=${VERSION}" >>"${GITHUB_ENV}" - - # When a tag is not pushed, then the version (e.g. 1.2.3-b0) is extracted - # from the BuildInfo.cpp file and the shortened commit hash appended to it. - # We use a plus sign instead of a hyphen because Conan recipe versions do - # not support two hyphens. - - name: Generate version for non-tag event - if: ${{ !startsWith(github.ref, 'refs/tags/') }} - shell: bash - run: | - echo 'Extracting version from BuildInfo.cpp.' - VERSION="$(cat src/libxrpl/protocol/BuildInfo.cpp | grep "versionString =" | awk -F '"' '{print $2}')" - if [[ -z "${VERSION}" ]]; then - echo 'Unable to extract version from BuildInfo.cpp.' - exit 1 - fi - - echo 'Appending shortened commit hash to version.' - SHA='${{ github.sha }}' - VERSION="${VERSION}+${SHA:0:7}" - - echo "VERSION=${VERSION}" >>"${GITHUB_ENV}" - - - name: Output version - id: version - shell: bash - run: echo "version=${VERSION}" >>"${GITHUB_OUTPUT}" diff --git a/.github/actions/release-info/action.yml b/.github/actions/release-info/action.yml new file mode 100644 index 0000000000..7f1061df93 --- /dev/null +++ b/.github/actions/release-info/action.yml @@ -0,0 +1,90 @@ +name: Release info +description: "Derive the version, release channel and package release number for this build." + +outputs: + version: + description: "The build version number." + value: ${{ steps.version.outputs.version }} + channel: + description: "The release channel this build belongs to." + value: ${{ steps.channel.outputs.channel }} + pkg_release: + description: "The package release number: 1 for a tag, the run number otherwise." + value: ${{ steps.pkg_release.outputs.pkg_release }} + +runs: + using: composite + steps: + # A tag names its own version. Anything else takes it from BuildInfo.cpp and + # appends the commit hash as build metadata, joined with a plus sign because a + # Conan version cannot contain two hyphens. + - name: Determine version + id: version + shell: bash + env: + IS_TAG: ${{ startsWith(github.ref, 'refs/tags/') }} + REF_NAME: ${{ github.ref_name }} + SHA: ${{ github.sha }} + run: | + if [[ "${IS_TAG}" == "true" ]]; then + version="${REF_NAME}" + else + version="$(awk -F'"' '/versionString =/ { print $2 }' src/libxrpl/protocol/BuildInfo.cpp)" + if [[ -z "${version}" ]]; then + echo "Unable to read versionString from BuildInfo.cpp." >&2 + exit 1 + fi + version="${version}+${SHA:0:7}" + fi + + echo "version=${version}" | tee -a "${GITHUB_OUTPUT}" + + # Only a tag says how mature a build is: a push is a develop build whatever + # its version, and a non-public codebase keeps its packages to itself. + - name: Determine release channel + id: channel + shell: bash + env: + IS_TAG: ${{ startsWith(github.ref, 'refs/tags/') }} + REF_NAME: ${{ github.ref_name }} + VISIBILITY: ${{ github.event.repository.visibility }} + run: | + pre_release="" + if [[ "${REF_NAME}" == *-* ]]; then + pre_release="${REF_NAME#*-}" + fi + + if [[ "${VISIBILITY}" != "public" ]]; then + channel=private + elif [[ "${IS_TAG}" != "true" ]]; then + channel=develop + elif [[ -z "${pre_release}" ]]; then + channel=stable + elif [[ "${pre_release}" =~ ^rc[0-9]+(\+.*)?$ ]]; then + channel=unstable + elif [[ "${pre_release}" =~ ^b(0|[1-9][0-9]*)(\+.*)?$ ]]; then + channel=experimental + else + echo "Unsupported pre-release in tag '${REF_NAME}'. Use bN or rcN." >&2 + exit 1 + fi + + echo "channel=${channel}" | tee -a "${GITHUB_OUTPUT}" + + # A tag is packaged once, so its release number is fixed at 1. Develop builds + # repeat the same version, so the run number is what makes each push an + # upgrade rather than a reinstall. + - name: Determine package release + id: pkg_release + shell: bash + env: + IS_TAG: ${{ startsWith(github.ref, 'refs/tags/') }} + RUN_NUMBER: ${{ github.run_number }} + run: | + if [[ "${IS_TAG}" == "true" ]]; then + pkg_release=1 + else + pkg_release="${RUN_NUMBER}" + fi + + echo "pkg_release=${pkg_release}" | tee -a "${GITHUB_OUTPUT}" diff --git a/.github/dependabot.yml b/.github/dependabot.yml index fcac44c44c..1ccbd61102 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,7 +4,7 @@ updates: directories: - / - .github/actions/build-deps/ - - .github/actions/generate-version/ + - .github/actions/release-info/ - .github/actions/set-compiler-env/ - .github/actions/setup-conan/ schedule: diff --git a/.github/scripts/strategy-matrix/linux.json b/.github/scripts/strategy-matrix/linux.json index 97163fb8ce..bd3446f599 100644 --- a/.github/scripts/strategy-matrix/linux.json +++ b/.github/scripts/strategy-matrix/linux.json @@ -92,7 +92,7 @@ "build_type": ["Release"], "arch": ["amd64"], "minimal": false, - "image": "ghcr.io/xrplf/xrpld/packaging-debian:sha-577d745" + "image": "ghcr.io/xrplf/xrpld/packaging-debian:sha-028ccea" } ], @@ -102,7 +102,7 @@ "build_type": ["Release"], "arch": ["amd64"], "minimal": false, - "image": "ghcr.io/xrplf/xrpld/packaging-rhel:sha-577d745" + "image": "ghcr.io/xrplf/xrpld/packaging-rhel:sha-028ccea" } ] } diff --git a/.github/workflows/on-pr.yml b/.github/workflows/on-pr.yml index 0a4e4b1f49..f14256b9e8 100644 --- a/.github/workflows/on-pr.yml +++ b/.github/workflows/on-pr.yml @@ -77,7 +77,7 @@ jobs: # Keep the paths below in sync with those in `on-trigger.yml`. .github/actions/build-deps/** - .github/actions/generate-version/** + .github/actions/release-info/** .github/actions/setup-conan/** .github/scripts/strategy-matrix/** .github/workflows/reusable-build-test-config.yml diff --git a/.github/workflows/on-tag.yml b/.github/workflows/on-tag.yml index abedc13d69..1c9fb414f2 100644 --- a/.github/workflows/on-tag.yml +++ b/.github/workflows/on-tag.yml @@ -1,5 +1,9 @@ -# This workflow uploads the libxrpl recipe to the Conan remote and builds -# release packages when a versioned tag is pushed. +# When a versioned tag is pushed, this workflow: +# +# - uploads the libxrpl recipe to the Conan remote +# - builds and tests the release binaries +# - builds the DEB and RPM packages +# - publishes those packages to the XRPLF package repositories name: Tag on: @@ -24,7 +28,7 @@ jobs: remote_password: ${{ secrets.NEXUS_REMOTE_PASSWORD }} build-test: - if: ${{ github.repository == 'XRPLF/rippled' }} + if: ${{ github.repository_owner == 'XRPLF' }} uses: ./.github/workflows/reusable-build-test.yml strategy: fail-fast: true @@ -37,6 +41,11 @@ jobs: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} package: - if: ${{ github.repository == 'XRPLF/rippled' }} + if: ${{ github.repository_owner == 'XRPLF' }} needs: build-test uses: ./.github/workflows/reusable-package.yml + with: + publish: true + secrets: + remote_username: ${{ secrets.NEXUS_REMOTE_USERNAME }} + remote_password: ${{ secrets.NEXUS_REMOTE_PASSWORD }} diff --git a/.github/workflows/on-trigger.yml b/.github/workflows/on-trigger.yml index 73f918d528..dcd14b7933 100644 --- a/.github/workflows/on-trigger.yml +++ b/.github/workflows/on-trigger.yml @@ -15,7 +15,7 @@ on: # Keep the paths below in sync with those in `on-pr.yml`. - ".github/actions/build-deps/**" - - ".github/actions/generate-version/**" + - ".github/actions/release-info/**" - ".github/actions/setup-conan/**" - ".github/scripts/strategy-matrix/**" - ".github/workflows/reusable-build-test-config.yml" @@ -108,3 +108,10 @@ jobs: package: needs: build-test uses: ./.github/workflows/reusable-package.yml + with: + # Packages are built on every trigger; only develop pushes in XRPLF/rippled + # publish them, matching upload-recipe above. + publish: ${{ github.repository == 'XRPLF/rippled' && github.event_name == 'push' && github.ref == 'refs/heads/develop' }} + secrets: + remote_username: ${{ secrets.NEXUS_REMOTE_USERNAME }} + remote_password: ${{ secrets.NEXUS_REMOTE_PASSWORD }} diff --git a/.github/workflows/reusable-build-test-config.yml b/.github/workflows/reusable-build-test-config.yml index d8550efc4c..7989d2c7f6 100644 --- a/.github/workflows/reusable-build-test-config.yml +++ b/.github/workflows/reusable-build-test-config.yml @@ -111,6 +111,9 @@ jobs: VOIDSTAR_ENABLED: ${{ contains(inputs.cmake_args, '-Dvoidstar=ON') }} VALIDATOR_KEYS_ENABLED: ${{ contains(inputs.cmake_args, '-Dvalidator_keys=ON') }} SANITIZERS_ENABLED: ${{ inputs.sanitizers != '' }} + # The binaries reusable-package.yml consumes. A private repository skips + # them except on a tag push, which is what produces its release packages. + PACKAGING_ARTIFACTS_ENABLED: ${{ github.event.repository.visibility == 'public' || startsWith(github.ref, 'refs/tags/') }} steps: - name: Cleanup workspace (macOS and Windows) if: ${{ runner.os == 'macOS' || runner.os == 'Windows' }} @@ -222,7 +225,7 @@ jobs: fi - name: Upload the binary (Linux) - if: ${{ github.event.repository.visibility == 'public' && runner.os == 'Linux' }} + if: ${{ env.PACKAGING_ARTIFACTS_ENABLED == 'true' && runner.os == 'Linux' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: xrpld-${{ inputs.config_name }} @@ -236,7 +239,7 @@ jobs: run: ./validator-keys --unittest - name: Upload the validator-keys binary - if: ${{ github.event.repository.visibility == 'public' && env.VALIDATOR_KEYS_ENABLED == 'true' }} + if: ${{ env.PACKAGING_ARTIFACTS_ENABLED == 'true' && env.VALIDATOR_KEYS_ENABLED == 'true' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: validator-keys-${{ inputs.config_name }} diff --git a/.github/workflows/reusable-package.yml b/.github/workflows/reusable-package.yml index b45cae52d9..430072b627 100644 --- a/.github/workflows/reusable-package.yml +++ b/.github/workflows/reusable-package.yml @@ -1,17 +1,34 @@ -# 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. +# Build Linux packages from the pre-built xrpld and validator-keys artifacts: +# +# - one job per distro, taken from "package_configs" in linux.json +# - each job runs in that distro's container, which is what decides DEB or RPM +# - with 'publish: true' a job also uploads what it built +# (see package/publish_pkg.sh) +# +# Only linux/amd64 is supported; the runner is hardcoded in the job below. name: Package on: workflow_call: inputs: - pkg_release: - description: "Package release number. Increment when repackaging the same executable." + publish: + description: "Whether to publish the packages after building them." + required: false + type: boolean + default: false + nexus_url: + description: "The base URL of the Nexus instance hosting the deb and rpm repositories." required: false type: string - default: "1" + default: https://packages.xrplf.org + + secrets: + remote_username: + description: "The username of a Nexus account with write access to the repositories." + required: false + remote_password: + description: "The password or token for that Nexus account." + required: false defaults: run: @@ -41,7 +58,7 @@ jobs: package: needs: [generate-matrix] - if: ${{ github.event.repository.visibility == 'public' }} + if: ${{ github.event.repository.visibility == 'public' || startsWith(github.ref, 'refs/tags/') }} strategy: fail-fast: false matrix: ${{ fromJson(needs.generate-matrix.outputs.matrix) }} @@ -71,9 +88,14 @@ jobs: - name: Make binaries executable run: chmod +x "${BUILD_DIR}/xrpld" "${BUILD_DIR}/validator-keys" + - name: Determine release info + id: release_info + uses: ./.github/actions/release-info + - name: Build package env: - PKG_RELEASE: ${{ inputs.pkg_release }} + PKG_RELEASE: ${{ steps.release_info.outputs.pkg_release }} + PKG_CHANNEL: ${{ steps.release_info.outputs.channel }} run: ./package/build_pkg.sh - name: Upload package artifact @@ -85,3 +107,12 @@ jobs: ${{ env.BUILD_DIR }}/debbuild/*.ddeb ${{ env.BUILD_DIR }}/rpmbuild/RPMS/**/*.rpm if-no-files-found: error + + - name: Publish package + if: ${{ inputs.publish }} + env: + CHANNEL: ${{ steps.release_info.outputs.channel }} + NEXUS_URL: ${{ inputs.nexus_url }} + NEXUS_USERNAME: ${{ secrets.remote_username }} + NEXUS_PASSWORD: ${{ secrets.remote_password }} + run: ./package/publish_pkg.sh "${CHANNEL}" "${BUILD_DIR}" diff --git a/.github/workflows/reusable-upload-recipe.yml b/.github/workflows/reusable-upload-recipe.yml index a8d35fadad..680d95fb97 100644 --- a/.github/workflows/reusable-upload-recipe.yml +++ b/.github/workflows/reusable-upload-recipe.yml @@ -49,9 +49,9 @@ jobs: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Generate build version number - id: version - uses: ./.github/actions/generate-version + - name: Determine release info + id: release_info + uses: ./.github/actions/release-info - name: Set up Conan uses: ./.github/actions/setup-conan @@ -64,8 +64,8 @@ jobs: - name: Upload Conan recipe (version) run: | - conan export . --version=${{ steps.version.outputs.version }} - conan upload --confirm --check --remote="${REMOTE_NAME}" xrpl/${{ steps.version.outputs.version }} + conan export . --version=${{ steps.release_info.outputs.version }} + conan upload --confirm --check --remote="${REMOTE_NAME}" xrpl/${{ steps.release_info.outputs.version }} # When this workflow is triggered by a push event, it will always be when merging into the # 'develop' branch, see on-trigger.yml. @@ -92,4 +92,4 @@ jobs: conan upload --confirm --check --remote="${REMOTE_NAME}" xrpl/release outputs: - ref: xrpl/${{ steps.version.outputs.version }} + ref: xrpl/${{ steps.release_info.outputs.version }} diff --git a/package/README.md b/package/README.md index 887509b60b..4899ee203e 100644 --- a/package/README.md +++ b/package/README.md @@ -8,7 +8,8 @@ a build configured with `-Dvalidator_keys=ON`. ``` package/ - build_pkg.sh Staging and build script (called by the CMake `package` target and CI) + build_pkg.sh Staging and build script (called by the CMake `package` target and CI) + publish_pkg.sh Uploads built packages to the XRPLF Nexus repositories (called by CI) rpm/ xrpld.spec RPM spec debian/ Debian control files (control, rules, copyright, xrpld.docs, xrpld.links, source/format) @@ -87,7 +88,7 @@ docker run --rm \ ./package/build_pkg.sh --pkg-release "${PKG_RELEASE}" # Output: -# build/debbuild/*.deb (DEB + dbgsym .ddeb) +# build/debbuild/*.deb (DEB + dbgsym; Debian names both .deb) # build/rpmbuild/RPMS/x86_64/*.rpm ``` @@ -120,6 +121,50 @@ The package version is not a CMake input on this path: `build_pkg.sh` derives it from the just-built `xrpld` binary's `xrpld --version` output. The package release defaults to 1 and is overridable with `-Dpkg_release=N`. +## Publishing packages + +Packages are published to the XRPLF repositories on Sonatype Nexus at +`https://packages.xrplf.org`. The `release-info` action decides the channel from +the event, and `publish_pkg.sh` maps that channel to a repository pair: + +| Event | Version | Channel | DEB repository | RPM repository | +| ------------------------ | ----------------- | -------------- | ------------------ | ------------------ | +| tag | `X.Y.Z` | `stable` | `deb-stable` | `rpm-stable` | +| tag | `X.Y.Z-rcN` | `unstable` | `deb-unstable` | `rpm-unstable` | +| tag | `X.Y.Z-bN` | `experimental` | `deb-experimental` | `rpm-experimental` | +| push to `develop` | `xrpld --version` | `develop` | `deb-develop` | `rpm-develop` | +| tag, non-public codebase | _any_ | `private` | `deb-private` | `rpm-private` | + +Only a tag names a channel — do not extend that to `develop`, where +`BuildInfo.cpp`'s `versionString` moves through `-bN`, `-rcN` and even the final +version during a release cycle, which would send develop builds into `stable`. +Versions sort in row order, so moving to a more mature channel never downgrades. + +The action decides the package release number on the same split: a tag's version +is unique, so its packages are release 1, while develop repeats the same version +and takes `github.run_number` so each push supersedes the last. Both reach the +packaging scripts as arguments, so neither script derives anything itself. + +Publishing is the last step of each packaging job, uploading from the container +that built the packages. It runs when the caller passes `publish: true`: +`on-trigger.yml` for develop pushes in `XRPLF/rippled`, `on-tag.yml` for tags in +any `XRPLF` repository, `on-pr.yml` never. Both authenticate with the +`NEXUS_REMOTE_USERNAME` / `NEXUS_REMOTE_PASSWORD` secrets already used for the +Conan remote. + +Nexus owns the repository metadata; nothing here signs or indexes anything. Worth +knowing: + +- Each apt-hosted repository needs a distribution and a PGP signing keypair + configured in Nexus, which rejects one created without a keypair. +- yum metadata is rebuilt asynchronously, so a successful publish is not + immediately installable. +- Each job uploads only what it built, and uploads are not transactional, so a + failure can leave one format published alone. Re-running is safe: both the apt + POST and the yum PUT replace an existing asset. +- The `develop` repositories gain a package per push, so they need a cleanup + policy to stay bounded; tagged channels publish each version once. + ## How `build_pkg.sh` works `build_pkg.sh` derives the `xrpld` software version from @@ -151,10 +196,9 @@ With `PKG_RELEASE=1`, the package metadata becomes: | `3.2.0-b1` | `3.2.0~b1-1%{?dist}` | `3.2.0~b1-1` | | `3.2.0-rc1` | `3.2.0~rc1-1%{?dist}` | `3.2.0~rc1-1` | -The Debian changelog entry carries the repository component: final releases use -`stable`, `b0` builds, including `b0+metadata`, use `develop`, and `bN`/`rcN` -pre-releases use `unstable`. -Build metadata on a final release, such as `3.2.0+abc123`, is rejected. +The Debian changelog entry carries the channel passed as `--channel` +(`PKG_CHANNEL`), defaulting to `unstable`. An unsupported pre-release, and build +metadata on a final release such as `3.2.0+abc123`, are both rejected. The RPM path intentionally uses `~` in `Version`, matching the Debian pre-release ordering convention, so RPM filenames/NVRs begin with forms like @@ -209,17 +253,20 @@ service restart. 5. Generates a minimal `debian/changelog` using `${pkg_version}-${PKG_RELEASE}`, where `pkg_version` is derived from the binary-reported `xrpld` version. 6. Runs `dpkg-buildpackage -b --no-sign -d` (`-d` skips the build-dependency check, since the binary is already built). `debian/rules` uses manual `install` commands. -7. Output: `debbuild/*.deb` and `debbuild/*.ddeb` (dbgsym package) +7. Output: `debbuild/*.deb`, the binary package and the `-dbgsym` package. + Debian gives dbgsym packages a `.deb` extension; only Ubuntu uses `.ddeb`. ## Post-build verification ```bash # DEB dpkg-deb -c debbuild/*.deb | grep -E 'systemd|sysusers|tmpfiles' -lintian -I debbuild/*.deb # RPM rpm -qlp rpmbuild/RPMS/x86_64/*.rpm + +# Optional, and not in the packaging image: apt-get install -y lintian +lintian -I debbuild/*.deb ``` ## Reproducibility diff --git a/package/build_pkg.sh b/package/build_pkg.sh index d853bf95b7..cca3be7248 100755 --- a/package/build_pkg.sh +++ b/package/build_pkg.sh @@ -16,6 +16,8 @@ Options (each can also be set via the env var shown): xrpld and validator-keys binaries [BUILD_DIR; default: ${PWD}/build] --pkg-release N package release iteration [PKG_RELEASE; default: 1] + --channel NAME release channel, written + to debian/changelog [PKG_CHANNEL; default: unstable] --source-date-epoch SECS reproducibility timestamp [SOURCE_DATE_EPOCH; latest git ctime; fallback: current time] -h, --help show this help and exit EOF @@ -32,6 +34,7 @@ need_arg() { SRC_DIR="${SRC_DIR:-}" BUILD_DIR="${BUILD_DIR:-}" PKG_RELEASE="${PKG_RELEASE:-1}" +PKG_CHANNEL="${PKG_CHANNEL:-unstable}" SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-}" while [[ $# -gt 0 ]]; do @@ -51,6 +54,11 @@ while [[ $# -gt 0 ]]; do PKG_RELEASE="$2" shift 2 ;; + --channel) + need_arg "$@" + PKG_CHANNEL="$2" + shift 2 + ;; --source-date-epoch) need_arg "$@" SOURCE_DATE_EPOCH="$2" @@ -198,7 +206,6 @@ stage_common() { build_rpm() { local topdir="${BUILD_DIR}/rpmbuild" - rm -rf "${topdir}" mkdir -p "${topdir}"/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS} cp "${SRC_DIR}/package/rpm/xrpld.spec" "${topdir}/SPECS/xrpld.spec" @@ -214,7 +221,6 @@ build_rpm() { build_deb() { local staging="${BUILD_DIR}/debbuild/source" - rm -rf "${staging}" mkdir -p "${staging}" stage_common "${staging}" @@ -225,25 +231,9 @@ build_deb() { cp "${staging}/xrpld.tmpfiles" "${staging}/debian/xrpld.tmpfiles" cp "${staging}/xrpld.logrotate" "${staging}/debian/xrpld.logrotate" - # Choose the Debian repository component for this package. - # 3.2.0 -> stable, *-b0[+metadata] -> develop, - # bN/rcN pre-releases -> unstable. - local deb_component - if [[ -z "${pre_release}" ]]; then - deb_component="stable" - elif [[ "${pre_release}" =~ ^b0(\+.*)?$ ]]; then - deb_component="develop" - elif [[ "${pre_release}" =~ ^(b[1-9][0-9]*|rc[0-9]+)(\+.*)?$ ]]; then - deb_component="unstable" - else - echo "build_pkg.sh: unsupported xrpld pre-release '${pre_release}'." >&2 - echo "Use bN or rcN, e.g. 3.2.0-b1 or 3.2.0-rc2." >&2 - exit 1 - fi - # Debian version is [~
    ]-.
         cat >"${staging}/debian/changelog" <  ${CHANGELOG_DATE}
    @@ -255,4 +245,8 @@ EOF
         (cd "${staging}" && dpkg-buildpackage -b --no-sign -d)
     }
     
    +# Remove both build directories, because a package left from an earlier build
    +# would otherwise be picked up and published alongside this one.
    +rm -rf "${BUILD_DIR}/debbuild" "${BUILD_DIR}/rpmbuild"
    +
     "build_${pkg_type}"
    diff --git a/package/publish_pkg.sh b/package/publish_pkg.sh
    new file mode 100755
    index 0000000000..be36b531de
    --- /dev/null
    +++ b/package/publish_pkg.sh
    @@ -0,0 +1,106 @@
    +#!/usr/bin/env bash
    +set -euo pipefail
    +
    +# Publish the DEB and RPM packages built by build_pkg.sh to the XRPLF package
    +# repositories on Sonatype Nexus.
    +#
    +# Usage: publish_pkg.sh  [package-dir]
    +#
    +#   channel      release channel, selecting the 'deb-' and
    +#                'rpm-' repository pair
    +#   package-dir  searched recursively for *.deb, *.ddeb and *.rpm ('build' by
    +#                default)
    +#
    +# NEXUS_USERNAME and NEXUS_PASSWORD are required. NEXUS_URL overrides the target
    +# instance, and DRY_RUN=1 lists the uploads without performing them.
    +
    +channel="${1:-}"
    +pkg_dir="${2:-build}"
    +nexus_url="${NEXUS_URL:-https://packages.xrplf.org}"
    +
    +if [[ -z "${channel}" ]]; then
    +    echo "usage: publish_pkg.sh  [package-dir]" >&2
    +    exit 2
    +fi
    +
    +deb_repo="deb-${channel}"
    +rpm_repo="rpm-${channel}"
    +
    +if [[ -z "${DRY_RUN:-}" ]]; then
    +    : "${NEXUS_USERNAME:?is required}" "${NEXUS_PASSWORD:?is required}"
    +fi
    +
    +# Deliberate curl choices:
    +#
    +#   - no --fail, which would hide the response body where Nexus explains what it
    +#     rejected
    +#   - no --location, since curl downgrades a redirected POST to GET and turns an
    +#     upload into a no-op that still answers 200
    +#   - credentials on stdin, to keep them out of the process list
    +upload() {
    +    local url="$1"
    +    shift
    +    [[ -z "${DRY_RUN:-}" ]] || return 0
    +
    +    local body code status=0
    +    body="$(mktemp)"
    +    code="$(
    +        printf 'user = %s:%s\n' "${NEXUS_USERNAME}" "${NEXUS_PASSWORD}" |
    +            curl \
    +                --config - \
    +                --silent \
    +                --show-error \
    +                --retry 3 \
    +                --retry-delay 5 \
    +                --retry-all-errors \
    +                --output "${body}" \
    +                --write-out '%{http_code}' \
    +                "$@" \
    +                "${url}"
    +    )" || status=$?
    +
    +    if [[ ${status} -ne 0 || ! "${code}" =~ ^2[0-9][0-9]$ ]]; then
    +        echo "publish_pkg.sh: upload failed (curl ${status}, HTTP ${code}): ${url}" >&2
    +        cat "${body}" >&2
    +        echo >&2
    +        rm -f "${body}"
    +        exit 1
    +    fi
    +
    +    rm -f "${body}"
    +}
    +
    +echo "Publishing ${pkg_dir} to ${deb_repo} and ${rpm_repo} on ${nexus_url}:"
    +
    +count=0
    +while IFS= read -r -d '' file; do
    +    name="${file##*/}"
    +    case "${name}" in
    +        # A raw body with a multipart Content-Type, POSTed to the repository root,
    +        # is the documented upload for a hosted apt repository:
    +        # https://help.sonatype.com/en/apt-repositories.html#deploying-packages-to-hosted-apt-repositories
    +        *.deb | *.ddeb)
    +            echo "  ${name} -> ${deb_repo}"
    +            upload "${nexus_url}/repository/${deb_repo}/" \
    +                --header 'Content-Type: multipart/form-data' \
    +                --data-binary "@${file}"
    +            ;;
    +        # yum repositories are addressed by path; the arch comes from the name.
    +        *.rpm)
    +            arch="${name%.rpm}"
    +            arch="${arch##*.}"
    +            echo "  ${name} -> ${rpm_repo}/${arch}"
    +            upload "${nexus_url}/repository/${rpm_repo}/${arch}/${name}" \
    +                --upload-file "${file}"
    +            ;;
    +    esac
    +    count=$((count + 1))
    +done < <(find "${pkg_dir}" -type f \( -name '*.deb' -o -name '*.ddeb' -o -name '*.rpm' \) -print0)
    +
    +# Uploading nothing would otherwise look like a successful publish.
    +if [[ ${count} -eq 0 ]]; then
    +    echo "publish_pkg.sh: no packages found in ${pkg_dir}." >&2
    +    exit 1
    +fi
    +
    +echo "${count} package(s) ${DRY_RUN:+would be }published."
    
    From b21fd86f6ec879828daff1379fefd83fd7ce3bef Mon Sep 17 00:00:00 2001
    From: Shawn Xie <35279399+shawnxie999@users.noreply.github.com>
    Date: Tue, 18 Aug 2026 17:56:33 +0000
    Subject: [PATCH 090/102] fix: Fix assorted NFT and pDEX bugs (#7749)
    
    ---
     src/libxrpl/ledger/helpers/NFTokenHelpers.cpp |  16 +-
     src/libxrpl/tx/paths/OfferStream.cpp          |  20 +++
     .../tx/transactors/nft/NFTokenAcceptOffer.cpp |   9 ++
     src/test/app/NFToken_test.cpp                 | 125 +++++++++++++++
     src/test/app/PermissionedDEX_test.cpp         | 145 ++++++++++++++++++
     5 files changed, 314 insertions(+), 1 deletion(-)
    
    diff --git a/src/libxrpl/ledger/helpers/NFTokenHelpers.cpp b/src/libxrpl/ledger/helpers/NFTokenHelpers.cpp
    index f3e4597558..ebe5271765 100644
    --- a/src/libxrpl/ledger/helpers/NFTokenHelpers.cpp
    +++ b/src/libxrpl/ledger/helpers/NFTokenHelpers.cpp
    @@ -12,6 +12,7 @@
     #include 
     #include 
     #include 
    +#include 
     #include 
     #include 
     #include 
    @@ -773,6 +774,13 @@ tokenOfferCreatePreflight(
             return temBAD_AMOUNT;
         }
     
    +    if (rules.enabled(fixCleanup3_4_0))
    +    {
    +        // We don't allow a non-native currency to use the currency code XRP.
    +        if (badAsset() == amount.asset())
    +            return temBAD_CURRENCY;
    +    }
    +
         if (!isXRP(amount))
         {
             if ((nftFlags & nft::kFlagOnlyXrp) != 0)
    @@ -851,7 +859,13 @@ tokenOfferCreatePreclaim(
                 return tefNFTOKEN_IS_NOT_TRANSFERABLE;
         }
     
    -    if (isFrozen(view, acctID, amount.get().currency, amount.getIssuer()))
    +    // The IOU issuer is not subject to their own global freeze when the offer
    +    // is denominated in their own IOU (e.g. receiving their own transfer fees),
    +    // and they cannot hold a trust line to themselves.
    +    bool const acctIsIouIssuer =
    +        view.rules().enabled(fixCleanup3_4_0) && acctID == amount.getIssuer();
    +    if (!acctIsIouIssuer &&
    +        isFrozen(view, acctID, amount.get().currency, amount.getIssuer()))
             return tecFROZEN;
     
         // If this is an offer to buy the token, the account must have the
    diff --git a/src/libxrpl/tx/paths/OfferStream.cpp b/src/libxrpl/tx/paths/OfferStream.cpp
    index 2f2fef49f0..6884a113bd 100644
    --- a/src/libxrpl/tx/paths/OfferStream.cpp
    +++ b/src/libxrpl/tx/paths/OfferStream.cpp
    @@ -4,6 +4,7 @@
     #include 
     #include 
     #include 
    +#include 
     #include 
     #include 
     #include 
    @@ -25,7 +26,9 @@
     #include 
     #include 
     #include 
    +#include 
     #include 
    +#include 
     
     #include 
     #include 
    @@ -257,6 +260,23 @@ TOfferStreamBase::step()
                 continue;
             }
     
    +        // Post-fixCleanup3_4_0 defensive check: an offer indexed in a domain
    +        // book must claim that same domain. This can only happen if the book
    +        // directory is corrupt (i.e. a separate book indexing bug). An offer
    +        // with no sfDomainID at all is just as wrong here: the domain
    +        // membership check below is gated on that field being present, so
    +        // such an offer would otherwise be consumed from a domain book
    +        // without any credential check.
    +        if (view_.rules().enabled(fixCleanup3_4_0) && book_.domain.has_value() &&
    +            (!entry->isFieldPresent(sfDomainID) ||
    +             entry->getFieldH256(sfDomainID) != *book_.domain))
    +        {
    +            JLOG(j_.error()) << "Offer " << entry->key()
    +                             << " domain missing or does not match book domain";
    +            Throw(
    +                tecINTERNAL, "Offer domain missing or does not match book domain.");
    +        }
    +
             // Pre-fixCleanup3_3_0: validate domain membership for any book.
             // Post-fixCleanup3_3_0: only validate when walking a domain book.
             // Hybrid offers carry sfDomainID but also participate in the open
    diff --git a/src/libxrpl/tx/transactors/nft/NFTokenAcceptOffer.cpp b/src/libxrpl/tx/transactors/nft/NFTokenAcceptOffer.cpp
    index 41bb051768..0cf7af1463 100644
    --- a/src/libxrpl/tx/transactors/nft/NFTokenAcceptOffer.cpp
    +++ b/src/libxrpl/tx/transactors/nft/NFTokenAcceptOffer.cpp
    @@ -8,12 +8,14 @@
     #include 
     #include 
     #include 
    +#include 
     #include 
     #include 
     #include 
     #include 
     #include 
     #include 
    +#include 
     #include 
     #include 
     #include 
    @@ -46,6 +48,13 @@ NFTokenAcceptOffer::preflight(PreflightContext const& ctx)
     
             if (*bf <= beast::kZero)
                 return temMALFORMED;
    +
    +        if (ctx.rules.enabled(fixCleanup3_4_0))
    +        {
    +            // We don't allow a non-native currency to use the currency code XRP.
    +            if (badAsset() == bf->asset())
    +                return temBAD_CURRENCY;
    +        }
         }
     
         return tesSUCCESS;
    diff --git a/src/test/app/NFToken_test.cpp b/src/test/app/NFToken_test.cpp
    index 7fcd34640b..08c12e94d1 100644
    --- a/src/test/app/NFToken_test.cpp
    +++ b/src/test/app/NFToken_test.cpp
    @@ -36,6 +36,7 @@
     #include 
     #include 
     #include 
    +#include 
     #include 
     #include 
     
    @@ -7355,6 +7356,127 @@ class NFTokenBaseUtil_test : public beast::unit_test::Suite
             }
         }
     
    +    void
    +    testCreateOfferInvalidAmount(FeatureBitset features)
    +    {
    +        testcase("Invalid NFT offer create amount");
    +
    +        using namespace test::jtx;
    +
    +        // Before fixCleanup3_4_0, a fake-XRP offer amount (an IOU using the
    +        // "XRP" currency code) is not rejected in preflight. With the amendment
    +        // enabled, preflight rejects it with temBAD_CURRENCY.
    +        for (bool const withFix : {false, true})
    +        {
    +            Env env{*this, withFix ? features | fixCleanup3_4_0 : features - fixCleanup3_4_0};
    +
    +            Account const alice{"alice"};
    +            Account const gw{"gw"};
    +
    +            env.fund(XRP(1000), alice, gw);
    +            env.close();
    +
    +            uint256 const nftID = token::getNextID(env, alice, 0, tfTransferable);
    +            env(token::mint(alice, 0u), Txflags(tfTransferable));
    +            env.close();
    +
    +            // Fake XRP (an IOU using the "XRP" currency code) sell offer
    +            // amount.
    +            auto const bad = IOU(gw, badCurrency());
    +            env(token::createOffer(alice, nftID, bad(1)),
    +                Txflags(tfSellNFToken),
    +                Ter(withFix ? TER{temBAD_CURRENCY} : TER{tesSUCCESS}));
    +            env.close();
    +        }
    +    }
    +
    +    void
    +    testAcceptOfferInvalidBrokerFee(FeatureBitset features)
    +    {
    +        testcase("Invalid NFT offer accept broker fee");
    +
    +        using namespace test::jtx;
    +
    +        // Before fixCleanup3_4_0, a fake-XRP broker fee (an IOU using the "XRP"
    +        // currency code) is not rejected in preflight and reaches later offer
    +        // validation instead. With the amendment enabled, preflight rejects it
    +        // with temBAD_CURRENCY.
    +        for (bool const withFix : {false, true})
    +        {
    +            Env env{*this, withFix ? features | fixCleanup3_4_0 : features - fixCleanup3_4_0};
    +
    +            Account const alice{"alice"};
    +            Account const buyer{"buyer"};
    +            Account const broker{"broker"};
    +            Account const gw{"gw"};
    +
    +            env.fund(XRP(1000), alice, buyer, broker, gw);
    +            env.close();
    +
    +            uint256 const nftID = token::getNextID(env, alice, 0, tfTransferable);
    +            env(token::mint(alice, 0u), Txflags(tfTransferable));
    +            env.close();
    +
    +            uint256 const sellOfferIndex =
    +                keylet::nftokenOffer(alice, SeqProxy::rawSequence(env.seq(alice))).key;
    +            env(token::createOffer(alice, nftID, XRP(10)), Txflags(tfSellNFToken));
    +            env.close();
    +
    +            uint256 const buyOfferIndex =
    +                keylet::nftokenOffer(buyer, SeqProxy::rawSequence(env.seq(buyer))).key;
    +            env(token::createOffer(buyer, nftID, XRP(40)), token::Owner(alice));
    +            env.close();
    +
    +            // Fake XRP (an IOU using the "XRP" currency code) broker fee.
    +            auto const bad = IOU(gw, badCurrency());
    +            env(token::brokerOffers(broker, buyOfferIndex, sellOfferIndex),
    +                token::BrokerFee(bad(1)),
    +                Ter(withFix ? TER{temBAD_CURRENCY} : TER{tecNFTOKEN_BUY_SELL_MISMATCH}));
    +            env.close();
    +        }
    +    }
    +
    +    void
    +    testCreateOfferIouIssuerGlobalFreeze(FeatureBitset features)
    +    {
    +        testcase("Create NFT offer by IOU issuer under global freeze");
    +
    +        using namespace test::jtx;
    +
    +        // Before fixCleanup3_4_0, an IOU issuer that has set a global freeze on
    +        // their own currency cannot create an NFToken offer denominated in that
    +        // currency; the offer is rejected with tecFROZEN.  With the amendment
    +        // enabled, the issuer is not subject to their own global freeze when the
    +        // offer is denominated in their own IOU (e.g. to receive their own
    +        // transfer fees), so the offer succeeds.
    +        for (bool const withFix : {false, true})
    +        {
    +            Env env{*this, withFix ? features | fixCleanup3_4_0 : features - fixCleanup3_4_0};
    +
    +            Account const issuer{"issuer"};
    +            IOU const isISU(issuer["ISU"]);
    +
    +            env.fund(XRP(1000), issuer);
    +            env.close();
    +
    +            // issuer mints a transferable NFToken.
    +            uint256 const nftID = token::getNextID(env, issuer, 0, tfTransferable);
    +            env(token::mint(issuer, 0u), Txflags(tfTransferable));
    +            env.close();
    +
    +            // issuer sets a global freeze on their own IOU.
    +            env(fset(issuer, asfGlobalFreeze));
    +            env.close();
    +
    +            // issuer creates a sell offer for the NFToken denominated in their
    +            // own (globally frozen) IOU.
    +            env(token::createOffer(issuer, nftID, isISU(100)),
    +                Txflags(tfSellNFToken),
    +                Ter(withFix ? TER{tesSUCCESS} : TER{tecFROZEN}));
    +            env.close();
    +        }
    +    }
    +
     protected:
         FeatureBitset const allFeatures_{test::jtx::testableAmendments()};
     
    @@ -7397,6 +7519,9 @@ protected:
             testUnaskedForAutoTrustline(features);
             testNFTIssuerIsIOUIssuer(features);
             testNFTokenModify(features);
    +        testCreateOfferInvalidAmount(features);
    +        testAcceptOfferInvalidBrokerFee(features);
    +        testCreateOfferIouIssuerGlobalFreeze(features);
         }
     
     public:
    diff --git a/src/test/app/PermissionedDEX_test.cpp b/src/test/app/PermissionedDEX_test.cpp
    index a7e4cd7615..ddb56a1480 100644
    --- a/src/test/app/PermissionedDEX_test.cpp
    +++ b/src/test/app/PermissionedDEX_test.cpp
    @@ -2008,6 +2008,143 @@ class PermissionedDEX_test : public beast::unit_test::Suite
             }
         }
     
    +    void
    +    testDomainOfferInWrongBook(FeatureBitset features)
    +    {
    +        bool const fixEnabled = features[fixCleanup3_4_0];
    +
    +        testcase << "Domain offer indexed in the wrong domain book"
    +                 << (fixEnabled ? " (fixCleanup3_4_0 enabled)" : " (fixCleanup3_4_0 disabled)");
    +
    +        // Bob (a member of domains A and B) places an offer in domain A's
    +        // book, which we then corrupt to claim domain B while it stays in
    +        // domain A's book. A payment routed through domain A meets this offer.
    +        //
    +        // - With fixCleanup3_4_0: OfferStream sees the offer's domain (B)
    +        //   mismatch the book (A) and errors out -> tecPATH_PARTIAL.
    +        // - Without it: OfferStream only checks the offer's own domain (B,
    +        //   which Bob is in), so it is used; the invariant then catches the
    +        //   mismatch -> tecINVARIANT_FAILED.
    +        //
    +        // Either way the payment fails and the offer is left untouched.
    +
    +        Env env(*this, features);
    +        auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
    +            PermissionedDEX(env);
    +
    +        // A second domain that Bob also belongs to.
    +        Account const bobAcct = bob;
    +        auto const domainID2 =
    +            setupDomain(env, {bobAcct}, Account("permdex-domainOwner2"), "permdex-cred2");
    +
    +        // Bob places a domain offer in domain A's book.
    +        auto const bobOfferSeq{env.seq(bob)};
    +        env(offer(bob, XRP(10), USD(10)), Domain(domainID));
    +        env.close();
    +        BEAST_EXPECT(checkOffer(env, bob, bobOfferSeq, XRP(10), USD(10), 0, true));
    +
    +        // Corrupt the offer: point its sfDomainID at domain B while it stays
    +        // indexed in domain A's book directory.
    +        auto const offerKey = keylet::offer(bob.id(), SeqProxy::rawSequence(bobOfferSeq));
    +        env.app().getOpenLedger().modify([&offerKey, &domainID2](OpenView& view, beast::Journal) {
    +            auto const sle = view.read(offerKey);
    +            if (!sle)
    +                return false;
    +            auto replacement = std::make_shared(*sle, sle->key());
    +            replacement->setFieldH256(sfDomainID, domainID2);
    +            view.rawReplace(replacement);
    +            return true;
    +        });
    +
    +        if (fixEnabled)
    +        {
    +            // With the fix: OfferStream rejects the mismatched offer.
    +            env(pay(alice, carol, USD(10)),
    +                Path(~USD),
    +                Sendmax(XRP(10)),
    +                Domain(domainID),
    +                Ter(tecPATH_PARTIAL));
    +            BEAST_EXPECT(offerExists(env, bob, bobOfferSeq));
    +        }
    +        else
    +        {
    +            // Without the fix: the offer is used, then the invariant
    +            // rejects the whole transaction.
    +            env(pay(alice, carol, USD(10)),
    +                Path(~USD),
    +                Sendmax(XRP(10)),
    +                Domain(domainID),
    +                Ter(tecINVARIANT_FAILED));
    +            BEAST_EXPECT(offerExists(env, bob, bobOfferSeq));
    +        }
    +    }
    +
    +    void
    +    testDomainBookOfferMissingDomain(FeatureBitset features)
    +    {
    +        bool const fixEnabled = features[fixCleanup3_4_0];
    +
    +        testcase << "Offer without a domain indexed in a domain book"
    +                 << (fixEnabled ? " (fixCleanup3_4_0 enabled)" : " (fixCleanup3_4_0 disabled)");
    +
    +        // Same corruption as testDomainOfferInWrongBook, except the offer
    +        // loses sfDomainID entirely instead of pointing at another domain
    +        // while it stays indexed in domain A's book.
    +        //
    +        // - With fixCleanup3_4_0: OfferStream sees an offer that claims no
    +        //   domain in a domain book and errors out -> tecPATH_PARTIAL.
    +        // - Without it: neither the domain mismatch check nor the domain
    +        //   membership check fires (both are gated on sfDomainID being
    +        //   present), and the invariant does not catch it either because the
    +        //   offer is fully consumed and deleted. The payment succeeds using an
    +        //   offer that was never credential checked.
    +
    +        Env env(*this, features);
    +        auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] =
    +            PermissionedDEX(env);
    +
    +        // Bob places a domain offer in domain A's book.
    +        auto const bobOfferSeq{env.seq(bob)};
    +        env(offer(bob, XRP(10), USD(10)), Domain(domainID));
    +        env.close();
    +        BEAST_EXPECT(checkOffer(env, bob, bobOfferSeq, XRP(10), USD(10), 0, true));
    +
    +        // Corrupt the offer: drop sfDomainID while it stays indexed in domain
    +        // A's book directory.
    +        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)
    +                return false;
    +            auto replacement = std::make_shared(*sle, sle->key());
    +            replacement->makeFieldAbsent(sfDomainID);
    +            view.rawReplace(replacement);
    +            return true;
    +        });
    +
    +        auto const carolBefore = env.balance(carol, USD);
    +
    +        if (fixEnabled)
    +        {
    +            // With the fix: OfferStream rejects the domainless offer.
    +            env(pay(alice, carol, USD(10)),
    +                Path(~USD),
    +                Sendmax(XRP(10)),
    +                Domain(domainID),
    +                Ter(tecPATH_PARTIAL));
    +            BEAST_EXPECT(offerExists(env, bob, bobOfferSeq));
    +            BEAST_EXPECT(env.balance(carol, USD) - carolBefore == USD(0));
    +        }
    +        else
    +        {
    +            // Without the fix: the offer is silently usable in the domain
    +            // book, and the payment goes through.
    +            env(pay(alice, carol, USD(10)), Path(~USD), Sendmax(XRP(10)), Domain(domainID));
    +            BEAST_EXPECT(!offerExists(env, bob, bobOfferSeq));
    +            BEAST_EXPECT(env.balance(carol, USD) - carolBefore == USD(10));
    +        }
    +    }
    +
         void
         testReplaceDomainOfferWithOtherDomainOffer(FeatureBitset features)
         {
    @@ -2100,6 +2237,14 @@ public:
             // only after fixCleanup3_2_0.
             testCancelRegularOfferWithDomainCreate(all);
             testCancelRegularOfferWithDomainCreate(all - fixCleanup3_2_0);
    +
    +        // A domain offer indexed in the wrong domain book is caught only
    +        // after fixCleanup3_4_0. (Not an existing bug, but defensive testing)
    +        testDomainOfferInWrongBook(all);
    +        testDomainOfferInWrongBook(all - fixCleanup3_4_0);
    +        testDomainBookOfferMissingDomain(all);
    +        testDomainBookOfferMissingDomain(all - fixCleanup3_4_0);
    +
             testReplaceDomainOfferWithOtherDomainOffer(all);
             testReplaceDomainOfferWithOtherDomainOffer(all - fixCleanup3_4_0);
         }
    
    From 8c12de6c5624017e866ba78bf41cf3cfc5a722ab Mon Sep 17 00:00:00 2001
    From: Vito Tumas <5780819+Tapanito@users.noreply.github.com>
    Date: Tue, 18 Aug 2026 17:57:12 +0000
    Subject: [PATCH 091/102] test: Split Vault_test into topical suites under
     src/test/app/vault/ (#8041)
    
    ---
     src/test/app/Vault_test.cpp                   | 9736 -----------------
     src/test/app/lending/LendingHelpers_test.cpp  |    2 +-
     src/test/app/lending/LoanTestBase.h           |   10 +
     src/test/app/lending/Loan_test.cpp            |   46 -
     src/test/app/vault/VaultBugs_test.cpp         |  716 ++
     src/test/app/vault/VaultClawback_test.cpp     | 1122 ++
     src/test/app/vault/VaultClosedEnded_test.cpp  | 1008 ++
     src/test/app/vault/VaultDomain_test.cpp       |  586 +
     src/test/app/vault/VaultFreeze_test.cpp       |  691 ++
     src/test/app/vault/VaultLifecycle_test.cpp    | 1776 +++
     src/test/app/vault/VaultRPC_test.cpp          |  543 +
     src/test/app/vault/VaultScale_test.cpp        | 1228 +++
     src/test/app/vault/VaultShares_test.cpp       |  736 ++
     .../app/vault/VaultSoleShareholder_test.cpp   |  655 ++
     src/test/app/vault/VaultTestBase.h            |  120 +
     src/test/app/vault/VaultValidation_test.cpp   | 1086 ++
     16 files changed, 10278 insertions(+), 9783 deletions(-)
     delete mode 100644 src/test/app/Vault_test.cpp
     delete mode 100644 src/test/app/lending/Loan_test.cpp
     create mode 100644 src/test/app/vault/VaultBugs_test.cpp
     create mode 100644 src/test/app/vault/VaultClawback_test.cpp
     create mode 100644 src/test/app/vault/VaultClosedEnded_test.cpp
     create mode 100644 src/test/app/vault/VaultDomain_test.cpp
     create mode 100644 src/test/app/vault/VaultFreeze_test.cpp
     create mode 100644 src/test/app/vault/VaultLifecycle_test.cpp
     create mode 100644 src/test/app/vault/VaultRPC_test.cpp
     create mode 100644 src/test/app/vault/VaultScale_test.cpp
     create mode 100644 src/test/app/vault/VaultShares_test.cpp
     create mode 100644 src/test/app/vault/VaultSoleShareholder_test.cpp
     create mode 100644 src/test/app/vault/VaultTestBase.h
     create mode 100644 src/test/app/vault/VaultValidation_test.cpp
    
    diff --git a/src/test/app/Vault_test.cpp b/src/test/app/Vault_test.cpp
    deleted file mode 100644
    index 34ac40fb54..0000000000
    --- a/src/test/app/Vault_test.cpp
    +++ /dev/null
    @@ -1,9736 +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 
    -
    -namespace xrpl {
    -
    -class Vault_test : public beast::unit_test::Suite
    -{
    -    using PrettyAsset = xrpl::test::jtx::PrettyAsset;
    -    using PrettyAmount = xrpl::test::jtx::PrettyAmount;
    -
    -    static constexpr auto kNegativeAmount = [](PrettyAsset const& asset) -> PrettyAmount {
    -        return {STAmount{asset.raw(), 1ul, 0, true, STAmount::Unchecked{}}, ""};
    -    };
    -
    -    /**
    -     * Get the current ledger's close time resolution.
    -     * @param env The test environment.
    -     */
    -    static NetClock::duration
    -    getLedgerTimeResolution(test::jtx::Env& env)
    -    {
    -        return env.current()->header().closeTimeResolution;
    -    }
    -
    -    void
    -    closeToTime(
    -        test::jtx::Env& env,
    -        NetClock::time_point time,
    -        std::source_location const& loc = std::source_location::current())
    -    {
    -        using namespace std::chrono_literals;
    -        env.close(time - env.closed()->header().closeTimeResolution + 1s);
    -        expect(
    -            env.closed()->header().closeTime == time,
    -            std::format(
    -                "current ledger time {} is not equal to the target ledger time {}",
    -                env.closed()->header().closeTime.time_since_epoch(),
    -                time.time_since_epoch()),
    -            loc.file_name(),
    -            loc.line());
    -    }
    -
    -    using d = NetClock::duration;
    -    using tp = NetClock::time_point;
    -
    -    // Vault holds an Env& so no default initializer is possible; the
    -    // struct is always aggregate-initialized by makeClosedEndedVault.
    -    // NOLINTBEGIN(cppcoreguidelines-pro-type-member-init)
    -    struct ClosedEndedSetup
    -    {
    -        test::jtx::Vault vault;
    -        Keylet keylet;
    -        std::uint32_t sub = 0;
    -        std::uint32_t red = 0;
    -    };
    -    // NOLINTEND(cppcoreguidelines-pro-type-member-init)
    -
    -    // Submit a VaultCreate for a closed-ended vault with SubscriptionDate at
    -    // env.now() + subOffset and RedemptionDate at SubscriptionDate + gap, then
    -    // close the ledger. Returns the Vault helper, the vault's keylet and the
    -    // resolved sub/red timestamps.
    -    static ClosedEndedSetup
    -    makeClosedEndedVault(
    -        test::jtx::Env& env,
    -        test::jtx::Account const& owner,
    -        Asset const& asset,
    -        std::uint32_t subOffset,
    -        std::uint32_t gap)
    -    {
    -        auto const sub = env.now().time_since_epoch().count() + subOffset;
    -        auto const red = sub + gap;
    -        test::jtx::Vault const vault{env};
    -        auto [tx, keylet] = vault.create(
    -            {.owner = owner,
    -             .asset = asset,
    -             .vaultKind = std::to_underlying(VaultKind::ClosedEnded),
    -             .subscriptionDate = sub,
    -             .redemptionDate = red});
    -        env(tx);
    -        env.close();
    -        return {.vault = vault, .keylet = keylet, .sub = sub, .red = red};
    -    }
    -
    -    void
    -    testSequences()
    -    {
    -        using namespace test::jtx;
    -        Account const issuer{"issuer"};
    -        Account const owner{"owner"};
    -        Account const depositor{"depositor"};
    -        Account const charlie{"charlie"};  // authorized 3rd party
    -        Account const dave{"dave"};
    -
    -        auto const testSequence = [&, this](
    -                                      std::string const& prefix,
    -                                      Env& env,
    -                                      Vault& vault,
    -                                      PrettyAsset const& asset) {
    -            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -            tx[sfData] = "AFEED00E";
    -            tx[sfAssetsMaximum] = asset(100).number();
    -            env(tx);
    -            env.close();
    -            BEAST_EXPECT(env.le(keylet));
    -            std::uint64_t const scale = asset.raw().holds() ? 1 : 1e6;
    -
    -            auto const [share, vaultAccount] =
    -                [&env, keylet = keylet, asset, this]() -> std::tuple {
    -                auto const vault = env.le(keylet);
    -                BEAST_EXPECT(vault != nullptr);
    -                if (!asset.integral())
    -                {
    -                    BEAST_EXPECT(vault->at(sfScale) == 6);
    -                }
    -                else
    -                {
    -                    BEAST_EXPECT(vault->at(sfScale) == 0);
    -                }
    -                auto const shares = env.le(keylet::mptokenIssuance(vault->at(sfShareMPTID)));
    -                BEAST_EXPECT(shares != nullptr);
    -                if (!asset.integral())
    -                {
    -                    BEAST_EXPECT(shares->at(sfAssetScale) == 6);
    -                }
    -                else
    -                {
    -                    BEAST_EXPECT(shares->at(sfAssetScale) == 0);
    -                }
    -                return {MPTIssue(vault->at(sfShareMPTID)), Account("vault", vault->at(sfAccount))};
    -            }();
    -            auto const shares = share.raw().get();
    -            env.memoize(vaultAccount);
    -
    -            // Several 3rd party accounts which cannot receive funds
    -            Account const alice{"alice"};
    -            Account const erin{"erin"};  // not authorized by issuer
    -            env.fund(XRP(1000), alice, erin);
    -            env(fset(alice, asfDepositAuth));
    -            env.close();
    -
    -            {
    -                testcase(prefix + " fail to deposit more than assets held");
    -                auto tx = vault.deposit(
    -                    {.depositor = depositor, .id = keylet.key, .amount = asset(10000)});
    -                env(tx, Ter(tecINSUFFICIENT_FUNDS));
    -                env.close();
    -            }
    -
    -            {
    -                testcase(prefix + " deposit non-zero amount");
    -                auto tx =
    -                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
    -                env(tx);
    -                env.close();
    -                BEAST_EXPECT(env.balance(depositor, shares) == share(50 * scale));
    -            }
    -
    -            {
    -                testcase(prefix + " deposit non-zero amount again");
    -                auto tx =
    -                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
    -                env(tx);
    -                env.close();
    -                BEAST_EXPECT(env.balance(depositor, shares) == share(100 * scale));
    -            }
    -
    -            {
    -                testcase(prefix + " fail to delete non-empty vault");
    -                auto tx = vault.del({.owner = owner, .id = keylet.key});
    -                env(tx, Ter(tecHAS_OBLIGATIONS));
    -                env.close();
    -            }
    -
    -            {
    -                testcase(prefix + " fail to update because wrong owner");
    -                auto tx = vault.set({.owner = issuer, .id = keylet.key});
    -                tx[sfAssetsMaximum] = asset(50).number();
    -                env(tx, Ter(tecNO_PERMISSION));
    -                env.close();
    -            }
    -
    -            {
    -                testcase(prefix + " fail to set maximum lower than current amount");
    -                auto tx = vault.set({.owner = owner, .id = keylet.key});
    -                tx[sfAssetsMaximum] = asset(50).number();
    -                env(tx, Ter(tecLIMIT_EXCEEDED));
    -                env.close();
    -            }
    -
    -            {
    -                testcase(prefix + " set maximum higher than current amount");
    -                auto tx = vault.set({.owner = owner, .id = keylet.key});
    -                tx[sfAssetsMaximum] = asset(150).number();
    -                env(tx);
    -                env.close();
    -            }
    -
    -            {
    -                testcase(prefix + " set maximum is idempotent, set it again");
    -                auto tx = vault.set({.owner = owner, .id = keylet.key});
    -                tx[sfAssetsMaximum] = asset(150).number();
    -                env(tx);
    -                env.close();
    -            }
    -
    -            {
    -                testcase(prefix + " set data");
    -                auto tx = vault.set({.owner = owner, .id = keylet.key});
    -                tx[sfData] = "0";
    -                env(tx);
    -                env.close();
    -            }
    -
    -            {
    -                testcase(prefix + " fail to set domain on public vault");
    -                auto tx = vault.set({.owner = owner, .id = keylet.key});
    -                tx[sfDomainID] = to_string(BaseUInt<256>(42ul));
    -                env(tx, Ter{tecNO_PERMISSION});
    -                env.close();
    -            }
    -
    -            {
    -                testcase(prefix + " fail to deposit more than maximum");
    -                auto tx =
    -                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)});
    -                env(tx, Ter(tecLIMIT_EXCEEDED));
    -                env.close();
    -            }
    -
    -            {
    -                testcase(prefix + " reset maximum to zero i.e. not enforced");
    -                auto tx = vault.set({.owner = owner, .id = keylet.key});
    -                tx[sfAssetsMaximum] = asset(0).number();
    -                env(tx);
    -                env.close();
    -            }
    -
    -            {
    -                testcase(prefix + " fail to withdraw more than assets held");
    -                auto tx = vault.withdraw(
    -                    {.depositor = depositor, .id = keylet.key, .amount = asset(1000)});
    -                env(tx, Ter(tecINSUFFICIENT_FUNDS));
    -                env.close();
    -            }
    -
    -            {
    -                testcase(prefix + " deposit some more");
    -                auto tx =
    -                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)});
    -                env(tx);
    -                env.close();
    -                BEAST_EXPECT(env.balance(depositor, shares) == share(200 * scale));
    -            }
    -
    -            {
    -                testcase(prefix + " clawback some");
    -                auto code = asset.raw().native() ? Ter(temMALFORMED) : Ter(tesSUCCESS);
    -                auto tx = vault.clawback(
    -                    {.issuer = issuer, .id = keylet.key, .holder = depositor, .amount = asset(10)});
    -                env(tx, code);
    -                env.close();
    -                if (!asset.raw().native())
    -                {
    -                    BEAST_EXPECT(env.balance(depositor, shares) == share(190 * scale));
    -                }
    -            }
    -
    -            {
    -                testcase(prefix + " clawback all");
    -                auto code = asset.raw().native() ? Ter(tecNO_PERMISSION) : Ter(tesSUCCESS);
    -                auto tx = vault.clawback({.issuer = issuer, .id = keylet.key, .holder = depositor});
    -                env(tx, code);
    -                env.close();
    -                if (!asset.raw().native())
    -                {
    -                    BEAST_EXPECT(env.balance(depositor, shares) == share(0));
    -
    -                    {
    -                        auto tx = vault.clawback(
    -                            {.issuer = issuer,
    -                             .id = keylet.key,
    -                             .holder = depositor,
    -                             .amount = asset(10)});
    -                        env(tx, Ter{tecPRECISION_LOSS});
    -                        env.close();
    -                    }
    -
    -                    {
    -                        auto tx = vault.withdraw(
    -                            {.depositor = depositor, .id = keylet.key, .amount = asset(10)});
    -                        env(tx, Ter{tecPRECISION_LOSS});
    -                        env.close();
    -                    }
    -                }
    -            }
    -
    -            if (!asset.raw().native())
    -            {
    -                testcase(prefix + " deposit again");
    -                auto tx =
    -                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(200)});
    -                env(tx);
    -                env.close();
    -                BEAST_EXPECT(env.balance(depositor, shares) == share(200 * scale));
    -            }
    -            else
    -            {
    -                testcase(prefix + " deposit/withdrawal same or less than fee");
    -                auto const amount = env.current()->fees().base;
    -
    -                auto tx =
    -                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = amount});
    -                env(tx);
    -                env.close();
    -
    -                tx = vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = amount});
    -                env(tx);
    -                env.close();
    -
    -                tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = amount});
    -                env(tx);
    -                env.close();
    -
    -                // Withdraw to 3rd party
    -                tx = vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = amount});
    -                tx[sfDestination] = charlie.human();
    -                env(tx);
    -                env.close();
    -
    -                tx =
    -                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = amount - 1});
    -                env(tx);
    -                env.close();
    -
    -                tx = vault.withdraw(
    -                    {.depositor = depositor, .id = keylet.key, .amount = amount - 1});
    -                env(tx);
    -                env.close();
    -            }
    -
    -            {
    -                testcase(prefix + " fail to withdraw to 3rd party lsfDepositAuth");
    -                auto tx = vault.withdraw(
    -                    {.depositor = depositor, .id = keylet.key, .amount = asset(100)});
    -                tx[sfDestination] = alice.human();
    -                env(tx, Ter{tecNO_PERMISSION});
    -                env.close();
    -            }
    -
    -            {
    -                testcase(prefix + " fail to withdraw to zero destination");
    -                auto tx = vault.withdraw(
    -                    {.depositor = depositor, .id = keylet.key, .amount = asset(1000)});
    -                tx[sfDestination] = "0";
    -                env(tx, Ter(temMALFORMED));
    -                env.close();
    -            }
    -
    -            if (!asset.raw().native())
    -            {
    -                testcase(prefix + " fail to withdraw to 3rd party no authorization");
    -                auto tx = vault.withdraw(
    -                    {.depositor = depositor, .id = keylet.key, .amount = asset(100)});
    -                tx[sfDestination] = erin.human();
    -                env(tx, Ter{asset.raw().holds() ? tecNO_LINE : tecNO_AUTH});
    -                env.close();
    -            }
    -
    -            {
    -                testcase(prefix + " fail to withdraw to 3rd party lsfRequireDestTag");
    -                auto tx = vault.withdraw(
    -                    {.depositor = depositor, .id = keylet.key, .amount = asset(100)});
    -                tx[sfDestination] = dave.human();
    -                env(tx, Ter{tecDST_TAG_NEEDED});
    -                env.close();
    -            }
    -
    -            {
    -                testcase(prefix + " withdraw to 3rd party lsfRequireDestTag");
    -                auto tx =
    -                    vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
    -                tx[sfDestination] = dave.human();
    -                tx[sfDestinationTag] = "0";
    -                env(tx);
    -                env.close();
    -            }
    -
    -            {
    -                testcase(prefix + " deposit again");
    -                auto tx = vault.deposit({.depositor = dave, .id = keylet.key, .amount = asset(50)});
    -                env(tx);
    -                env.close();
    -            }
    -
    -            {
    -                testcase(prefix + " fail to withdraw lsfRequireDestTag");
    -                auto tx =
    -                    vault.withdraw({.depositor = dave, .id = keylet.key, .amount = asset(50)});
    -                env(tx, Ter{tecDST_TAG_NEEDED});
    -                env.close();
    -            }
    -
    -            {
    -                testcase(prefix + " withdraw with tag");
    -                auto tx =
    -                    vault.withdraw({.depositor = dave, .id = keylet.key, .amount = asset(50)});
    -                tx[sfDestinationTag] = "0";
    -                env(tx);
    -                env.close();
    -            }
    -
    -            {
    -                testcase(prefix + " withdraw to authorized 3rd party");
    -                auto tx =
    -                    vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
    -                tx[sfDestination] = charlie.human();
    -                env(tx);
    -                env.close();
    -                BEAST_EXPECT(env.balance(depositor, shares) == share(100 * scale));
    -            }
    -
    -            {
    -                testcase(prefix + " withdraw to issuer");
    -                auto tx =
    -                    vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
    -                tx[sfDestination] = issuer.human();
    -                env(tx);
    -                env.close();
    -                BEAST_EXPECT(env.balance(depositor, shares) == share(50 * scale));
    -            }
    -
    -            if (!asset.raw().native())
    -            {
    -                testcase(prefix + " issuer deposits");
    -                auto tx =
    -                    vault.deposit({.depositor = issuer, .id = keylet.key, .amount = asset(10)});
    -                env(tx);
    -                env.close();
    -                BEAST_EXPECT(env.balance(issuer, shares) == share(10 * scale));
    -
    -                testcase(prefix + " issuer withdraws");
    -                tx = vault.withdraw(
    -                    {.depositor = issuer, .id = keylet.key, .amount = share(10 * scale)});
    -                env(tx);
    -                env.close();
    -                BEAST_EXPECT(env.balance(issuer, shares) == share(0 * scale));
    -            }
    -
    -            {
    -                testcase(prefix + " withdraw remaining assets");
    -                auto tx =
    -                    vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
    -                env(tx);
    -                env.close();
    -                BEAST_EXPECT(env.balance(depositor, shares) == share(0));
    -
    -                if (!asset.raw().native())
    -                {
    -                    auto tx = vault.clawback(
    -                        {.issuer = issuer,
    -                         .id = keylet.key,
    -                         .holder = depositor,
    -                         .amount = asset(0)});
    -                    env(tx, Ter{tecPRECISION_LOSS});
    -                    env.close();
    -                }
    -
    -                {
    -                    auto tx = vault.withdraw(
    -                        {.depositor = depositor, .id = keylet.key, .amount = share(10)});
    -                    env(tx, Ter{tecINSUFFICIENT_FUNDS});
    -                    env.close();
    -                }
    -            }
    -
    -            if (!asset.integral())
    -            {
    -                testcase(prefix + " temporary authorization for 3rd party");
    -                env(trust(erin, asset(1000)));
    -                env(trust(issuer, asset(0), erin, tfSetfAuth));
    -                env(pay(issuer, erin, asset(10)));
    -
    -                // Erin deposits all in vault, then sends shares to depositor
    -                auto tx = vault.deposit({.depositor = erin, .id = keylet.key, .amount = asset(10)});
    -                env(tx);
    -                env.close();
    -                {
    -                    auto tx = pay(erin, depositor, share(10 * scale));
    -
    -                    // depositor no longer has MPToken for shares
    -                    env(tx, Ter{tecNO_AUTH});
    -                    env.close();
    -
    -                    // depositor will gain MPToken for shares again
    -                    env(vault.deposit(
    -                        {.depositor = depositor, .id = keylet.key, .amount = asset(1)}));
    -                    env.close();
    -
    -                    env(tx);
    -                    env.close();
    -                }
    -
    -                testcase(prefix + " withdraw to authorized 3rd party");
    -                // Depositor withdraws assets, destined to Erin
    -                tx =
    -                    vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(10)});
    -                tx[sfDestination] = erin.human();
    -                env(tx);
    -                env.close();
    -
    -                // Erin returns assets to issuer
    -                env(pay(erin, issuer, asset(10)));
    -                env.close();
    -
    -                testcase(prefix + " fail to pay to unauthorized 3rd party");
    -                env(trust(erin, asset(0)));
    -                env.close();
    -
    -                // Erin has MPToken but is no longer authorized to hold assets
    -                env(pay(depositor, erin, share(1)), Ter{tecNO_LINE});
    -                env.close();
    -
    -                // Depositor withdraws remaining single asset
    -                tx = vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(1)});
    -                env(tx);
    -                env.close();
    -            }
    -
    -            {
    -                testcase(prefix + " fail to delete because wrong owner");
    -                auto tx = vault.del({.owner = issuer, .id = keylet.key});
    -                env(tx, Ter(tecNO_PERMISSION));
    -                env.close();
    -            }
    -
    -            {
    -                testcase(prefix + " delete empty vault");
    -                auto tx = vault.del({.owner = owner, .id = keylet.key});
    -                env(tx);
    -                env.close();
    -                BEAST_EXPECT(!env.le(keylet));
    -            }
    -        };
    -
    -        auto testCases = [&, this](
    -                             std::string prefix, std::function setup) {
    -            Env env{*this, testableAmendments()};
    -
    -            Vault vault{env};
    -            env.fund(XRP(1000), issuer, owner, depositor, charlie, dave);
    -            env.close();
    -            env(fset(issuer, asfAllowTrustLineClawback));
    -            env(fset(issuer, asfRequireAuth));
    -            env(fset(dave, asfRequireDest));
    -            env.close();
    -            env.require(Flags(issuer, asfAllowTrustLineClawback));
    -            env.require(Flags(issuer, asfRequireAuth));
    -
    -            PrettyAsset const asset = setup(env);
    -            testSequence(prefix, env, vault, asset);
    -        };
    -
    -        testCases("XRP", [&](Env& env) -> PrettyAsset { return {xrpIssue(), 1'000'000}; });
    -
    -        testCases("IOU", [&](Env& env) -> Asset {
    -            PrettyAsset const asset = issuer["IOU"];
    -            env(trust(owner, asset(1000)));
    -            env(trust(depositor, asset(1000)));
    -            env(trust(charlie, asset(1000)));
    -            env(trust(dave, asset(1000)));
    -            env(trust(issuer, asset(0), owner, tfSetfAuth));
    -            env(trust(issuer, asset(0), depositor, tfSetfAuth));
    -            env(trust(issuer, asset(0), charlie, tfSetfAuth));
    -            env(trust(issuer, asset(0), dave, tfSetfAuth));
    -            env(pay(issuer, depositor, asset(1000)));
    -            env.close();
    -            return asset;
    -        });
    -
    -        testCases("MPT", [&](Env& env) -> Asset {
    -            MPTTester mptt{env, issuer, kMptInitNoFund};
    -            mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
    -            PrettyAsset const asset = mptt.issuanceID();
    -            mptt.authorize({.account = depositor});
    -            mptt.authorize({.account = charlie});
    -            mptt.authorize({.account = dave});
    -            env(pay(issuer, depositor, asset(1000)));
    -            env.close();
    -            return asset;
    -        });
    -    }
    -
    -    void
    -    testPreflight()
    -    {
    -        using namespace test::jtx;
    -
    -        struct CaseArgs
    -        {
    -            FeatureBitset features = testableAmendments();
    -        };
    -
    -        auto testCase = [&, this](
    -                            std::function test,
    -                            CaseArgs args = {}) {
    -            Env env{*this, args.features};
    -            Account const issuer{"issuer"};
    -            Account const owner{"owner"};
    -            Vault vault{env};
    -            env.fund(XRP(1000), issuer, owner);
    -            env.close();
    -
    -            env(fset(issuer, asfAllowTrustLineClawback));
    -            env(fset(issuer, asfRequireAuth));
    -            env.close();
    -
    -            PrettyAsset const asset = issuer["IOU"];
    -            env(trust(owner, asset(1000)));
    -            env(trust(issuer, asset(0), owner, tfSetfAuth));
    -            env(pay(issuer, owner, asset(1000)));
    -            env.close();
    -
    -            test(env, issuer, owner, asset, vault);
    -        };
    -
    -        auto testDisabled = [&](TER resultAfterCreate = temDISABLED) {
    -            return [&, resultAfterCreate](
    -                       Env& env,
    -                       Account const& issuer,
    -                       Account const& owner,
    -                       Asset const& asset,
    -                       Vault& vault) {
    -                testcase("disabled single asset vault");
    -
    -                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -                env(tx, Ter{temDISABLED});
    -
    -                {
    -                    auto tx = vault.set({.owner = owner, .id = keylet.key});
    -                    env(tx, kData("test"), Ter{resultAfterCreate});
    -                }
    -
    -                {
    -                    auto tx =
    -                        vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(10)});
    -                    env(tx, Ter{resultAfterCreate});
    -                }
    -
    -                {
    -                    auto tx =
    -                        vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)});
    -                    env(tx, Ter{resultAfterCreate});
    -                }
    -
    -                {
    -                    auto tx = vault.clawback(
    -                        {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(10)});
    -                    env(tx, Ter{resultAfterCreate});
    -                }
    -
    -                {
    -                    auto tx = vault.del({.owner = owner, .id = keylet.key});
    -                    env(tx, Ter{resultAfterCreate});
    -                }
    -            };
    -        };
    -
    -        testCase(testDisabled(), {.features = testableAmendments() - featureSingleAssetVault});
    -
    -        testCase(testDisabled(tecNO_ENTRY), {.features = testableAmendments() - featureMPTokensV1});
    -
    -        testCase(
    -            [&](Env& env,
    -                Account const& issuer,
    -                Account const& owner,
    -                Asset const& asset,
    -                Vault& vault) {
    -                testcase("disabled permissioned domains");
    -
    -                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -                env(tx);
    -
    -                tx[sfFlags] = tx[sfFlags].asUInt() | tfVaultPrivate;
    -                tx[sfDomainID] = to_string(BaseUInt<256>(42ul));
    -                env(tx, Ter{temDISABLED});
    -
    -                {
    -                    auto tx = vault.set({.owner = owner, .id = keylet.key});
    -                    env(tx, kData("Test"));
    -
    -                    tx[sfDomainID] = to_string(BaseUInt<256>(13ul));
    -                    env(tx, Ter{temDISABLED});
    -                }
    -            },
    -            {.features = testableAmendments() - featurePermissionedDomains});
    -
    -        testCase([&](Env& env,
    -                     Account const& issuer,
    -                     Account const& owner,
    -                     Asset const& asset,
    -                     Vault& vault) {
    -            testcase("invalid flags");
    -
    -            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -            tx[sfFlags] = tfClearDeepFreeze;
    -            env(tx, Ter{temINVALID_FLAG});
    -
    -            {
    -                auto tx = vault.set({.owner = owner, .id = keylet.key});
    -                tx[sfFlags] = tfClearDeepFreeze;
    -                env(tx, Ter{temINVALID_FLAG});
    -            }
    -
    -            {
    -                auto tx =
    -                    vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(10)});
    -                tx[sfFlags] = tfClearDeepFreeze;
    -                env(tx, Ter{temINVALID_FLAG});
    -            }
    -
    -            {
    -                auto tx =
    -                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)});
    -                tx[sfFlags] = tfClearDeepFreeze;
    -                env(tx, Ter{temINVALID_FLAG});
    -            }
    -
    -            {
    -                auto tx = vault.clawback(
    -                    {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(10)});
    -                tx[sfFlags] = tfClearDeepFreeze;
    -                env(tx, Ter{temINVALID_FLAG});
    -            }
    -
    -            {
    -                auto tx = vault.del({.owner = owner, .id = keylet.key});
    -                tx[sfFlags] = tfClearDeepFreeze;
    -                env(tx, Ter{temINVALID_FLAG});
    -            }
    -        });
    -
    -        testCase([&](Env& env,
    -                     Account const& issuer,
    -                     Account const& owner,
    -                     Asset const& asset,
    -                     Vault& vault) {
    -            testcase("invalid fee");
    -
    -            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -            tx[jss::Fee] = "-1";
    -            env(tx, Ter{temBAD_FEE});
    -
    -            {
    -                auto tx = vault.set({.owner = owner, .id = keylet.key});
    -                tx[jss::Fee] = "-1";
    -                env(tx, Ter{temBAD_FEE});
    -            }
    -
    -            {
    -                auto tx =
    -                    vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(10)});
    -                tx[jss::Fee] = "-1";
    -                env(tx, Ter{temBAD_FEE});
    -            }
    -
    -            {
    -                auto tx =
    -                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)});
    -                tx[jss::Fee] = "-1";
    -                env(tx, Ter{temBAD_FEE});
    -            }
    -
    -            {
    -                auto tx = vault.clawback(
    -                    {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(10)});
    -                tx[jss::Fee] = "-1";
    -                env(tx, Ter{temBAD_FEE});
    -            }
    -
    -            {
    -                auto tx = vault.del({.owner = owner, .id = keylet.key});
    -                tx[jss::Fee] = "-1";
    -                env(tx, Ter{temBAD_FEE});
    -            }
    -        });
    -
    -        testCase(
    -            [&](Env& env, Account const&, Account const& owner, Asset const&, Vault& vault) {
    -                testcase("disabled permissioned domain");
    -
    -                auto [tx, keylet] = vault.create({.owner = owner, .asset = xrpIssue()});
    -                tx[sfDomainID] = to_string(BaseUInt<256>(42ul));
    -                env(tx, Ter{temDISABLED});
    -
    -                {
    -                    auto tx = vault.set({.owner = owner, .id = keylet.key});
    -                    tx[sfDomainID] = to_string(BaseUInt<256>(42ul));
    -                    env(tx, Ter{temDISABLED});
    -                }
    -
    -                {
    -                    auto tx = vault.set({.owner = owner, .id = keylet.key});
    -                    tx[sfDomainID] = "0";
    -                    env(tx, Ter{temDISABLED});
    -                }
    -            },
    -            {.features = (testableAmendments()) - featurePermissionedDomains});
    -
    -        testCase([&](Env& env,
    -                     Account const& issuer,
    -                     Account const& owner,
    -                     Asset const& asset,
    -                     Vault& vault) {
    -            testcase("use zero vault");
    -
    -            auto [tx, keylet] = vault.create({.owner = owner, .asset = xrpIssue()});
    -
    -            {
    -                auto tx = vault.set({
    -                    .owner = owner,
    -                    .id = beast::kZero,
    -                });
    -                env(tx, Ter{temMALFORMED});
    -            }
    -
    -            {
    -                auto tx =
    -                    vault.deposit({.depositor = owner, .id = beast::kZero, .amount = asset(10)});
    -                env(tx, Ter(temMALFORMED));
    -            }
    -
    -            {
    -                auto tx =
    -                    vault.withdraw({.depositor = owner, .id = beast::kZero, .amount = asset(10)});
    -                env(tx, Ter{temMALFORMED});
    -            }
    -
    -            {
    -                auto tx = vault.clawback(
    -                    {.issuer = issuer, .id = beast::kZero, .holder = owner, .amount = asset(10)});
    -                env(tx, Ter{temMALFORMED});
    -            }
    -
    -            {
    -                auto tx = vault.del({
    -                    .owner = owner,
    -                    .id = beast::kZero,
    -                });
    -                env(tx, Ter{temMALFORMED});
    -            }
    -        });
    -
    -        testCase(
    -            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
    -                testcase("withdraw to bad destination");
    -
    -                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -
    -                {
    -                    auto tx =
    -                        vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)});
    -                    tx[jss::Destination] = "0";
    -                    env(tx, Ter{temMALFORMED});
    -                }
    -            });
    -
    -        testCase(
    -            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
    -                testcase("create with Scale");
    -
    -                {
    -                    auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -                    tx[sfScale] = 255;
    -                    env(tx, Ter(temMALFORMED));
    -                }
    -
    -                {
    -                    auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -                    tx[sfScale] = 19;
    -                    env(tx, Ter(temMALFORMED));
    -                }
    -
    -                // accepted range from 0 to 18
    -                {
    -                    auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -                    tx[sfScale] = 18;
    -                    env(tx);
    -                    env.close();
    -                    auto const sleVault = env.le(keylet);
    -                    BEAST_EXPECT(sleVault);
    -                    BEAST_EXPECT((*sleVault)[sfScale] == 18);
    -                }
    -
    -                {
    -                    auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -                    tx[sfScale] = 0;
    -                    env(tx);
    -                    env.close();
    -                    auto const sleVault = env.le(keylet);
    -                    BEAST_EXPECT(sleVault);
    -                    BEAST_EXPECT((*sleVault)[sfScale] == 0);
    -                }
    -
    -                {
    -                    auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -                    env(tx);
    -                    env.close();
    -                    auto const sleVault = env.le(keylet);
    -                    BEAST_EXPECT(sleVault);
    -                    BEAST_EXPECT((*sleVault)[sfScale] == 6);
    -                }
    -            });
    -
    -        testCase(
    -            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
    -                testcase("create or set invalid data");
    -
    -                auto [tx1, keylet] = vault.create({.owner = owner, .asset = asset});
    -
    -                {
    -                    auto tx = tx1;
    -                    tx[sfData] = "";
    -                    env(tx, Ter(temMALFORMED));
    -                }
    -
    -                {
    -                    auto tx = tx1;
    -                    // A hexadecimal string of 257 bytes.
    -                    tx[sfData] = std::string(514, 'A');
    -                    env(tx, Ter(temMALFORMED));
    -                }
    -
    -                {
    -                    auto tx = vault.set({.owner = owner, .id = keylet.key});
    -                    tx[sfData] = "";
    -                    env(tx, Ter{temMALFORMED});
    -                }
    -
    -                {
    -                    auto tx = vault.set({.owner = owner, .id = keylet.key});
    -                    // A hexadecimal string of 257 bytes.
    -                    tx[sfData] = std::string(514, 'A');
    -                    env(tx, Ter{temMALFORMED});
    -                }
    -            });
    -
    -        testCase(
    -            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
    -                testcase("set nothing updated");
    -
    -                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -
    -                {
    -                    auto tx = vault.set({.owner = owner, .id = keylet.key});
    -                    env(tx, Ter{temMALFORMED});
    -                }
    -            });
    -
    -        testCase(
    -            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
    -                testcase("create with invalid metadata");
    -
    -                auto [tx1, keylet] = vault.create({.owner = owner, .asset = asset});
    -
    -                {
    -                    auto tx = tx1;
    -                    tx[sfMPTokenMetadata] = "";
    -                    env(tx, Ter(temMALFORMED));
    -                }
    -
    -                {
    -                    auto tx = tx1;
    -                    // This metadata is for the share token.
    -                    // A hexadecimal string of 1025 bytes.
    -                    tx[sfMPTokenMetadata] = std::string(2050, 'B');
    -                    env(tx, Ter(temMALFORMED));
    -                }
    -            });
    -
    -        testCase(
    -            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
    -                testcase("set negative maximum");
    -
    -                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -
    -                {
    -                    auto tx = vault.set({.owner = owner, .id = keylet.key});
    -                    tx[sfAssetsMaximum] = kNegativeAmount(asset).number();
    -                    env(tx, Ter{temMALFORMED});
    -                }
    -            });
    -
    -        testCase(
    -            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
    -                testcase("invalid deposit amount");
    -
    -                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -
    -                {
    -                    auto tx = vault.deposit(
    -                        {.depositor = owner, .id = keylet.key, .amount = kNegativeAmount(asset)});
    -                    env(tx, Ter(temBAD_AMOUNT));
    -                }
    -
    -                {
    -                    auto tx =
    -                        vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(0)});
    -                    env(tx, Ter(temBAD_AMOUNT));
    -                }
    -            });
    -
    -        testCase(
    -            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
    -                testcase("invalid set immutable flag");
    -
    -                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -
    -                {
    -                    auto tx = vault.set({.owner = owner, .id = keylet.key});
    -                    tx[sfFlags] = tfVaultPrivate;
    -                    env(tx, Ter(temINVALID_FLAG));
    -                }
    -            });
    -
    -        testCase(
    -            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
    -                testcase("invalid withdraw amount");
    -
    -                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -
    -                {
    -                    auto tx = vault.withdraw(
    -                        {.depositor = owner, .id = keylet.key, .amount = kNegativeAmount(asset)});
    -                    env(tx, Ter(temBAD_AMOUNT));
    -                }
    -
    -                {
    -                    auto tx =
    -                        vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(0)});
    -                    env(tx, Ter(temBAD_AMOUNT));
    -                }
    -            });
    -
    -        testCase([&](Env& env,
    -                     Account const& issuer,
    -                     Account const& owner,
    -                     Asset const& asset,
    -                     Vault& vault) {
    -            testcase("invalid clawback");
    -
    -            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -
    -            // Preclaim only checks for native assets.
    -            if (asset.native())
    -            {
    -                auto tx = vault.clawback(
    -                    {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(50)});
    -                env(tx, Ter(temMALFORMED));
    -            }
    -
    -            {
    -                auto tx = vault.clawback(
    -                    {.issuer = issuer,
    -                     .id = keylet.key,
    -                     .holder = owner,
    -                     .amount = kNegativeAmount(asset)});
    -                env(tx, Ter(temBAD_AMOUNT));
    -            }
    -        });
    -
    -        testCase(
    -            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
    -                testcase("invalid create");
    -
    -                auto [tx1, keylet] = vault.create({.owner = owner, .asset = asset});
    -
    -                {
    -                    auto tx = tx1;
    -                    tx[sfWithdrawalPolicy] = 0;
    -                    env(tx, Ter(temMALFORMED));
    -                }
    -
    -                {
    -                    auto tx = tx1;
    -                    tx[sfDomainID] = to_string(BaseUInt<256>(42ul));
    -                    env(tx, Ter{temMALFORMED});
    -                }
    -
    -                {
    -                    auto tx = tx1;
    -                    tx[sfAssetsMaximum] = kNegativeAmount(asset).number();
    -                    env(tx, Ter{temMALFORMED});
    -                }
    -
    -                {
    -                    auto tx = tx1;
    -                    tx[sfFlags] = tfVaultPrivate;
    -                    tx[sfDomainID] = "0";
    -                    env(tx, Ter{temMALFORMED});
    -                }
    -            });
    -    }
    -
    -    // VaultCreate malformation and happy paths for closed-ended vaults, plus the
    -    // featureLendingProtocolV1_1 gate.
    -    void
    -    testVaultCreateClosedEnded()
    -    {
    -        testcase("closed-ended VaultCreate");
    -        using namespace test::jtx;
    -
    -        auto const withEnv = [this](FeatureBitset features, auto&& body) {
    -            Env env{*this, features};
    -            Account const owner{"owner"};
    -            env.fund(XRP(1000), owner);
    -            env.close();
    -            Vault vault{env};
    -            body(env, owner, vault);
    -        };
    -
    -        Asset const asset = xrpIssue();
    -        auto const minPeriod = kMinInvestmentPeriod;
    -        auto const maxPeriod = kMaxInvestmentPeriod;
    -        auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded);
    -
    -        // Gate: the three new fields require featureLendingProtocolV1_1.
    -        withEnv(
    -            testableAmendments() - featureLendingProtocolV1_1,
    -            [&](Env& env, Account const& owner, Vault& vault) {
    -                auto const sub = env.now().time_since_epoch().count() + 60;
    -                auto [tx, keylet] = vault.create(
    -                    {.owner = owner,
    -                     .asset = asset,
    -                     .vaultKind = closedEnded,
    -                     .subscriptionDate = sub,
    -                     .redemptionDate = sub + minPeriod});
    -                env(tx, Ter{temDISABLED});
    -            });
    -
    -        /*
    -         * Valid closed-ended creation with a comfortably interior gap (well above
    -         * MIN_INVESTMENT_PERIOD and well below MAX_INVESTMENT_PERIOD).
    -         */
    -        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
    -            auto const sub = env.now().time_since_epoch().count() + 60;
    -            auto const red = sub + 86400;
    -            auto [tx, keylet] = vault.create(
    -                {.owner = owner,
    -                 .asset = asset,
    -                 .vaultKind = closedEnded,
    -                 .subscriptionDate = sub,
    -                 .redemptionDate = red});
    -            env(tx);
    -            env.close();
    -            auto const sle = env.le(keylet);
    -            if (BEAST_EXPECT(sle))
    -            {
    -                BEAST_EXPECT(sle->at(sfVaultKind) == closedEnded);
    -                BEAST_EXPECT(sle->at(sfSubscriptionDate) == sub);
    -                BEAST_EXPECT(sle->at(sfRedemptionDate) == red);
    -            }
    -        });
    -
    -        // ClosedEnded missing one of SubscriptionDate / RedemptionDate => temMALFORMED.
    -        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
    -            auto const sub = env.now().time_since_epoch().count() + 60;
    -            auto [tx, keylet] = vault.create(
    -                {.owner = owner,
    -                 .asset = asset,
    -                 .vaultKind = closedEnded,
    -                 .redemptionDate = sub + minPeriod});
    -            env(tx, Ter{temMALFORMED});
    -        });
    -        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
    -            auto const sub = env.now().time_since_epoch().count() + 60;
    -            auto [tx, keylet] = vault.create(
    -                {.owner = owner,
    -                 .asset = asset,
    -                 .vaultKind = closedEnded,
    -                 .subscriptionDate = sub});
    -            env(tx, Ter{temMALFORMED});
    -        });
    -
    -        /*
    -         * SubscriptionDate not strictly after parent close time (preclaim, state-dependent -
    -         * returns tecEXPIRED). This is the only reachable path to tecEXPIRED in VaultCreate; see
    -         * the note below the next case. Note: there is no separate "expired RedemptionDate" test
    -         * case here. preflight enforces red >= sub + kMinInvestmentPeriod, so any past
    -         * RedemptionDate implies a strictly-earlier, equally-past SubscriptionDate; the
    -         * SubscriptionDate check above short-circuits first. The RedemptionDate arm of the
    -         * hasExpired check in VaultCreate::preclaim is defensive and unreachable as the sole cause
    -         * of tecEXPIRED.
    -         */
    -        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
    -            auto const nowSec = env.now().time_since_epoch().count();
    -            auto [tx, keylet] = vault.create(
    -                {.owner = owner,
    -                 .asset = asset,
    -                 .vaultKind = closedEnded,
    -                 .subscriptionDate = nowSec,
    -                 .redemptionDate = nowSec + minPeriod});
    -            env(tx, Ter{tecEXPIRED});
    -        });
    -
    -        /*
    -         * Gap smaller than MIN_INVESTMENT_PERIOD => temMALFORMED. Includes the SubscriptionDate >=
    -         * RedemptionDate degenerate cases: the red == sub boundary and the strictly-reversed red <
    -         * sub case, the latter yielding a negative signed int64 gap that is caught by the
    -         * sub-minimum branch of the gap check.
    -         */
    -        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
    -            auto const sub = env.now().time_since_epoch().count() + 60;
    -            auto [tx, keylet] = vault.create(
    -                {.owner = owner,
    -                 .asset = asset,
    -                 .vaultKind = closedEnded,
    -                 .subscriptionDate = sub,
    -                 .redemptionDate = sub + minPeriod - 1});
    -            env(tx, Ter{temMALFORMED});
    -        });
    -        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
    -            auto const sub = env.now().time_since_epoch().count() + 60;
    -            auto [tx, keylet] = vault.create(
    -                {.owner = owner,
    -                 .asset = asset,
    -                 .vaultKind = closedEnded,
    -                 .subscriptionDate = sub,
    -                 .redemptionDate = sub});
    -            env(tx, Ter{temMALFORMED});
    -        });
    -        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
    -            auto const sub = env.now().time_since_epoch().count() + 60;
    -            auto [tx, keylet] = vault.create(
    -                {.owner = owner,
    -                 .asset = asset,
    -                 .vaultKind = closedEnded,
    -                 .subscriptionDate = sub,
    -                 .redemptionDate = sub - 1});
    -            env(tx, Ter{temMALFORMED});
    -        });
    -
    -        // Gap equal to MAX_INVESTMENT_PERIOD => temMALFORMED (bound is half-open on the right).
    -        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
    -            auto const sub = env.now().time_since_epoch().count() + 60;
    -            auto [tx, keylet] = vault.create(
    -                {.owner = owner,
    -                 .asset = asset,
    -                 .vaultKind = closedEnded,
    -                 .subscriptionDate = sub,
    -                 .redemptionDate = sub + maxPeriod});
    -            env(tx, Ter{temMALFORMED});
    -        });
    -
    -        // Gap strictly greater than MAX_INVESTMENT_PERIOD => temMALFORMED. Same code path as
    -        // gap == MAX_INVESTMENT_PERIOD above, but covers the "gap >= MAX" bullet fully.
    -        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
    -            auto const sub = env.now().time_since_epoch().count() + 60;
    -            auto [tx, keylet] = vault.create(
    -                {.owner = owner,
    -                 .asset = asset,
    -                 .vaultKind = closedEnded,
    -                 .subscriptionDate = sub,
    -                 .redemptionDate = sub + maxPeriod + 1});
    -            env(tx, Ter{temMALFORMED});
    -        });
    -
    -        // Happy path: gap exactly equal to MIN_INVESTMENT_PERIOD is accepted (lower bound is
    -        // inclusive).
    -        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
    -            auto const sub = env.now().time_since_epoch().count() + 60;
    -            auto const red = sub + minPeriod;
    -            auto [tx, keylet] = vault.create(
    -                {.owner = owner,
    -                 .asset = asset,
    -                 .vaultKind = closedEnded,
    -                 .subscriptionDate = sub,
    -                 .redemptionDate = red});
    -            env(tx);
    -            env.close();
    -            auto const sle = env.le(keylet);
    -            if (BEAST_EXPECT(sle))
    -            {
    -                BEAST_EXPECT(sle->at(sfRedemptionDate) == red);
    -            }
    -        });
    -
    -        // Happy path: gap one second less than MAX_INVESTMENT_PERIOD is
    -        // accepted (upper bound is exclusive).
    -        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
    -            auto const sub = env.now().time_since_epoch().count() + 60;
    -            auto const red = sub + maxPeriod - 1;
    -            auto [tx, keylet] = vault.create(
    -                {.owner = owner,
    -                 .asset = asset,
    -                 .vaultKind = closedEnded,
    -                 .subscriptionDate = sub,
    -                 .redemptionDate = red});
    -            env(tx);
    -            env.close();
    -            auto const sle = env.le(keylet);
    -            if (BEAST_EXPECT(sle))
    -            {
    -                BEAST_EXPECT(sle->at(sfRedemptionDate) == red);
    -            }
    -        });
    -
    -        // OpenEnded (absent/0) with SubscriptionDate or RedemptionDate present
    -        // => temMALFORMED.
    -        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
    -            auto const sub = env.now().time_since_epoch().count() + 60;
    -            auto [tx, keylet] =
    -                vault.create({.owner = owner, .asset = asset, .subscriptionDate = sub});
    -            env(tx, Ter{temMALFORMED});
    -        });
    -        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
    -            auto const sub = env.now().time_since_epoch().count() + 60;
    -            auto [tx, keylet] =
    -                vault.create({.owner = owner, .asset = asset, .redemptionDate = sub + minPeriod});
    -            env(tx, Ter{temMALFORMED});
    -        });
    -
    -        // Unrecognised VaultKind => temMALFORMED.
    -        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
    -            auto [tx, keylet] = vault.create(
    -                {.owner = owner,
    -                 .asset = asset,
    -                 .vaultKind = static_cast(closedEnded + 1)});
    -            env(tx, Ter{temMALFORMED});
    -        });
    -
    -        // Happy path: open-ended vault (no new fields present) is unaffected.
    -        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
    -            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -            env(tx);
    -            env.close();
    -            auto const sle = env.le(keylet);
    -            if (BEAST_EXPECT(sle))
    -            {
    -                BEAST_EXPECT(!sle->isFieldPresent(sfVaultKind));
    -                BEAST_EXPECT(!sle->isFieldPresent(sfSubscriptionDate));
    -                BEAST_EXPECT(!sle->isFieldPresent(sfRedemptionDate));
    -            }
    -        });
    -
    -        // Happy path: explicit `VaultKind = 0` (OpenEnded) behaves the same
    -        // as absent. Per spec, absent and OpenEnded are equivalent.
    -        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
    -            auto [tx, keylet] = vault.create(
    -                {.owner = owner,
    -                 .asset = asset,
    -                 .vaultKind = std::to_underlying(VaultKind::OpenEnded)});
    -            env(tx);
    -            env.close();
    -            auto const sle = env.le(keylet);
    -            if (BEAST_EXPECT(sle))
    -            {
    -                // OpenEnded is sfVaultKind's default; SoeDefault fields
    -                // aren't serialized when they hold the default value.
    -                BEAST_EXPECT(!sle->isFieldPresent(sfVaultKind));
    -                BEAST_EXPECT(!sle->isFieldPresent(sfSubscriptionDate));
    -                BEAST_EXPECT(!sle->isFieldPresent(sfRedemptionDate));
    -            }
    -        });
    -    }
    -
    -    // Phase derivation across the SubscriptionDate / RedemptionDate boundaries, including the now
    -    // == SubscriptionDate case (which must still resolve to Subscription).
    -    void
    -    testVaultPhaseDerivation()
    -    {
    -        testcase("closed-ended phase derivation");
    -        using namespace test::jtx;
    -
    -        Env env{*this, testableAmendments()};
    -        Account const owner{"owner"};
    -        Account const depositor{"depositor"};
    -        env.fund(XRP(1000), owner, depositor);
    -        env.close();
    -
    -        Asset const asset = xrpIssue();
    -        auto const [vault, keylet, sub, red] =
    -            makeClosedEndedVault(env, owner, asset, 60u, kMinInvestmentPeriod);
    -
    -        // Pre-seed shares during Subscription so the depositor has capital to
    -        // withdraw at the Redemption boundary below.
    -        env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = XRP(10).value()}));
    -        env.close();
    -
    -        auto const deposit =
    -            [&](TER expected, std::source_location const& loc = std::source_location::current()) {
    -                env(
    -                    WithSourceLocation{
    -                        vault.deposit(
    -                            {.depositor = depositor, .id = keylet.key, .amount = XRP(1).value()}),
    -                        loc},
    -                    Ter{expected});
    -            };
    -        auto const withdraw =
    -            [&](TER expected, std::source_location const& loc = std::source_location::current()) {
    -                env(
    -                    WithSourceLocation{
    -                        vault.withdraw(
    -                            {.depositor = depositor, .id = keylet.key, .amount = XRP(1).value()}),
    -                        loc},
    -                    Ter{expected});
    -            };
    -
    -        auto const runTest = [&](TER expectedDeposit,
    -                                 TER expectedWithdraw,
    -                                 std::source_location const& loc =
    -                                     std::source_location::current()) {
    -            deposit(expectedDeposit, loc);
    -            withdraw(expectedWithdraw, loc);
    -        };
    -
    -        // Assert both deposit and withdraw return codes at each point so the
    -        // active phase is uniquely identified:
    -        //   Subscription: deposit tesSUCCESS, withdraw tesSUCCESS
    -        //   Investment:   deposit tecEXPIRED, withdraw tecTOO_SOON
    -        //   Redemption:   deposit tecEXPIRED, withdraw tesSUCCESS
    -
    -        // Ledger time comfortably before SubscriptionDate: Subscription.
    -        runTest(tesSUCCESS, tesSUCCESS);
    -
    -        // Boundary: parent close time exactly at SubscriptionDate must still
    -        // be Subscription.
    -        closeToTime(env, tp{d{sub}});
    -        runTest(tesSUCCESS, tesSUCCESS);
    -
    -        // One second past SubscriptionDate: Investment.
    -        closeToTime(env, tp{d{sub}} + getLedgerTimeResolution(env));
    -        runTest(tecEXPIRED, tecTOO_SOON);
    -
    -        // Any point strictly before RedemptionDate remains Investment.
    -        closeToTime(env, tp{d{red}} - getLedgerTimeResolution(env));
    -        runTest(tecEXPIRED, tecTOO_SOON);
    -
    -        // Boundary: parent close time == RedemptionDate is Redemption (per
    -        // spec table: now >= RedemptionDate). Deposits are rejected but
    -        // withdrawals succeed.
    -        closeToTime(env, tp{d{red}});
    -        runTest(tecEXPIRED, tesSUCCESS);
    -        env.close();
    -    }
    -
    -    // Open-ended vaults are always in VaultPhase::NoPhase, regardless of the ledger clock or any
    -    // dates present on the vault.
    -    void
    -    testVaultPhaseDerivationOpenEnded()
    -    {
    -        testcase("open-ended phase derivation");
    -        using namespace test::jtx;
    -
    -        Env env{*this, testableAmendments()};
    -        Account const owner{"owner"};
    -        env.fund(XRP(1000), owner);
    -        env.close();
    -
    -        Asset const asset = xrpIssue();
    -        Vault const vault{env};
    -        auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -        env(tx);
    -        env.close();
    -
    -        auto const checkPhaseAt = [&](NetClock::time_point at) {
    -            closeToTime(env, at);
    -            auto const sle = env.le(keylet);
    -            if (!BEAST_EXPECT(sle))
    -                return;
    -            BEAST_EXPECT(getVaultPhase(*env.current(), sle) == VaultPhase::NoPhase);
    -        };
    -
    -        // Advance the clock through a wide range of ledger times: an open-ended vault's phase
    -        // must be NoPhase at every one of them, because the derivation short-circuits on
    -        // VaultKind::OpenEnded before it looks at any dates.
    -        auto const ledgerTime = tp{d{30}} + env.closed()->header().closeTimeResolution;
    -        checkPhaseAt(ledgerTime);
    -        checkPhaseAt(ledgerTime + std::chrono::seconds{kMinInvestmentPeriod});
    -        checkPhaseAt(
    -            ledgerTime + std::chrono::seconds{kMaxInvestmentPeriod} -
    -            env.closed()->header().closeTimeResolution);
    -    }
    -
    -    // VaultDeposit is allowed only during Subscription (or NoPhase). Rejected during Investment and
    -    // Redemption.
    -    void
    -    testVaultDepositClosedEnded()
    -    {
    -        testcase("closed-ended VaultDeposit phase gating");
    -        using namespace test::jtx;
    -
    -        Env env{*this, testableAmendments()};
    -        Account const owner{"owner"};
    -        Account const depositor{"depositor"};
    -        env.fund(XRP(1000), owner, depositor);
    -        env.close();
    -
    -        Asset const asset = xrpIssue();
    -        auto const [vault, keylet, sub, red] =
    -            makeClosedEndedVault(env, owner, asset, 60u, kMinInvestmentPeriod);
    -
    -        auto const deposit =
    -            [&](TER expected, std::source_location const& loc = std::source_location::current()) {
    -                env(
    -                    WithSourceLocation{
    -                        vault.deposit(
    -                            {.depositor = depositor, .id = keylet.key, .amount = XRP(1).value()}),
    -                        loc},
    -                    Ter{expected});
    -                env.close();
    -            };
    -
    -        // Subscription: allowed.
    -        deposit(tesSUCCESS);
    -
    -        // Investment: rejected.
    -        env.close(tp{d{sub + 1}});
    -        deposit(tecEXPIRED);
    -
    -        // Redemption: rejected.
    -        env.close(tp{d{red}});
    -        deposit(tecEXPIRED);
    -    }
    -
    -    // VaultWithdraw is allowed in Subscription and Redemption; rejected in Investment. The
    -    // AssetsAvailable cap continues to apply and is exercised in Redemption against a vault with
    -    // capital deployed as an outstanding loan.
    -    void
    -    testVaultWithdrawClosedEnded()
    -    {
    -        testcase("closed-ended VaultWithdraw phase gating");
    -        using namespace test::jtx;
    -        using namespace loan_broker;
    -        using namespace loan;
    -
    -        Env env{*this, testableAmendments()};
    -        Account const owner{"owner"};
    -        Account const depositor{"depositor"};
    -        Account const borrower{"borrower"};
    -        env.fund(XRP(10'000), owner, depositor, borrower);
    -        env.close();
    -
    -        Asset const asset = xrpIssue();
    -        // Widen the Investment window so a single-payment loan (min payment
    -        // interval kMinPaymentInterval = 60s) fits before RedemptionDate.
    -        auto const [vault, keylet, sub, red] =
    -            makeClosedEndedVault(env, owner, asset, 60u, kMinInvestmentPeriod + 3600u);
    -
    -        // Deposit XRP(100) in Subscription so the depositor's shares are
    -        // worth XRP(100). The vault holds XRP(100) with
    -        // AssetsAvailable == AssetsTotal.
    -        env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = XRP(100).value()}));
    -        env.close();
    -
    -        // Create a loan broker backed by this vault. LoanBrokerSet has no
    -        // phase gate, so this is fine to do in Subscription.
    -        auto const brokerKeylet =
    -            keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
    -        env(loan_broker::set(owner, keylet.key));
    -        env.close();
    -
    -        auto const withdraw = [&](STAmount const& amount,
    -                                  TER expected,
    -                                  std::source_location const& loc =
    -                                      std::source_location::current()) {
    -            env(
    -                WithSourceLocation{
    -                    vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = amount}),
    -                    loc},
    -                Ter{expected});
    -            env.close();
    -        };
    -
    -        // Subscription: allowed (LP cancel).
    -        withdraw(XRP(1).value(), tesSUCCESS);
    -
    -        // Investment: rejected.
    -        closeToTime(env, tp{d{sub}} + getLedgerTimeResolution(env));
    -        withdraw(XRP(1).value(), tecTOO_SOON);
    -
    -        // Deploy capital: borrower takes a loan of XRP(60) against the
    -        // vault, dropping AssetsAvailable to ~XRP(39) while AssetsTotal
    -        // remains ~XRP(99).
    -        env(loan::set(borrower, brokerKeylet.key, XRP(60).value()),
    -            loan::kInterestRate(TenthBips32(0)),
    -            kGracePeriod(60),
    -            kPaymentInterval(60),
    -            kPaymentTotal(1),
    -            Sig(sfCounterpartySignature, owner),
    -            Fee(env.current()->fees().base * 2));
    -        env.close();
    -
    -        // Redemption: withdrawals are allowed but subject to the AssetsAvailable cap. A small
    -        // withdrawal within AssetsAvailable succeeds. A withdrawal within the depositor's share
    -        // value but exceeding the vault's liquid balance fails with tecINSUFFICIENT_FUNDS from the
    -        // vault-shortage guard (not the insufficient-shares guard).
    -        closeToTime(env, tp{d{red}});
    -        withdraw(XRP(10).value(), tesSUCCESS);
    -        withdraw(XRP(80).value(), tecINSUFFICIENT_FUNDS);
    -    }
    -
    -    // End-to-end lifecycle of a closed-ended vault (Subscription → Investment → Redemption) with
    -    // multiple depositors and a real loan originated through the Investment leg. Exercises every
    -    // phase transition and verifies the expected deposit, withdrawal, and lending behaviour in each
    -    // phase.
    -    void
    -    testVaultClosedEndedLifecycle()
    -    {
    -        testcase("closed-ended vault lifecycle (subscribe → invest → redeem)");
    -        using namespace test::jtx;
    -        using namespace loan_broker;
    -        using namespace loan;
    -
    -        Env env{*this, testableAmendments()};
    -        Account const owner{"owner"};
    -        Account const alice{"alice"};
    -        Account const bob{"bob"};
    -        Account const borrower{"borrower"};
    -        env.fund(XRP(10'000), owner, alice, bob, borrower);
    -        env.close();
    -
    -        auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded);
    -        Asset const asset = xrpIssue();
    -        // Widen the Investment window so a single-payment loan (min payment interval
    -        // kMinPaymentInterval = 60s) fits before RedemptionDate with headroom.
    -        auto const [vault, keylet, sub, red] =
    -            makeClosedEndedVault(env, owner, asset, 300u, kMinInvestmentPeriod + 3600u);
    -
    -        auto const sleCreate = env.le(keylet);
    -        BEAST_EXPECT(sleCreate);
    -        MPTIssue const shares{sleCreate->at(sfShareMPTID)};
    -
    -        auto const balancesEq = [&](STAmount const& available, STAmount const& total) {
    -            auto const sle = env.le(keylet);
    -            BEAST_EXPECT(sle->at(sfAssetsAvailable) == available);
    -            BEAST_EXPECT(sle->at(sfAssetsTotal) == total);
    -        };
    -        auto const availableEq = [&](STAmount const& expected) { balancesEq(expected, expected); };
    -
    -        // env.balance(account, mptIssue) name-resolves the issuer via Env::lookup, but the share
    -        // issuer is the vault's pseudo-account and is never registered with the jtx Env. Read the
    -        // MPToken SLE directly to avoid the lookup.
    -        auto const sharesEq = [&](Account const& holder, std::uint64_t expected) {
    -            auto const sle = env.le(keylet::mptoken(shares.getMptID(), holder.id()));
    -            std::uint64_t const actual = sle ? sle->getFieldU64(sfMPTAmount) : 0u;
    -            BEAST_EXPECT(actual == expected);
    -        };
    -
    -        // ---- Subscription phase ----
    -        // A legitimate VaultSet succeeds (positive control for 3.7).
    -        {
    -            auto tx = vault.set({.owner = owner, .id = keylet.key});
    -            tx[sfData] = "AA";
    -            env(tx);
    -            env.close();
    -        }
    -
    -        // alice deposits 100 XRP.
    -        env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()}));
    -        env.close();
    -        sharesEq(alice, 100'000'000);
    -        availableEq(XRP(100).value());
    -
    -        // bob deposits 200 XRP.
    -        env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = XRP(200).value()}));
    -        env.close();
    -        sharesEq(bob, 200'000'000);
    -        availableEq(XRP(300).value());
    -
    -        // alice cancels 25 XRP (LP cancel is permitted in Subscription).
    -        env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(25).value()}));
    -        env.close();
    -        sharesEq(alice, 75'000'000);
    -        availableEq(XRP(275).value());
    -
    -        // Create a loan broker backed by this vault. LoanBrokerSet has no phase gate, so it is
    -        // fine to do in Subscription.
    -        auto const brokerKeylet =
    -            keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
    -        env(loan_broker::set(owner, keylet.key));
    -        env.close();
    -
    -        // ---- Investment phase (now == sub + 1) ----
    -        env.close(tp{d{sub + 1}});
    -
    -        // Deposits into a closed-ended vault past SubscriptionDate return tecEXPIRED.
    -        env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(10).value()}),
    -            Ter{tecEXPIRED});
    -        env.close();
    -        // Withdrawals from a closed-ended vault during the Investment phase return tecTOO_SOON.
    -        env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(10).value()}),
    -            Ter{tecTOO_SOON});
    -        env.close();
    -
    -        // A real loan is originated during Investment (permitted only in this phase). Zero-interest
    -        // one-payment schedule keeps AssetsTotal unchanged (both accrual and cash-basis
    -        // accounting recognise no interest at origination); AssetsAvailable drops by the loan
    -        // principal.
    -        env(loan::set(borrower, brokerKeylet.key, XRP(60).value()),
    -            loan::kInterestRate(TenthBips32(0)),
    -            kGracePeriod(60),
    -            kPaymentInterval(60),
    -            kPaymentTotal(1),
    -            Sig(sfCounterpartySignature, owner),
    -            Fee(env.current()->fees().base * 2));
    -        env.close();
    -        auto const sleBroker = env.le(keylet::loanBroker(brokerKeylet.key));
    -        BEAST_EXPECT(sleBroker);
    -        auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1u));
    -        BEAST_EXPECT(env.le(loanKeylet));
    -        balancesEq(XRP(215).value(), XRP(275).value());
    -
    -        // Non-immutable VaultSet still works in Investment (positive control).
    -        {
    -            auto tx = vault.set({.owner = owner, .id = keylet.key});
    -            tx[sfData] = "BB";
    -            env(tx);
    -            env.close();
    -        }
    -
    -        // Depositor share balances unchanged by the loan origination; only AssetsAvailable moved.
    -        sharesEq(alice, 75'000'000);
    -        sharesEq(bob, 200'000'000);
    -
    -        // ---- Redemption phase (now == red) ----
    -        env.close(tp{d{red}});
    -
    -        // Deposits into a closed-ended vault past SubscriptionDate return tecEXPIRED, in both
    -        // Investment and Redemption.
    -        env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(10).value()}),
    -            Ter{tecEXPIRED});
    -        env.close();
    -
    -        // alice redeems her remaining 75 XRP (fits within AssetsAvailable = 215).
    -        env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(75).value()}));
    -        env.close();
    -        sharesEq(alice, 0);
    -        balancesEq(XRP(140).value(), XRP(200).value());
    -
    -        // bob has 200 XRP-worth of shares but only 140 XRP is available (the remaining 60 XRP
    -        // sits in the outstanding loan). A full 200 XRP withdrawal fails against the
    -        // AssetsAvailable cap; bob redeems 140 XRP instead and is left holding 60M shares backed
    -        // by the loan receivable — the realistic outcome when capital is still deployed at
    -        // Redemption.
    -        env(vault.withdraw({.depositor = bob, .id = keylet.key, .amount = XRP(200).value()}),
    -            Ter{tecINSUFFICIENT_FUNDS});
    -        env.close();
    -        env(vault.withdraw({.depositor = bob, .id = keylet.key, .amount = XRP(140).value()}));
    -        env.close();
    -        sharesEq(bob, 60'000'000);
    -        balancesEq(XRP(0).value(), XRP(60).value());
    -
    -        // Defensive spot-check that the three immutable fields have not changed across the entire
    -        // lifecycle. Direct immutability coverage lives with the invariant tests.
    -        auto const sleFinal = env.le(keylet);
    -        if (BEAST_EXPECT(sleFinal))
    -        {
    -            BEAST_EXPECT(sleFinal->at(sfVaultKind) == closedEnded);
    -            BEAST_EXPECT(sleFinal->at(sfSubscriptionDate) == sub);
    -            BEAST_EXPECT(sleFinal->at(sfRedemptionDate) == red);
    -        }
    -    }
    -
    -    // SubscriptionDate boundary cases at the top of the UINT32 range.
    -    // (1) The largest legal sub picks red = UINT32_MAX exactly, which hits
    -    // the inclusive lower bound of the kMinInvestmentPeriod gap check.
    -    // (2) sub = UINT32_MAX must be rejected: sub + kMinInvestmentPeriod is
    -    // unrepresentable as the tx's UINT32 sfRedemptionDate, so no red value
    -    // can satisfy the gap check.
    -    void
    -    testVaultCreateSubscriptionDateBoundary()
    -    {
    -        testcase("closed-ended VaultCreate SubscriptionDate near UINT32_MAX");
    -        using namespace test::jtx;
    -
    -        auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded);
    -        Asset const asset = xrpIssue();
    -
    -        {
    -            Env env{*this, testableAmendments()};
    -            Account const owner{"owner"};
    -            env.fund(XRP(1000), owner);
    -            env.close();
    -
    -            Vault const vault{env};
    -            auto const sub = std::numeric_limits::max() - kMinInvestmentPeriod;
    -            auto const red = std::numeric_limits::max();
    -            auto [tx, keylet] = vault.create(
    -                {.owner = owner,
    -                 .asset = asset,
    -                 .vaultKind = closedEnded,
    -                 .subscriptionDate = sub,
    -                 .redemptionDate = red});
    -            env(tx);
    -            env.close();
    -            auto const sle = env.le(keylet);
    -            if (BEAST_EXPECT(sle))
    -            {
    -                BEAST_EXPECT(sle->at(sfSubscriptionDate) == sub);
    -                BEAST_EXPECT(sle->at(sfRedemptionDate) == red);
    -            }
    -        }
    -
    -        // sub = UINT32_MAX: no legal red exists because sub + kMinInvestmentPeriod
    -        // wraps in a UINT32. Every candidate red must fall to temMALFORMED via
    -        // the gap check in preflight.
    -        auto const rejectAtMax = [&, this](std::uint32_t red) {
    -            Env env{*this, testableAmendments()};
    -            Account const owner{"owner"};
    -            env.fund(XRP(1000), owner);
    -            env.close();
    -
    -            Vault const vault{env};
    -            auto [tx, keylet] = vault.create(
    -                {.owner = owner,
    -                 .asset = asset,
    -                 .vaultKind = closedEnded,
    -                 .subscriptionDate = std::numeric_limits::max(),
    -                 .redemptionDate = red});
    -            env(tx, Ter{temMALFORMED});
    -        };
    -        rejectAtMax(std::numeric_limits::max());
    -        rejectAtMax(0u);
    -        rejectAtMax(kMinInvestmentPeriod - 1u);
    -    }
    -
    -    // A loan whose payment is made after the Investment phase has ended
    -    // (well past its next-due-date and grace period, into Redemption) must
    -    // still be repayable. The vault phase must not gate LoanPay.
    -    void
    -    testVaultLoanLatePaymentAfterInvestment()
    -    {
    -        testcase("closed-ended vault: late loan payment during Redemption succeeds");
    -        using namespace test::jtx;
    -        using namespace loan_broker;
    -        using namespace loan;
    -
    -        Env env{*this, testableAmendments()};
    -        Account const owner{"owner"};
    -        Account const alice{"alice"};
    -        Account const borrower{"borrower"};
    -        env.fund(XRP(10'000), owner, alice, borrower);
    -        env.close();
    -
    -        Asset const asset = xrpIssue();
    -        auto const [vault, keylet, sub, red] =
    -            makeClosedEndedVault(env, owner, asset, 300u, kMinInvestmentPeriod + 3600u);
    -
    -        env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()}));
    -        env.close();
    -
    -        auto const brokerKeylet =
    -            keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
    -        env(loan_broker::set(owner, keylet.key));
    -        env.close();
    -
    -        // Investment phase: originate a zero-interest, single-payment loan
    -        // with a 300s payment interval and 60s grace. The payment is due
    -        // shortly after origination and well before RedemptionDate.
    -        env.close(tp{d{sub + 1}});
    -        env(loan::set(borrower, brokerKeylet.key, XRP(60).value()),
    -            loan::kInterestRate(TenthBips32(0)),
    -            kGracePeriod(60),
    -            kPaymentInterval(300),
    -            kPaymentTotal(1),
    -            Sig(sfCounterpartySignature, owner),
    -            Fee(env.current()->fees().base * 2));
    -        env.close();
    -        auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1u));
    -        BEAST_EXPECT(env.le(loanKeylet));
    -
    -        // Advance to Redemption. The payment is now past its due date and
    -        // grace, and the vault is no longer in Investment.
    -        closeToTime(env, tp{d{red}});
    -
    -        env(loan::pay(borrower, loanKeylet.key, XRP(60).value(), tfLoanLatePayment));
    -        env.close();
    -
    -        // Loan principal returned to the vault; assetsAvailable == assetsTotal.
    -        auto const sleAfter = env.le(keylet);
    -        if (BEAST_EXPECT(sleAfter))
    -        {
    -            BEAST_EXPECT(sleAfter->at(sfAssetsAvailable) == sleAfter->at(sfAssetsTotal));
    -            BEAST_EXPECT(sleAfter->at(sfAssetsAvailable) == XRP(100).value());
    -        }
    -
    -        env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()}));
    -        env.close();
    -    }
    -
    -    // Two concurrent loans against the same closed-ended vault in Investment
    -    // must coexist: both loan SLEs are created, AssetsAvailable reflects the
    -    // sum of the two outstanding principals, and each can be repaid
    -    // independently.
    -    void
    -    testVaultClosedEndedMultipleLoans()
    -    {
    -        testcase("closed-ended vault: multiple concurrent loans in Investment");
    -        using namespace test::jtx;
    -        using namespace loan_broker;
    -        using namespace loan;
    -
    -        Env env{*this, testableAmendments()};
    -        Account const owner{"owner"};
    -        Account const alice{"alice"};
    -        Account const bob{"bob"};
    -        Account const borrower1{"borrower1"};
    -        Account const borrower2{"borrower2"};
    -        env.fund(XRP(10'000), owner, alice, bob, borrower1, borrower2);
    -        env.close();
    -
    -        Asset const asset = xrpIssue();
    -        auto const [vault, keylet, sub, red] =
    -            makeClosedEndedVault(env, owner, asset, 300u, kMinInvestmentPeriod + 3600u);
    -
    -        env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()}));
    -        env.close();
    -        env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = XRP(100).value()}));
    -        env.close();
    -
    -        auto const brokerKeylet =
    -            keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
    -        env(loan_broker::set(owner, keylet.key));
    -        env.close();
    -
    -        env.close(tp{d{sub + 1}});
    -
    -        auto const originate = [&](Account const& b, STAmount const& principal) {
    -            env(loan::set(b, brokerKeylet.key, principal),
    -                loan::kInterestRate(TenthBips32(0)),
    -                kGracePeriod(60),
    -                kPaymentInterval(300),
    -                kPaymentTotal(1),
    -                Sig(sfCounterpartySignature, owner),
    -                Fee(env.current()->fees().base * 2));
    -            env.close();
    -        };
    -        originate(borrower1, XRP(50).value());
    -        originate(borrower2, XRP(70).value());
    -
    -        auto const loan1 = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1u));
    -        auto const loan2 = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(2u));
    -        BEAST_EXPECT(env.le(loan1));
    -        BEAST_EXPECT(env.le(loan2));
    -
    -        // Zero-interest at origination: AssetsTotal unchanged, AssetsAvailable
    -        // drops by the sum of the two loan principals.
    -        {
    -            auto const sle = env.le(keylet);
    -            if (BEAST_EXPECT(sle))
    -            {
    -                BEAST_EXPECT(sle->at(sfAssetsTotal) == XRP(200).value());
    -                BEAST_EXPECT(sle->at(sfAssetsAvailable) == XRP(80).value());
    -            }
    -        }
    -
    -        // Repay the first loan; the second remains outstanding.
    -        env(loan::pay(borrower1, loan1.key, XRP(50).value()));
    -        env.close();
    -        {
    -            auto const sle = env.le(keylet);
    -            if (BEAST_EXPECT(sle))
    -            {
    -                BEAST_EXPECT(sle->at(sfAssetsTotal) == XRP(200).value());
    -                BEAST_EXPECT(sle->at(sfAssetsAvailable) == XRP(130).value());
    -            }
    -        }
    -
    -        // Repay the second loan; vault is fully liquid again.
    -        env(loan::pay(borrower2, loan2.key, XRP(70).value()));
    -        env.close();
    -        {
    -            auto const sle = env.le(keylet);
    -            if (BEAST_EXPECT(sle))
    -            {
    -                BEAST_EXPECT(sle->at(sfAssetsAvailable) == sle->at(sfAssetsTotal));
    -                BEAST_EXPECT(sle->at(sfAssetsAvailable) == XRP(200).value());
    -            }
    -        }
    -
    -        // Redemption: both depositors withdraw in full.
    -        env.close(tp{d{red}});
    -        env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()}));
    -        env.close();
    -        env(vault.withdraw({.depositor = bob, .id = keylet.key, .amount = XRP(100).value()}));
    -        env.close();
    -    }
    -
    -    // VaultClawback has no phase gate: an issuer must be able to reclaim
    -    // asset from a depositor in Subscription, Investment and Redemption
    -    // alike. Uses an IOU with asfAllowTrustLineClawback so the issuer path
    -    // is exercised (XRP clawback with an explicit amount is temMALFORMED).
    -    void
    -    testVaultClawbackClosedEndedPhases()
    -    {
    -        testcase("closed-ended vault: VaultClawback succeeds in each phase");
    -        using namespace test::jtx;
    -
    -        Env env{*this, testableAmendments()};
    -        Account const issuer{"issuer"};
    -        Account const owner{"owner"};
    -        Account const alice{"alice"};
    -        env.fund(XRP(10'000), issuer, owner, alice);
    -        env.close();
    -
    -        env(fset(issuer, asfAllowTrustLineClawback));
    -        env.close();
    -
    -        PrettyAsset const iou = issuer["IOU"];
    -        env.trust(iou(10'000), alice);
    -        env(pay(issuer, alice, iou(1'000)));
    -        env.close();
    -
    -        auto const [vault, keylet, sub, red] =
    -            makeClosedEndedVault(env, owner, iou, 300u, kMinInvestmentPeriod + 3600u);
    -
    -        env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = iou(300).value()}));
    -        env.close();
    -
    -        auto const totalsEq = [&](STAmount const& expected) {
    -            auto const sle = env.le(keylet);
    -            if (BEAST_EXPECT(sle))
    -                BEAST_EXPECT(sle->at(sfAssetsTotal) == expected);
    -        };
    -
    -        // Subscription phase clawback.
    -        env(vault.clawback(
    -            {.issuer = issuer, .id = keylet.key, .holder = alice, .amount = iou(10).value()}));
    -        env.close();
    -        totalsEq(iou(290).value());
    -
    -        // Investment phase clawback.
    -        env.close(tp{d{sub + 1}});
    -        env(vault.clawback(
    -            {.issuer = issuer, .id = keylet.key, .holder = alice, .amount = iou(10).value()}));
    -        env.close();
    -        totalsEq(iou(280).value());
    -
    -        // Redemption phase clawback.
    -        env.close(tp{d{red}});
    -        env(vault.clawback(
    -            {.issuer = issuer, .id = keylet.key, .holder = alice, .amount = iou(10).value()}));
    -        env.close();
    -        totalsEq(iou(270).value());
    -    }
    -
    -    // Test for non-asset specific behaviors.
    -    void
    -    testCreateFailXRP()
    -    {
    -        using namespace test::jtx;
    -
    -        auto testCase = [this](
    -                            std::function test) {
    -            Env env{*this, testableAmendments()};
    -            Account const issuer{"issuer"};
    -            Account const owner{"owner"};
    -            Account const depositor{"depositor"};
    -
    -            env.fund(XRP(1000), issuer, owner, depositor);
    -            env.close();
    -            Vault vault{env};
    -            Asset const asset = xrpIssue();
    -
    -            test(env, issuer, owner, depositor, asset, vault);
    -        };
    -
    -        testCase([this](
    -                     Env& env,
    -                     Account const& issuer,
    -                     Account const& owner,
    -                     Account const& depositor,
    -                     PrettyAsset const& asset,
    -                     Vault& vault) {
    -            testcase("nothing to set");
    -            auto tx = vault.set({.owner = owner, .id = keylet::skip().key});
    -            tx[sfAssetsMaximum] = asset(0).number();
    -            env(tx, Ter(tecNO_ENTRY));
    -        });
    -
    -        testCase([this](
    -                     Env& env,
    -                     Account const& issuer,
    -                     Account const& owner,
    -                     Account const& depositor,
    -                     PrettyAsset const& asset,
    -                     Vault& vault) {
    -            testcase("nothing to deposit to");
    -            auto tx = vault.deposit(
    -                {.depositor = depositor, .id = keylet::skip().key, .amount = asset(10)});
    -            env(tx, Ter(tecNO_ENTRY));
    -        });
    -
    -        testCase([this](
    -                     Env& env,
    -                     Account const& issuer,
    -                     Account const& owner,
    -                     Account const& depositor,
    -                     PrettyAsset const& asset,
    -                     Vault& vault) {
    -            testcase("nothing to withdraw from");
    -            auto tx = vault.withdraw(
    -                {.depositor = depositor, .id = keylet::skip().key, .amount = asset(10)});
    -            env(tx, Ter(tecNO_ENTRY));
    -        });
    -
    -        testCase([this](
    -                     Env& env,
    -                     Account const& issuer,
    -                     Account const& owner,
    -                     Account const& depositor,
    -                     Asset const& asset,
    -                     Vault& vault) {
    -            testcase("nothing to delete");
    -            auto tx = vault.del({.owner = owner, .id = keylet::skip().key});
    -            env(tx, Ter(tecNO_ENTRY));
    -        });
    -
    -        testCase([this](
    -                     Env& env,
    -                     Account const& issuer,
    -                     Account const& owner,
    -                     Account const& depositor,
    -                     Asset const& asset,
    -                     Vault& vault) {
    -            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -            testcase("transaction is good");
    -            env(tx);
    -        });
    -
    -        testCase([this](
    -                     Env& env,
    -                     Account const& issuer,
    -                     Account const& owner,
    -                     Account const& depositor,
    -                     Asset const& asset,
    -                     Vault& vault) {
    -            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -            tx[sfWithdrawalPolicy] = 1;
    -            testcase("explicitly select withdrawal policy");
    -            env(tx);
    -        });
    -
    -        testCase([this](
    -                     Env& env,
    -                     Account const& issuer,
    -                     Account const& owner,
    -                     Account const& depositor,
    -                     Asset const& asset,
    -                     Vault& vault) {
    -            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -            testcase("insufficient fee");
    -            env(tx, Fee(env.current()->fees().base - 1), Ter(telINSUF_FEE_P));
    -        });
    -
    -        testCase([this](
    -                     Env& env,
    -                     Account const& issuer,
    -                     Account const& owner,
    -                     Account const& depositor,
    -                     Asset const& asset,
    -                     Vault& vault) {
    -            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -            testcase("insufficient reserve");
    -            // It is possible to construct a complicated mathematical
    -            // expression for this amount, but it is sadly not easy.
    -            env(pay(owner, issuer, XRP(775)));
    -            env.close();
    -            env(tx, Ter(tecINSUFFICIENT_RESERVE));
    -        });
    -
    -        testCase([this](
    -                     Env& env,
    -                     Account const& issuer,
    -                     Account const& owner,
    -                     Account const& depositor,
    -                     Asset const& asset,
    -                     Vault& vault) {
    -            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -            tx[sfFlags] = tfVaultPrivate;
    -            tx[sfDomainID] = to_string(BaseUInt<256>(42ul));
    -            testcase("non-existing domain");
    -            env(tx, Ter{tecOBJECT_NOT_FOUND});
    -        });
    -
    -        testCase([this](
    -                     Env& env,
    -                     Account const& issuer,
    -                     Account const& owner,
    -                     Account const& depositor,
    -                     Asset const& asset,
    -                     Vault& vault) {
    -            testcase("cannot set Scale=0");
    -            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -            tx[sfScale] = 0;
    -            env(tx, Ter{temMALFORMED});
    -        });
    -
    -        testCase([this](
    -                     Env& env,
    -                     Account const& issuer,
    -                     Account const& owner,
    -                     Account const& depositor,
    -                     Asset const& asset,
    -                     Vault& vault) {
    -            testcase("cannot set Scale=1");
    -            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -            tx[sfScale] = 1;
    -            env(tx, Ter{temMALFORMED});
    -        });
    -    }
    -
    -    void
    -    testCreateFailIOU()
    -    {
    -        using namespace test::jtx;
    -        {
    -            {
    -                testcase("IOU fail because MPT is disabled");
    -                Env env{*this, (testableAmendments() - featureMPTokensV1)};
    -                Account const issuer{"issuer"};
    -                Account const owner{"owner"};
    -                env.fund(XRP(1000), issuer, owner);
    -                env.close();
    -
    -                Vault const vault{env};
    -                Asset const asset = issuer["IOU"].asset();
    -                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -
    -                env(tx, Ter(temDISABLED));
    -                env.close();
    -            }
    -
    -            {
    -                testcase("IOU fail create frozen");
    -                Env env{*this, testableAmendments()};
    -                Account const issuer{"issuer"};
    -                Account const owner{"owner"};
    -                env.fund(XRP(1000), issuer, owner);
    -                env.close();
    -                env(fset(issuer, asfGlobalFreeze));
    -                env.close();
    -
    -                Vault const vault{env};
    -                Asset const asset = issuer["IOU"].asset();
    -                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -
    -                env(tx, Ter(tecFROZEN));
    -                env.close();
    -            }
    -
    -            {
    -                testcase("IOU fail create no ripling");
    -                Env env{*this, testableAmendments()};
    -                Account const issuer{"issuer"};
    -                Account const owner{"owner"};
    -                env.fund(XRP(1000), issuer, owner);
    -                env.close();
    -                env(fclear(issuer, asfDefaultRipple));
    -                env.close();
    -
    -                Vault const vault{env};
    -                Asset const asset = issuer["IOU"].asset();
    -                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -                env(tx, Ter(terNO_RIPPLE));
    -                env.close();
    -            }
    -
    -            {
    -                testcase("IOU no issuer");
    -                Env env{*this, testableAmendments()};
    -                Account const issuer{"issuer"};
    -                Account const owner{"owner"};
    -                env.fund(XRP(1000), owner);
    -                env.close();
    -
    -                Vault const vault{env};
    -                Asset const asset = issuer["IOU"].asset();
    -                {
    -                    auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -                    env(tx, Ter(terNO_ACCOUNT));
    -                    env.close();
    -                }
    -            }
    -        }
    -
    -        {
    -            testcase("IOU fail create vault for AMM LPToken");
    -            Env env{*this, testableAmendments()};
    -            Account const gw("gateway");
    -            Account const alice("alice");
    -            Account const carol("carol");
    -            IOU const usd = gw["USD"];
    -
    -            auto const [asset1, asset2] = std::pair(XRP(10000), usd(10000));
    -            auto toFund = [&](STAmount const& a) -> STAmount {
    -                if (a.native())
    -                {
    -                    auto const defXRP = XRP(30000);
    -                    if (a <= defXRP)
    -                        return defXRP;
    -                    return a + XRP(1000);
    -                }
    -                auto defIOU = STAmount{a.asset(), 30000};
    -                if (a <= defIOU)
    -                    return defIOU;
    -                return a + STAmount{a.asset(), 1000};
    -            };
    -            auto const toFund1 = toFund(asset1);
    -            auto const toFund2 = toFund(asset2);
    -            BEAST_EXPECT(asset1 <= toFund1 && asset2 <= toFund2);
    -
    -            if (!asset1.native() && !asset2.native())
    -            {
    -                fund(env, gw, {alice, carol}, {toFund1, toFund2}, Fund::All);
    -            }
    -            else if (asset1.native())
    -            {
    -                fund(env, gw, {alice, carol}, toFund1, {toFund2}, Fund::All);
    -            }
    -            else if (asset2.native())
    -            {
    -                fund(env, gw, {alice, carol}, toFund2, {toFund1}, Fund::All);
    -            }
    -
    -            AMM const ammAlice(env, alice, asset1, asset2, CreateArg{.log = false, .tfee = 0});
    -
    -            Account const owner{"owner"};
    -            env.fund(XRP(1000000), owner);
    -
    -            Vault const vault{env};
    -            auto [tx, k] = vault.create({.owner = owner, .asset = ammAlice.lptIssue()});
    -            env(tx, Ter{tecWRONG_ASSET});
    -            env.close();
    -        }
    -    }
    -
    -    void
    -    testCreateFailMPT()
    -    {
    -        using namespace test::jtx;
    -
    -        auto testCase = [this](
    -                            std::function test) {
    -            Env env{*this, testableAmendments()};
    -            Account const issuer{"issuer"};
    -            Account const owner{"owner"};
    -            Account const depositor{"depositor"};
    -            env.fund(XRP(1000), issuer, owner, depositor);
    -            env.close();
    -            Vault vault{env};
    -            MPTTester mptt{env, issuer, kMptInitNoFund};
    -            // Locked because that is the default flag.
    -            mptt.create();
    -            Asset const asset = mptt.issuanceID();
    -
    -            test(env, issuer, owner, depositor, asset, vault);
    -        };
    -
    -        testCase([this](
    -                     Env& env,
    -                     Account const& issuer,
    -                     Account const& owner,
    -                     Account const& depositor,
    -                     Asset const& asset,
    -                     Vault& vault) {
    -            testcase("MPT no authorization");
    -            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -            env(tx, Ter(tecNO_AUTH));
    -        });
    -
    -        testCase([this](
    -                     Env& env,
    -                     Account const& issuer,
    -                     Account const& owner,
    -                     Account const& depositor,
    -                     Asset const& asset,
    -                     Vault& vault) {
    -            testcase("MPT cannot set Scale=0");
    -            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -            tx[sfScale] = 0;
    -            env(tx, Ter{temMALFORMED});
    -        });
    -
    -        testCase([this](
    -                     Env& env,
    -                     Account const& issuer,
    -                     Account const& owner,
    -                     Account const& depositor,
    -                     Asset const& asset,
    -                     Vault& vault) {
    -            testcase("MPT cannot set Scale=1");
    -            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -            tx[sfScale] = 1;
    -            env(tx, Ter{temMALFORMED});
    -        });
    -    }
    -
    -    void
    -    testNonTransferableShares()
    -    {
    -        using namespace test::jtx;
    -
    -        Env env{*this, testableAmendments()};
    -        Account const issuer{"issuer"};
    -        Account const owner{"owner"};
    -        Account const depositor{"depositor"};
    -        env.fund(XRP(1000), issuer, owner, depositor);
    -        env.close();
    -
    -        Vault const vault{env};
    -        PrettyAsset const asset = issuer["IOU"];
    -        env.trust(asset(1000), owner);
    -        env(pay(issuer, owner, asset(100)));
    -        env.trust(asset(1000), depositor);
    -        env(pay(issuer, depositor, asset(100)));
    -        env.close();
    -
    -        auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -        tx[sfFlags] = tfVaultShareNonTransferable;
    -        env(tx);
    -        env.close();
    -
    -        {
    -            testcase("nontransferable deposits");
    -            auto tx1 =
    -                vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(40)});
    -            env(tx1);
    -
    -            auto tx2 = vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(60)});
    -            env(tx2);
    -            env.close();
    -        }
    -
    -        auto const vaultAccount =  //
    -            [&env, key = keylet.key, this]() -> AccountID {
    -            auto jvVault = env.rpc("vault_info", strHex(key));
    -
    -            BEAST_EXPECT(jvVault[jss::result][jss::vault][sfAssetsTotal] == "100");
    -            BEAST_EXPECT(
    -                jvVault[jss::result][jss::vault][jss::shares][sfOutstandingAmount] == "100000000");
    -
    -            // Vault pseudo-account
    -            return parseBase58(jvVault[jss::result][jss::vault][jss::Account].asString())
    -                .value();
    -        }();
    -
    -        auto const mptId = makeMptID(1, vaultAccount);
    -        Asset const shares = mptId;
    -
    -        {
    -            testcase("nontransferable shares cannot be moved");
    -            env(pay(owner, depositor, shares(10)), Ter{tecNO_AUTH});
    -            env(pay(depositor, owner, shares(10)), Ter{tecNO_AUTH});
    -        }
    -
    -        {
    -            testcase("nontransferable shares can be used to withdraw");
    -            auto tx1 =
    -                vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(20)});
    -            env(tx1);
    -
    -            auto tx2 = vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(30)});
    -            env(tx2);
    -            env.close();
    -        }
    -
    -        {
    -            testcase("nontransferable shares balance check");
    -            auto jvVault = env.rpc("vault_info", strHex(keylet.key));
    -            BEAST_EXPECT(jvVault[jss::result][jss::vault][sfAssetsTotal] == "50");
    -            BEAST_EXPECT(
    -                jvVault[jss::result][jss::vault][jss::shares][sfOutstandingAmount] == "50000000");
    -        }
    -
    -        {
    -            testcase("nontransferable shares withdraw rest");
    -            auto tx1 =
    -                vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(20)});
    -            env(tx1);
    -
    -            auto tx2 = vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(30)});
    -            env(tx2);
    -            env.close();
    -        }
    -
    -        {
    -            testcase("nontransferable shares delete empty vault");
    -            auto tx = vault.del({.owner = owner, .id = keylet.key});
    -            env(tx);
    -            BEAST_EXPECT(!env.le(keylet));
    -        }
    -    }
    -
    -    void
    -    testWithMPT()
    -    {
    -        using namespace test::jtx;
    -
    -        struct CaseArgs
    -        {
    -            bool enableClawback = true;
    -            bool requireAuth = true;
    -            int initialXRP = 1000;
    -            FeatureBitset features = testableAmendments();
    -        };
    -
    -        auto testCase = [this](
    -                            std::function test,
    -                            CaseArgs args = {}) {
    -            Env env{*this, args.features};
    -            Account const issuer{"issuer"};
    -            Account const owner{"owner"};
    -            Account const depositor{"depositor"};
    -            env.fund(XRP(args.initialXRP), issuer, owner, depositor);
    -            env.close();
    -            Vault vault{env};
    -
    -            MPTTester mptt{env, issuer, kMptInitNoFund};
    -            auto const kNone = LedgerSpecificFlags(0);
    -            mptt.create(
    -                {.flags = tfMPTCanTransfer | tfMPTCanLock |
    -                     (args.enableClawback ? tfMPTCanClawback : kNone) |
    -                     (args.requireAuth ? tfMPTRequireAuth : kNone)});
    -            PrettyAsset const asset = mptt.issuanceID();
    -            mptt.authorize({.account = owner});
    -            mptt.authorize({.account = depositor});
    -            if (args.requireAuth)
    -            {
    -                mptt.authorize({.account = issuer, .holder = owner});
    -                mptt.authorize({.account = issuer, .holder = depositor});
    -            }
    -
    -            env(pay(issuer, depositor, asset(1000)));
    -            env.close();
    -
    -            test(env, issuer, owner, depositor, asset, vault, mptt);
    -        };
    -
    -        testCase([this](
    -                     Env& env,
    -                     Account const& issuer,
    -                     Account const& owner,
    -                     Account const& depositor,
    -                     PrettyAsset const& asset,
    -                     Vault& vault,
    -                     MPTTester& mptt) {
    -            testcase("MPT nothing to clawback from");
    -            auto tx = vault.clawback(
    -                {.issuer = issuer,
    -                 .id = keylet::skip().key,
    -                 .holder = depositor,
    -                 .amount = asset(10)});
    -            env(tx, Ter(tecNO_ENTRY));
    -        });
    -
    -        testCase([this](
    -                     Env& env,
    -                     Account const& issuer,
    -                     Account const& owner,
    -                     Account const& depositor,
    -                     Asset const& asset,
    -                     Vault& vault,
    -                     MPTTester& mptt) {
    -            testcase("MPT global lock blocks create");
    -            mptt.set({.account = issuer, .flags = tfMPTLock});
    -            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -            env(tx, Ter(tecLOCKED));
    -        });
    -
    -        testCase([this](
    -                     Env& env,
    -                     Account const& issuer,
    -                     Account const& owner,
    -                     Account const& depositor,
    -                     PrettyAsset const& asset,
    -                     Vault& vault,
    -                     MPTTester& mptt) {
    -            testcase("MPT only issuer can clawback");
    -
    -            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -            env(tx);
    -            env.close();
    -
    -            tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)});
    -            env(tx);
    -            env.close();
    -
    -            {
    -                auto tx = vault.clawback({
    -                    .issuer = depositor,
    -                    .id = keylet.key,
    -                    .holder = depositor,
    -                });
    -                env(tx, Ter(tecNO_PERMISSION));
    -            }
    -
    -            {
    -                auto tx = vault.clawback({
    -                    .issuer = owner,
    -                    .id = keylet.key,
    -                    .holder = depositor,
    -                });
    -                env(tx, Ter(tecNO_PERMISSION));
    -            }
    -        });
    -
    -        testCase(
    -            [this](
    -                Env& env,
    -                Account const& issuer,
    -                Account const& owner,
    -                Account const& depositor,
    -                PrettyAsset const& asset,
    -                Vault& vault,
    -                MPTTester& mptt) {
    -                testcase("MPT depositor without MPToken, auth required");
    -
    -                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -                env(tx);
    -                env.close();
    -
    -                tx = vault.deposit(
    -                    {.depositor = depositor, .id = keylet.key, .amount = asset(1000)});
    -                env(tx);
    -                env.close();
    -
    -                {
    -                    // Remove depositor MPToken and it will not be re-created
    -                    mptt.authorize({.account = depositor, .flags = tfMPTUnauthorize});
    -                    env.close();
    -
    -                    auto const mptoken = keylet::mptoken(mptt.issuanceID(), depositor);
    -                    auto const sleMPT1 = env.le(mptoken);
    -                    BEAST_EXPECT(sleMPT1 == nullptr);
    -
    -                    tx = vault.withdraw(
    -                        {.depositor = depositor, .id = keylet.key, .amount = asset(100)});
    -                    env(tx, Ter{tecNO_AUTH});
    -                    env.close();
    -
    -                    auto const sleMPT2 = env.le(mptoken);
    -                    BEAST_EXPECT(sleMPT2 == nullptr);
    -                }
    -
    -                {
    -                    // Set destination to 3rd party without MPToken
    -                    Account const charlie{"charlie"};
    -                    env.fund(XRP(1000), charlie);
    -                    env.close();
    -
    -                    tx = vault.withdraw(
    -                        {.depositor = depositor, .id = keylet.key, .amount = asset(100)});
    -                    tx[sfDestination] = charlie.human();
    -                    env(tx, Ter(tecNO_AUTH));
    -                }
    -            },
    -            {.requireAuth = true});
    -
    -        testCase(
    -            [this](
    -                Env& env,
    -                Account const& issuer,
    -                Account const& owner,
    -                Account const& depositor,
    -                PrettyAsset const& asset,
    -                Vault& vault,
    -                MPTTester& mptt) {
    -                testcase("MPT depositor without MPToken, no auth required");
    -
    -                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -                env(tx);
    -                env.close();
    -                auto v = env.le(keylet);
    -                BEAST_EXPECT(v);
    -
    -                tx = vault.deposit(
    -                    {.depositor = depositor,
    -                     .id = keylet.key,
    -                     .amount = asset(1000)});  // all assets held by depositor
    -                env(tx);
    -                env.close();
    -
    -                {
    -                    // Remove depositor's MPToken and it will be re-created
    -                    mptt.authorize({.account = depositor, .flags = tfMPTUnauthorize});
    -                    env.close();
    -
    -                    auto const mptoken = keylet::mptoken(mptt.issuanceID(), depositor);
    -                    auto const sleMPT1 = env.le(mptoken);
    -                    BEAST_EXPECT(sleMPT1 == nullptr);
    -
    -                    tx = vault.withdraw(
    -                        {.depositor = depositor, .id = keylet.key, .amount = asset(100)});
    -                    env(tx);
    -                    env.close();
    -
    -                    auto const sleMPT2 = env.le(mptoken);
    -                    BEAST_EXPECT(sleMPT2 != nullptr);
    -                    BEAST_EXPECT(sleMPT2->at(sfMPTAmount) == 100);
    -                }
    -
    -                {
    -                    // Remove 3rd party MPToken and it will not be re-created
    -                    mptt.authorize({.account = owner, .flags = tfMPTUnauthorize});
    -                    env.close();
    -
    -                    auto const mptoken = keylet::mptoken(mptt.issuanceID(), owner);
    -                    auto const sleMPT1 = env.le(mptoken);
    -                    BEAST_EXPECT(sleMPT1 == nullptr);
    -
    -                    tx = vault.withdraw(
    -                        {.depositor = depositor, .id = keylet.key, .amount = asset(100)});
    -                    tx[sfDestination] = owner.human();
    -                    env(tx, Ter(tecNO_AUTH));
    -                    env.close();
    -
    -                    auto const sleMPT2 = env.le(mptoken);
    -                    BEAST_EXPECT(sleMPT2 == nullptr);
    -                }
    -            },
    -            {.requireAuth = false});
    -
    -        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,
    -                Account const& issuer,
    -                Account const& owner,
    -                Account const& depositor,
    -                PrettyAsset const& asset,
    -                Vault& vault,
    -                MPTTester& mptt) {
    -                testcase("MPT fail reserve to re-create MPToken");
    -
    -                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -                env(tx);
    -                env.close();
    -                auto v = env.le(keylet);
    -                BEAST_EXPECT(v);
    -
    -                env(pay(depositor, owner, asset(1000)));
    -                env.close();
    -
    -                tx = vault.deposit(
    -                    {.depositor = owner,
    -                     .id = keylet.key,
    -                     .amount = asset(1000)});  // all assets held by owner
    -                env(tx);
    -                env.close();
    -
    -                {
    -                    // Remove owners's MPToken and it will not be re-created
    -                    mptt.authorize({.account = owner, .flags = tfMPTUnauthorize});
    -                    env.close();
    -
    -                    auto const mptoken = keylet::mptoken(mptt.issuanceID(), owner);
    -                    auto const sleMPT = env.le(mptoken);
    -                    BEAST_EXPECT(sleMPT == nullptr);
    -
    -                    // Use one reserve so the next transaction fails
    -                    env(ticket::create(owner, 1));
    -                    env.close();
    -
    -                    // No reserve to create MPToken for asset in VaultWithdraw
    -                    tx = vault.withdraw(
    -                        {.depositor = owner, .id = keylet.key, .amount = asset(100)});
    -                    env(tx, Ter{tecINSUFFICIENT_RESERVE});
    -                    env.close();
    -
    -                    env(pay(depositor, owner, XRP(incReserve)));
    -                    env.close();
    -
    -                    // Withdraw can now create asset MPToken, tx will succeed
    -                    env(tx);
    -                    env.close();
    -                }
    -            },
    -            {.requireAuth = false, .initialXRP = acctReserve + (incReserve * 4) + 1});
    -
    -        testCase([this](
    -                     Env& env,
    -                     Account const& issuer,
    -                     Account const& owner,
    -                     Account const& depositor,
    -                     PrettyAsset const& asset,
    -                     Vault& vault,
    -                     MPTTester& mptt) {
    -            testcase("MPT issuance deleted");
    -
    -            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -            env(tx);
    -            env.close();
    -
    -            tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1000)});
    -            env(tx);
    -            env.close();
    -
    -            {
    -                auto tx = vault.clawback(
    -                    {.issuer = issuer, .id = keylet.key, .holder = depositor, .amount = asset(0)});
    -                env(tx);
    -            }
    -
    -            mptt.destroy({.issuer = issuer, .id = mptt.issuanceID()});
    -            env.close();
    -
    -            {
    -                auto [tx, keylet] = vault.create({.owner = depositor, .asset = asset});
    -                env(tx, Ter{tecOBJECT_NOT_FOUND});
    -            }
    -
    -            {
    -                auto tx =
    -                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(10)});
    -                env(tx, Ter{tecOBJECT_NOT_FOUND});
    -            }
    -
    -            {
    -                auto tx =
    -                    vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(10)});
    -                env(tx, Ter{tecOBJECT_NOT_FOUND});
    -            }
    -
    -            {
    -                auto tx = vault.clawback(
    -                    {.issuer = issuer, .id = keylet.key, .holder = depositor, .amount = asset(0)});
    -                env(tx, Ter{tecOBJECT_NOT_FOUND});
    -            }
    -
    -            env(vault.del({.owner = owner, .id = keylet.key}));
    -        });
    -
    -        testCase([this](
    -                     Env& env,
    -                     Account const& issuer,
    -                     Account const& owner,
    -                     Account const& depositor,
    -                     PrettyAsset const& asset,
    -                     Vault& vault,
    -                     MPTTester& mptt) {
    -            testcase("MPT vault owner can receive shares unless unauthorized");
    -
    -            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -            env(tx);
    -            env.close();
    -
    -            tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1000)});
    -            env(tx);
    -            env.close();
    -
    -            auto const issuanceId = [&env](xrpl::Keylet keylet) -> MPTID {
    -                auto const vault = env.le(keylet);
    -                return vault->at(sfShareMPTID);
    -            }(keylet);
    -            PrettyAsset const shares = MPTIssue(issuanceId);
    -
    -            {
    -                // owner has MPToken for shares they did not explicitly create
    -                env(pay(depositor, owner, shares(1)));
    -                env.close();
    -
    -                tx = vault.withdraw({.depositor = owner, .id = keylet.key, .amount = shares(1)});
    -                env(tx);
    -                env.close();
    -
    -                // owner's MPToken for vault shares not destroyed by withdraw
    -                env(pay(depositor, owner, shares(1)));
    -                env.close();
    -
    -                tx = vault.clawback(
    -                    {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(0)});
    -                env(tx);
    -                env.close();
    -
    -                // owner's MPToken for vault shares not destroyed by clawback
    -                env(pay(depositor, owner, shares(1)));
    -                env.close();
    -
    -                // pay back, so we can destroy owner's MPToken now
    -                env(pay(owner, depositor, shares(1)));
    -                env.close();
    -
    -                {
    -                    // explicitly destroy vault owners MPToken with zero balance
    -                    json::Value jv;
    -                    jv[sfAccount] = owner.human();
    -                    jv[sfMPTokenIssuanceID] = to_string(issuanceId);
    -                    jv[sfFlags] = tfMPTUnauthorize;
    -                    jv[sfTransactionType] = jss::MPTokenAuthorize;
    -                    env(jv);
    -                    env.close();
    -                }
    -
    -                // owner no longer has MPToken for vault shares
    -                tx = pay(depositor, owner, shares(1));
    -                env(tx, Ter{tecNO_AUTH});
    -                env.close();
    -
    -                // destroy all remaining shares, so we can delete vault
    -                tx = vault.clawback(
    -                    {.issuer = issuer, .id = keylet.key, .holder = depositor, .amount = asset(0)});
    -                env(tx);
    -                env.close();
    -
    -                // will soft fail destroying MPToken for vault owner
    -                env(vault.del({.owner = owner, .id = keylet.key}));
    -                env.close();
    -            }
    -        });
    -
    -        testCase(
    -            [this](
    -                Env& env,
    -                Account const& issuer,
    -                Account const& owner,
    -                Account const& depositor,
    -                PrettyAsset const& asset,
    -                Vault& vault,
    -                MPTTester& mptt) {
    -                testcase("MPT clawback disabled");
    -
    -                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -                env(tx);
    -                env.close();
    -
    -                tx = vault.deposit(
    -                    {.depositor = depositor, .id = keylet.key, .amount = asset(1000)});
    -                env(tx);
    -                env.close();
    -
    -                {
    -                    auto tx = vault.clawback(
    -                        {.issuer = issuer,
    -                         .id = keylet.key,
    -                         .holder = depositor,
    -                         .amount = asset(0)});
    -                    env(tx, Ter{tecNO_PERMISSION});
    -                }
    -            },
    -            {.enableClawback = false});
    -
    -        testCase([this](
    -                     Env& env,
    -                     Account const& issuer,
    -                     Account const& owner,
    -                     Account const& depositor,
    -                     Asset const& asset,
    -                     Vault& vault,
    -                     MPTTester& mptt) {
    -            testcase("MPT un-authorization");
    -            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -            env(tx);
    -            env.close();
    -            tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1000)});
    -            env(tx);
    -            env.close();
    -
    -            mptt.authorize({.account = issuer, .holder = depositor, .flags = tfMPTUnauthorize});
    -            env.close();
    -
    -            {
    -                auto tx = vault.withdraw(
    -                    {.depositor = depositor, .id = keylet.key, .amount = asset(100)});
    -                env(tx, Ter(tecNO_AUTH));
    -
    -                // Withdrawal to other (authorized) accounts works
    -                tx[sfDestination] = issuer.human();
    -                env(tx);
    -                env.close();
    -
    -                tx[sfDestination] = owner.human();
    -                env(tx);
    -                env.close();
    -            }
    -
    -            {
    -                // Cannot deposit some more
    -                auto tx =
    -                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)});
    -                env(tx, Ter(tecNO_AUTH));
    -            }
    -
    -            {
    -                // Cannot clawback if issuer is the holder
    -                tx = vault.clawback(
    -                    {.issuer = issuer, .id = keylet.key, .holder = issuer, .amount = asset(800)});
    -                env(tx, Ter(tecNO_PERMISSION));
    -            }
    -            // Clawback works
    -            tx = vault.clawback(
    -                {.issuer = issuer, .id = keylet.key, .holder = depositor, .amount = asset(800)});
    -            env(tx);
    -            env.close();
    -
    -            env(vault.del({.owner = owner, .id = keylet.key}));
    -        });
    -
    -        {
    -            testcase("MPT shares to a vault");
    -
    -            Env env{*this, testableAmendments()};
    -            Account const owner{"owner"};
    -            Account const issuer{"issuer"};
    -            env.fund(XRP(1000000), owner, issuer);
    -            env.close();
    -            Vault const vault{env};
    -
    -            MPTTester mptt{env, issuer, kMptInitNoFund};
    -            mptt.create(
    -                {.flags = tfMPTCanTransfer | tfMPTCanLock | lsfMPTCanClawback | tfMPTRequireAuth});
    -            mptt.authorize({.account = owner});
    -            mptt.authorize({.account = issuer, .holder = owner});
    -            PrettyAsset const asset = mptt.issuanceID();
    -            env(pay(issuer, owner, asset(100)));
    -            auto [tx1, k1] = vault.create({.owner = owner, .asset = asset});
    -            env(tx1);
    -            env.close();
    -
    -            auto const shares = [&env, keylet = k1, this]() -> Asset {
    -                auto const vault = env.le(keylet);
    -                BEAST_EXPECT(vault != nullptr);
    -                return MPTIssue(vault->at(sfShareMPTID));
    -            }();
    -
    -            auto [tx2, k2] = vault.create({.owner = owner, .asset = shares});
    -            env(tx2, Ter{tecWRONG_ASSET});
    -            env.close();
    -        }
    -
    -        {
    -            testcase("MPT locked: vault shares inherit underlying lock");
    -
    -            Env env{*this, testableAmendments()};
    -            Account const issuer{"issuer"};
    -            Account const owner{"owner"};
    -            Account const alice{"alice"};
    -            Account const bob{"bob"};
    -            Account const carol{"carol"};
    -            env.fund(XRP(10'000), issuer, owner, alice, bob, carol);
    -            env.close();
    -            Vault const vault{env};
    -
    -            MPTTester asset{
    -                {.env = env,
    -                 .issuer = issuer,
    -                 .holders = {owner, alice, bob, carol},
    -                 .flags = tfMPTCanTransfer | tfMPTCanTrade | tfMPTCanLock}};
    -            env(pay(issuer, alice, asset(1'000)));
    -            env(pay(issuer, bob, asset(1'000)));
    -            env.close();
    -
    -            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -            env(tx);
    -            env.close();
    -
    -            env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = asset(500)}));
    -            // Bob also deposits so he has a share MPToken to receive into.
    -            env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = asset(500)}));
    -            env.close();
    -
    -            auto const shares = [&]() -> PrettyAsset {
    -                auto const sle = env.le(keylet);
    -                BEAST_EXPECT(sle != nullptr);
    -                return MPTIssue(sle->at(sfShareMPTID));
    -            }();
    -            auto const shareMptID = shares.raw().get().getMptID();
    -            auto const shareBalance = [&](Account const& account) {
    -                auto const sle = env.le(keylet::mptoken(shareMptID, account));
    -                return sle ? sle->at(sfMPTAmount) : 0;
    -            };
    -
    -            // Sanity: before the underlying lock, peer-to-peer share
    -            // transfers are allowed.
    -            env(pay(alice, bob, shares(1)));
    -            env.close();
    -
    -            // Create the offer while shares are spendable, then lock the
    -            // underlying to test whether a stale offer can still be crossed.
    -            env(offer(alice, XRP(1), shares(1)));
    -            env.close();
    -
    -            // Lock the underlying after the vault and share balances exist.
    -            asset.set({.account = issuer, .flags = tfMPTLock});
    -            env.close();
    -
    -            // Direct vault share payment inherits the underlying lock via
    -            // sfReferenceHolding.
    -            BEAST_EXPECT(shareBalance(alice) == 499);
    -            BEAST_EXPECT(shareBalance(bob) == 501);
    -            env(pay(alice, bob, shares(1)), Ter{tecLOCKED});
    -            env.close();
    -            BEAST_EXPECT(shareBalance(alice) == 499);
    -            BEAST_EXPECT(shareBalance(bob) == 501);
    -
    -            // The same inherited lock must also block DEX payment paths that
    -            // would consume an offer selling vault shares.
    -            env(pay(carol, bob, shares(1)),
    -                Sendmax(XRP(1)),
    -                Path(BookSpec{shares.raw()}),
    -                Ter{tecPATH_PARTIAL});
    -            env.close();
    -            BEAST_EXPECT(shareBalance(alice) == 499);
    -            BEAST_EXPECT(shareBalance(bob) == 501);
    -            BEAST_EXPECT(expectOffers(env, alice, 1));
    -        }
    -
    -        {
    -            testcase("MPT CanTrade governance: share inherits underlying on DEX and AMM");
    -
    -            Env env{*this, testableAmendments()};
    -            Account const issuer{"issuer"};
    -            Account const owner{"owner"};
    -            Account const alice{"alice"};
    -            Account const bob{"bob"};
    -            env.fund(XRP(100'000), issuer, owner, alice, bob);
    -            env.close();
    -            Vault const vault{env};
    -
    -            MPTTester mptt{env, issuer, kMptInitNoFund};
    -            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
    -            PrettyAsset const asset = mptt.issuanceID();
    -            mptt.authorize({.account = owner});
    -            mptt.authorize({.account = alice});
    -            mptt.authorize({.account = bob});
    -            env(pay(issuer, alice, asset(10'000)));
    -            env(pay(issuer, bob, asset(10'000)));
    -            env.close();
    -
    -            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -            env(tx);
    -            env.close();
    -
    -            // Seed shares so we can later place them on trading venues.
    -            env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = asset(5'000)}));
    -            env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = asset(5'000)}));
    -            env.close();
    -
    -            auto const shares = [&]() -> PrettyAsset {
    -                auto const sle = env.le(keylet);
    -                BEAST_EXPECT(sle != nullptr);
    -                return MPTIssue(sle->at(sfShareMPTID));
    -            }();
    -
    -            // CanTrade is not set on the underlying, both the asset and
    -            // the vault share are blocked on the DEX.
    -            env(offer(alice, XRP(1), asset(10)), Ter{tecNO_PERMISSION});
    -            env(offer(alice, XRP(1), shares(1)), Ter{tecNO_PERMISSION});
    -            env.close();
    -
    -            // Deposit still works before enabling CanTrade.
    -            env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = asset(100)}));
    -            env.close();
    -
    -            // Peer-to-peer share transfers still work (CanTransfer is set on
    -            // both layers).
    -            env(pay(alice, bob, shares(1)));
    -            env.close();
    -
    -            // Withdraw still works before enabling CanTrade.
    -            env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = asset(100)}));
    -            env.close();
    -
    -            // Enable CanTrade on the underlying.
    -            mptt.set({.flags = tfMPTSetCanTrade});
    -            env.close();
    -
    -            env(offer(alice, XRP(1), asset(10)));
    -            env(offer(alice, XRP(1), shares(1)));
    -            env.close();
    -
    -            AMM const ammUnderlying(env, alice, XRP(1'000), asset(1'000));
    -        }
    -
    -        {
    -            testcase("MPT OutstandingAmount > MaximumAmount");
    -
    -            Env env{*this, testableAmendments() | featureSingleAssetVault};
    -            Account const alice{"alice"};
    -            Account const issuer{"issuer"};
    -            env.fund(XRP(1'000), alice, issuer);
    -            env.close();
    -            Vault const vault{env};
    -
    -            MPTTester const btc({.env = env, .issuer = issuer, .holders = {alice}, .maxAmt = 100});
    -
    -            auto [tx, k] = vault.create({.owner = issuer, .asset = btc});
    -            env(tx);
    -            env.close();
    -
    -            tx = vault.deposit({.depositor = issuer, .id = k.key, .amount = btc(110)});
    -            // accountHolds is the first check and the issuer has only BTC(100)
    -            // available
    -            env(tx, Ter{tecINSUFFICIENT_FUNDS});
    -            env.close();
    -
    -            // OutstandingAmount == MaximumAmount
    -            env(pay(issuer, alice, btc(100)));
    -            env.close();
    -
    -            tx = vault.deposit({.depositor = issuer, .id = k.key, .amount = btc(100)});
    -            // the issuer has BTC(0) available
    -            env(tx, Ter{tecINSUFFICIENT_FUNDS});
    -            env.close();
    -
    -            tx = vault.deposit({.depositor = alice, .id = k.key, .amount = btc(100)});
    -            // alice transfers BTC(100), OutstandingAmount is 100
    -            env(tx);
    -            env.close();
    -        }
    -    }
    -
    -    void
    -    testWithIOU()
    -    {
    -        using namespace test::jtx;
    -
    -        struct CaseArgs
    -        {
    -            int initialXRP = 1000;
    -            Number initialIOU = 200;
    -            double transferRate = 1.0;
    -            bool charlieRipple = true;
    -            FeatureBitset features = testableAmendments();
    -        };
    -
    -        auto testCase = [&, this](
    -                            std::function vaultAccount,
    -                                Vault& vault,
    -                                PrettyAsset const& asset,
    -                                std::function issuanceId)> test,
    -                            CaseArgs args = {}) {
    -            Env env{*this, args.features};
    -            Account const owner{"owner"};
    -            Account const issuer{"issuer"};
    -            Account const charlie{"charlie"};
    -            Vault vault{env};
    -            env.fund(XRP(args.initialXRP), issuer, owner, charlie);
    -            env(fset(issuer, asfAllowTrustLineClawback));
    -            env.close();
    -
    -            PrettyAsset const asset = issuer["IOU"];
    -            env.trust(asset(1000), owner);
    -            env(pay(issuer, owner, asset(args.initialIOU)));
    -            env.close();
    -            if (!args.charlieRipple)
    -            {
    -                env(fset(issuer, 0, asfDefaultRipple));
    -                env.close();
    -                env.trust(asset(1000), charlie);
    -                env.close();
    -                env(pay(issuer, charlie, asset(args.initialIOU)));
    -                env.close();
    -                env(fset(issuer, asfDefaultRipple));
    -            }
    -            else
    -            {
    -                env.trust(asset(1000), charlie);
    -            }
    -            env.close();
    -            env(rate(issuer, args.transferRate));
    -            env.close();
    -
    -            auto const vaultAccount = [&env](xrpl::Keylet keylet) -> Account {
    -                return Account("vault", env.le(keylet)->at(sfAccount));
    -            };
    -            auto const issuanceId = [&env](xrpl::Keylet keylet) -> MPTID {
    -                return env.le(keylet)->at(sfShareMPTID);
    -            };
    -
    -            test(env, owner, issuer, charlie, vaultAccount, vault, asset, issuanceId);
    -        };
    -
    -        testCase([&, this](
    -                     Env& env,
    -                     Account const& owner,
    -                     Account const& issuer,
    -                     Account const&,
    -                     auto vaultAccount,
    -                     Vault& vault,
    -                     PrettyAsset const& asset,
    -                     auto&&...) {
    -            testcase("IOU cannot use different asset");
    -            PrettyAsset const foo = issuer["FOO"];
    -
    -            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -            env(tx);
    -            env.close();
    -
    -            {
    -                // Cannot create new trustline to a vault
    -                auto tx = [&, account = vaultAccount(keylet)]() {
    -                    json::Value jv;
    -                    jv[jss::Account] = issuer.human();
    -                    {
    -                        auto& ja = jv[jss::LimitAmount] =
    -                            foo(0).value().getJson(JsonOptions::Values::None);
    -                        ja[jss::issuer] = toBase58(account);
    -                    }
    -                    jv[jss::TransactionType] = jss::TrustSet;
    -                    jv[jss::Flags] = tfSetFreeze;
    -                    return jv;
    -                }();
    -                env(tx, Ter{tecNO_PERMISSION});
    -                env.close();
    -            }
    -
    -            {
    -                auto tx = vault.deposit({.depositor = issuer, .id = keylet.key, .amount = foo(20)});
    -                env(tx, Ter{tecWRONG_ASSET});
    -                env.close();
    -            }
    -
    -            {
    -                auto tx =
    -                    vault.withdraw({.depositor = issuer, .id = keylet.key, .amount = foo(20)});
    -                env(tx, Ter{tecWRONG_ASSET});
    -                env.close();
    -            }
    -
    -            env(vault.del({.owner = owner, .id = keylet.key}));
    -            env.close();
    -        });
    -
    -        testCase(
    -            [&, this](
    -                Env& env,
    -                Account const& owner,
    -                Account const& issuer,
    -                Account const& charlie,
    -                auto vaultAccount,
    -                Vault& vault,
    -                PrettyAsset const& asset,
    -                auto issuanceId) {
    -                testcase("IOU transfer fees not applied");
    -
    -                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -                env(tx);
    -                env.close();
    -
    -                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)}));
    -                env.close();
    -
    -                auto const issue = asset.raw().get();
    -                Asset const share = Asset(issuanceId(keylet));
    -
    -                // transfer fees ignored on deposit
    -                BEAST_EXPECT(env.balance(owner, issue) == asset(100));
    -                BEAST_EXPECT(env.balance(vaultAccount(keylet), issue) == asset(100));
    -
    -                {
    -                    auto tx = vault.clawback(
    -                        {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(50)});
    -                    env(tx);
    -                    env.close();
    -                }
    -
    -                // transfer fees ignored on clawback
    -                BEAST_EXPECT(env.balance(owner, issue) == asset(100));
    -                BEAST_EXPECT(env.balance(vaultAccount(keylet), issue) == asset(50));
    -
    -                env(vault.withdraw(
    -                    {.depositor = owner, .id = keylet.key, .amount = share(20'000'000)}));
    -
    -                // transfer fees ignored on withdraw
    -                BEAST_EXPECT(env.balance(owner, issue) == asset(120));
    -                BEAST_EXPECT(env.balance(vaultAccount(keylet), issue) == asset(30));
    -
    -                {
    -                    auto tx = vault.withdraw(
    -                        {.depositor = owner, .id = keylet.key, .amount = share(30'000'000)});
    -                    tx[sfDestination] = charlie.human();
    -                    env(tx);
    -                }
    -
    -                // transfer fees ignored on withdraw to 3rd party
    -                BEAST_EXPECT(env.balance(owner, issue) == asset(120));
    -                BEAST_EXPECT(env.balance(charlie, issue) == asset(30));
    -                BEAST_EXPECT(env.balance(vaultAccount(keylet), issue) == asset(0));
    -
    -                env(vault.del({.owner = owner, .id = keylet.key}));
    -                env.close();
    -            },
    -            CaseArgs{.transferRate = 1.25});
    -
    -        testCase([&, this](
    -                     Env& env,
    -                     Account const& owner,
    -                     Account const& issuer,
    -                     Account const& charlie,
    -                     auto,
    -                     Vault& vault,
    -                     PrettyAsset const& asset,
    -                     auto&&...) {
    -            testcase("IOU no trust line to 3rd party");
    -
    -            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -            env(tx);
    -            env.close();
    -
    -            env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)}));
    -            env.close();
    -
    -            Account const erin{"erin"};
    -            env.fund(XRP(1000), erin);
    -            env.close();
    -
    -            // Withdraw to 3rd party without trust line
    -            auto const tx1 = [&](xrpl::Keylet keylet) {
    -                auto tx =
    -                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)});
    -                tx[sfDestination] = erin.human();
    -                return tx;
    -            }(keylet);
    -            env(tx1, Ter{tecNO_LINE});
    -        });
    -
    -        testCase([&, this](
    -                     Env& env,
    -                     Account const& owner,
    -                     Account const& issuer,
    -                     Account const& charlie,
    -                     auto,
    -                     Vault& vault,
    -                     PrettyAsset const& asset,
    -                     auto&&...) {
    -            testcase("IOU no trust line to depositor");
    -
    -            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -            env(tx);
    -            env.close();
    -
    -            // reset limit, so deposit of all funds will delete the trust line
    -            env.trust(asset(0), owner);
    -            env.close();
    -
    -            env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(200)}));
    -            env.close();
    -
    -            auto trustline = env.le(keylet::trustLine(owner, asset.raw().get()));
    -            BEAST_EXPECT(trustline == nullptr);
    -
    -            // Withdraw without trust line, will succeed
    -            auto const tx1 = [&](xrpl::Keylet keylet) {
    -                auto tx =
    -                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)});
    -                return tx;
    -            }(keylet);
    -            env(tx1);
    -        });
    -
    -        testCase(
    -            [&, this](
    -                Env& env,
    -                Account const& owner,
    -                Account const& issuer,
    -                Account const& charlie,
    -                auto vaultAccount,
    -                Vault& vault,
    -                PrettyAsset const& asset,
    -                std::function issuanceId) {
    -                testcase("IOU non-transferable");
    -
    -                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -                tx[sfScale] = 0;
    -                env(tx);
    -                env.close();
    -
    -                // Turn on noripple on the pseudo account's trust line.
    -                // Charlie's is already set.
    -                env(trust(issuer, vaultAccount(keylet)["IOU"], tfSetNoRipple));
    -
    -                {
    -                    // Charlie cannot deposit
    -                    auto tx = vault.deposit(
    -                        {.depositor = charlie, .id = keylet.key, .amount = asset(100)});
    -                    env(tx, Ter{terNO_RIPPLE});
    -                    env.close();
    -                }
    -
    -                {
    -                    PrettyAsset const shares = issuanceId(keylet);
    -                    auto tx1 =
    -                        vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)});
    -                    env(tx1);
    -                    env.close();
    -
    -                    // Charlie cannot receive funds
    -                    auto tx2 = vault.withdraw(
    -                        {.depositor = owner, .id = keylet.key, .amount = shares(100)});
    -                    tx2[sfDestination] = charlie.human();
    -                    env(tx2, Ter{terNO_RIPPLE});
    -                    env.close();
    -
    -                    {
    -                        // Create MPToken for shares held by Charlie
    -                        json::Value tx{json::ValueType::Object};
    -                        tx[sfAccount] = charlie.human();
    -                        tx[sfMPTokenIssuanceID] =
    -                            to_string(shares.raw().get().getMptID());
    -                        tx[sfTransactionType] = jss::MPTokenAuthorize;
    -                        env(tx);
    -                        env.close();
    -                    }
    -                    // Behavioral shift introduced by share inheritance:
    -                    // before fixCleanup3_2_0 this share Payment succeeded
    -                    // and the underlying IOU's NoRipple restriction surfaced
    -                    // only later on Charlie's withdrawal (terNO_RIPPLE).
    -                    // Post-amendment, canTransfer reads the share's
    -                    // sfReferenceHolding and dispatches to the underlying IOU;
    -                    // rippling is disabled between owner and charlie so the
    -                    // share payment itself is now blocked. tecPATH_DRY is
    -                    // the path-find layer's translation of the underlying
    -                    // terNO_RIPPLE under featureMPTokensV2.
    -                    env(pay(owner, charlie, shares(100)), Ter{tecPATH_DRY});
    -                    env.close();
    -                }
    -
    -                tx = vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(100)});
    -                env(tx);
    -                env.close();
    -
    -                // Delete vault with zero balance
    -                env(vault.del({.owner = owner, .id = keylet.key}));
    -            },
    -            {.charlieRipple = false});
    -
    -        testCase(
    -            [&, this](
    -                Env& env,
    -                Account const& owner,
    -                Account const& issuer,
    -                Account const& charlie,
    -                auto const& vaultAccount,
    -                Vault& vault,
    -                PrettyAsset const& asset,
    -                auto&&...) {
    -                testcase("IOU calculation rounding");
    -
    -                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -                tx[sfScale] = 1;
    -                env(tx);
    -                env.close();
    -
    -                auto const startingOwnerBalance = env.balance(owner, asset);
    -                BEAST_EXPECT((startingOwnerBalance.value() == STAmount{asset, 11875, -2}));
    -
    -                // This operation (first deposit 100, then 3.75 x 5) is known to
    -                // have triggered calculation rounding errors in Number
    -                // (addition and division), causing the last deposit to be
    -                // blocked by Vault invariants.
    -                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)}));
    -
    -                auto const tx1 = vault.deposit(
    -                    {.depositor = owner, .id = keylet.key, .amount = asset(Number(375, -2))});
    -                for (auto i = 0; i < 5; ++i)
    -                {
    -                    env(tx1);
    -                }
    -                env.close();
    -
    -                {
    -                    STAmount const xfer{asset, 1185, -1};
    -                    BEAST_EXPECT(env.balance(owner, asset) == startingOwnerBalance.value() - xfer);
    -                    BEAST_EXPECT(env.balance(vaultAccount(keylet), asset) == xfer);
    -
    -                    auto const vault = env.le(keylet);
    -                    BEAST_EXPECT(vault->at(sfAssetsAvailable) == xfer);
    -                    BEAST_EXPECT(vault->at(sfAssetsTotal) == xfer);
    -                }
    -
    -                // Total vault balance should be 118.5 IOU. Withdraw and delete
    -                // the vault to verify this exact amount was deposited and the
    -                // owner has matching shares
    -                env(vault.withdraw(
    -                    {.depositor = owner,
    -                     .id = keylet.key,
    -                     .amount = asset(Number(1000 + (37 * 5), -1))}));
    -
    -                {
    -                    BEAST_EXPECT(env.balance(owner, asset) == startingOwnerBalance.value());
    -                    BEAST_EXPECT(env.balance(vaultAccount(keylet), asset) == beast::kZero);
    -                    auto const vault = env.le(keylet);
    -                    BEAST_EXPECT(vault->at(sfAssetsAvailable) == beast::kZero);
    -                    BEAST_EXPECT(vault->at(sfAssetsTotal) == beast::kZero);
    -                }
    -
    -                env(vault.del({.owner = owner, .id = keylet.key}));
    -                env.close();
    -            },
    -            {.initialIOU = Number(11875, -2)});
    -
    -        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,
    -                Account const& owner,
    -                Account const& issuer,
    -                Account const& charlie,
    -                auto,
    -                Vault& vault,
    -                PrettyAsset const& asset,
    -                auto&&...) {
    -                testcase("IOU no trust line to depositor no reserve");
    -                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -                env(tx);
    -                env.close();
    -
    -                // reset limit, so deposit of all funds will delete the trust
    -                // line
    -                env.trust(asset(0), owner);
    -                env.close();
    -
    -                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(200)}));
    -                env.close();
    -
    -                auto trustline = env.le(keylet::trustLine(owner, asset.raw().get()));
    -                BEAST_EXPECT(trustline == nullptr);
    -
    -                env(ticket::create(owner, 1));
    -                env.close();
    -
    -                // Fail because not enough reserve to create trust line
    -                tx = vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)});
    -                env(tx, Ter{tecNO_LINE_INSUF_RESERVE});
    -                env.close();
    -
    -                env(pay(charlie, owner, XRP(incReserve)));
    -                env.close();
    -
    -                // Withdraw can now create trust line, will succeed
    -                env(tx);
    -                env.close();
    -            },
    -            CaseArgs{.initialXRP = acctReserve + (incReserve * 4) + 1});
    -
    -        testCase(
    -            [&, this](
    -                Env& env,
    -                Account const& owner,
    -                Account const& issuer,
    -                Account const& charlie,
    -                auto,
    -                Vault& vault,
    -                PrettyAsset const& asset,
    -                auto&&...) {
    -                testcase("IOU no reserve for share MPToken");
    -                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -                env(tx);
    -                env.close();
    -
    -                env(pay(owner, charlie, asset(100)));
    -                env.close();
    -
    -                env(ticket::create(charlie, 3));
    -                env.close();
    -
    -                // Fail because not enough reserve to create MPToken for shares
    -                tx = vault.deposit({.depositor = charlie, .id = keylet.key, .amount = asset(100)});
    -                env(tx, Ter{tecINSUFFICIENT_RESERVE});
    -                env.close();
    -
    -                env(pay(issuer, charlie, XRP(incReserve)));
    -                env.close();
    -
    -                // Deposit can now create MPToken, will succeed
    -                env(tx);
    -                env.close();
    -            },
    -            CaseArgs{.initialXRP = acctReserve + (incReserve * 4) + 1});
    -    }
    -
    -    void
    -    testWithDomainCheck()
    -    {
    -        using namespace test::jtx;
    -
    -        testcase("private vault");
    -
    -        Env env{*this, testableAmendments()};
    -        Account const issuer{"issuer"};
    -        Account const owner{"owner"};
    -        Account const depositor{"depositor"};
    -        Account const charlie{"charlie"};
    -        Account const pdOwner{"pdOwner"};
    -        Account const credIssuer1{"credIssuer1"};
    -        Account const credIssuer2{"credIssuer2"};
    -        std::string const credType = "credential";
    -        Vault const vault{env};
    -        env.fund(XRP(1000), issuer, owner, depositor, charlie, pdOwner, credIssuer1, credIssuer2);
    -        env.close();
    -        env(fset(issuer, asfAllowTrustLineClawback));
    -        env.close();
    -        env.require(Flags(issuer, asfAllowTrustLineClawback));
    -
    -        PrettyAsset const asset = issuer["IOU"];
    -        env.trust(asset(1000), owner);
    -        env(pay(issuer, owner, asset(500)));
    -        env.trust(asset(1000), depositor);
    -        env(pay(issuer, depositor, asset(500)));
    -        env.trust(asset(1000), charlie);
    -        env(pay(issuer, charlie, asset(5)));
    -        env.close();
    -
    -        auto [tx, keylet] = vault.create({.owner = owner, .asset = asset, .flags = tfVaultPrivate});
    -        env(tx);
    -        env.close();
    -        BEAST_EXPECT(env.le(keylet));
    -
    -        {
    -            testcase("private vault owner can deposit");
    -            auto tx = vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(50)});
    -            env(tx);
    -        }
    -
    -        {
    -            testcase("private vault depositor not authorized yet");
    -            auto tx =
    -                vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
    -            env(tx, Ter{tecNO_AUTH});
    -        }
    -
    -        {
    -            testcase("private vault cannot set non-existing domain");
    -            auto tx = vault.set({.owner = owner, .id = keylet.key});
    -            tx[sfDomainID] = to_string(BaseUInt<256>(42ul));
    -            env(tx, Ter{tecOBJECT_NOT_FOUND});
    -        }
    -
    -        {
    -            testcase("private vault set domainId");
    -
    -            {
    -                pdomain::Credentials const credentials1{
    -                    {.issuer = credIssuer1, .credType = credType}};
    -
    -                env(pdomain::setTx(pdOwner, credentials1));
    -                auto const domainId1 = [&]() {
    -                    auto tx = env.tx()->getJson(JsonOptions::Values::None);
    -                    return pdomain::getNewDomain(env.meta());
    -                }();
    -
    -                auto tx = vault.set({.owner = owner, .id = keylet.key});
    -                tx[sfDomainID] = to_string(domainId1);
    -                env(tx);
    -                env.close();
    -
    -                // Update domain second time, should be harmless
    -                env(tx);
    -                env.close();
    -            }
    -
    -            {
    -                pdomain::Credentials const credentials{
    -                    {.issuer = credIssuer1, .credType = credType},
    -                    {.issuer = credIssuer2, .credType = credType}};
    -
    -                env(pdomain::setTx(pdOwner, credentials));
    -                auto const domainId = [&]() {
    -                    auto tx = env.tx()->getJson(JsonOptions::Values::None);
    -                    return pdomain::getNewDomain(env.meta());
    -                }();
    -
    -                auto tx = vault.set({.owner = owner, .id = keylet.key});
    -                tx[sfDomainID] = to_string(domainId);
    -                env(tx);
    -                env.close();
    -
    -                // Should be idempotent
    -                tx = vault.set({.owner = owner, .id = keylet.key});
    -                tx[sfDomainID] = to_string(domainId);
    -                env(tx);
    -                env.close();
    -            }
    -        }
    -
    -        {
    -            testcase("private vault depositor still not authorized");
    -            auto tx =
    -                vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
    -            env(tx, Ter{tecNO_AUTH});
    -            env.close();
    -        }
    -
    -        auto const credKeylet = credentials::keylet(depositor, credIssuer1, credType);
    -        {
    -            testcase("private vault depositor now authorized");
    -            env(credentials::create(depositor, credIssuer1, credType));
    -            env(credentials::accept(depositor, credIssuer1, credType));
    -            env(credentials::create(charlie, credIssuer1, credType));
    -            // charlie's credential not accepted
    -            env.close();
    -            auto credSle = env.le(credKeylet);
    -            BEAST_EXPECT(credSle != nullptr);
    -
    -            auto tx =
    -                vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
    -            env(tx);
    -            env.close();
    -
    -            tx = vault.deposit({.depositor = charlie, .id = keylet.key, .amount = asset(50)});
    -            env(tx, Ter{tecNO_AUTH});
    -            env.close();
    -        }
    -
    -        {
    -            testcase("private vault depositor lost authorization");
    -            env(credentials::deleteCred(credIssuer1, depositor, credIssuer1, credType));
    -            env(credentials::deleteCred(credIssuer1, charlie, credIssuer1, credType));
    -            env.close();
    -            auto credSle = env.le(credKeylet);
    -            BEAST_EXPECT(credSle == nullptr);
    -
    -            auto tx =
    -                vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
    -            env(tx, Ter{tecNO_AUTH});
    -            env.close();
    -        }
    -
    -        auto const shares = [&env, keylet = keylet, this]() -> Asset {
    -            auto const vault = env.le(keylet);
    -            BEAST_EXPECT(vault != nullptr);
    -            return MPTIssue(vault->at(sfShareMPTID));
    -        }();
    -
    -        {
    -            testcase("private vault expired authorization");
    -            uint32_t const closeTime =
    -                env.current()->header().parentCloseTime.time_since_epoch().count();
    -            {
    -                auto tx0 = credentials::create(depositor, credIssuer2, credType);
    -                tx0[sfExpiration] = closeTime + 20;
    -                env(tx0);
    -                tx0 = credentials::create(charlie, credIssuer2, credType);
    -                tx0[sfExpiration] = closeTime + 20;
    -                env(tx0);
    -                env.close();
    -
    -                env(credentials::accept(depositor, credIssuer2, credType));
    -                env(credentials::accept(charlie, credIssuer2, credType));
    -                env.close();
    -            }
    -
    -            {
    -                auto tx1 =
    -                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
    -                env(tx1);
    -                env.close();
    -
    -                auto const tokenKeylet =
    -                    keylet::mptoken(shares.get().getMptID(), depositor.id());
    -                BEAST_EXPECT(env.le(tokenKeylet) != nullptr);
    -            }
    -
    -            {
    -                // time advance
    -                env.close();
    -                env.close();
    -                env.close();
    -
    -                auto const credsKeylet = credentials::keylet(depositor, credIssuer2, credType);
    -                BEAST_EXPECT(env.le(credsKeylet) != nullptr);
    -
    -                auto tx2 =
    -                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1)});
    -                env(tx2, Ter{tecEXPIRED});
    -                env.close();
    -
    -                BEAST_EXPECT(env.le(credsKeylet) == nullptr);
    -            }
    -
    -            {
    -                auto const credsKeylet = credentials::keylet(charlie, credIssuer2, credType);
    -                BEAST_EXPECT(env.le(credsKeylet) != nullptr);
    -                auto const tokenKeylet =
    -                    keylet::mptoken(shares.get().getMptID(), charlie.id());
    -                BEAST_EXPECT(env.le(tokenKeylet) == nullptr);
    -
    -                auto tx3 =
    -                    vault.deposit({.depositor = charlie, .id = keylet.key, .amount = asset(2)});
    -                env(tx3, Ter{tecEXPIRED});
    -
    -                env.close();
    -                BEAST_EXPECT(env.le(credsKeylet) == nullptr);
    -                BEAST_EXPECT(env.le(tokenKeylet) == nullptr);
    -            }
    -        }
    -
    -        {
    -            testcase("private vault reset domainId");
    -            auto tx = vault.set({.owner = owner, .id = keylet.key});
    -            tx[sfDomainID] = "0";
    -            env(tx);
    -            env.close();
    -
    -            tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
    -            env(tx, Ter{tecNO_AUTH});
    -            env.close();
    -
    -            tx = vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
    -            env(tx);
    -            env.close();
    -
    -            tx = vault.clawback(
    -                {.issuer = issuer, .id = keylet.key, .holder = depositor, .amount = asset(0)});
    -            env(tx);
    -
    -            tx = vault.clawback(
    -                {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(0)});
    -            env(tx);
    -            env.close();
    -
    -            tx = vault.del({
    -                .owner = owner,
    -                .id = keylet.key,
    -            });
    -            env(tx);
    -        }
    -    }
    -
    -    void
    -    testDomainLossAfterAcquisition()
    -    {
    -        using namespace test::jtx;
    -
    -        testcase("private vault share transfer after depositor loses domain");
    -
    -        // The "Private Vault - Access Control Rules" spec requires that a holder who
    -        // loses Layer 2 (Permissioned Domain membership) after acquiring shares be
    -        // blocked from sending them onward, by P2P transfer or DEX offer, the same
    -        // way a brand-new never-authorized holder is blocked. Only withdrawal to
    -        // self is meant to stay open.
    -        //
    -        // For a domain-gated share MPToken, requireAuth()'s escape hatch for
    -        // holders who already have an MPToken (MPTokenHelpers.cpp) only applies to
    -        // the classic explicit-issuer-authorization flag, which
    -        // enforceMPTokenAuthorization documents as "meaningless" for
    -        // domain-authorized holders and never sets. So a stale MPToken does not
    -        // carry authorization forward once the account's domain credential is
    -        // gone, and both actions below are correctly blocked.
    -
    -        Env env{*this, testableAmendments()};
    -        Account const issuer{"issuer"};
    -        Account const owner{"owner"};
    -        Account const depositor{"depositor"};
    -        Account const bob{"bob"};
    -        Account const pdOwner{"pdOwner"};
    -        Account const credIssuer{"credIssuer"};
    -        std::string const credType = "credential";
    -        Vault const vault{env};
    -        env.fund(XRP(1000), issuer, owner, depositor, bob, pdOwner, credIssuer);
    -        env.close();
    -
    -        PrettyAsset const asset = issuer["IOU"];
    -        env.trust(asset(1000), owner);
    -        env(pay(issuer, owner, asset(500)));
    -        env.trust(asset(1000), depositor);
    -        env(pay(issuer, depositor, asset(500)));
    -        env.trust(asset(1000), bob);
    -        env(pay(issuer, bob, asset(500)));
    -        env.close();
    -
    -        // Transferable shares (no tfVaultShareNonTransferable): sections 3.3/3.4 of
    -        // the spec (DEX trading / P2P transfer) only apply to transferable shares.
    -        auto [tx, keylet] = vault.create({.owner = owner, .asset = asset, .flags = tfVaultPrivate});
    -        env(tx);
    -        env.close();
    -
    -        pdomain::Credentials const credentials{{.issuer = credIssuer, .credType = credType}};
    -        env(pdomain::setTx(pdOwner, credentials));
    -        auto const domainId = [&]() {
    -            auto tx = env.tx()->getJson(JsonOptions::Values::None);
    -            return pdomain::getNewDomain(env.meta());
    -        }();
    -        {
    -            auto domainTx = vault.set({.owner = owner, .id = keylet.key});
    -            domainTx[sfDomainID] = to_string(domainId);
    -            env(domainTx);
    -            env.close();
    -        }
    -
    -        // Both depositor and bob acquire domain membership and deposit, so each
    -        // ends up with an authorized share MPToken.
    -        env(credentials::create(depositor, credIssuer, credType));
    -        env(credentials::accept(depositor, credIssuer, credType));
    -        env(credentials::create(bob, credIssuer, credType));
    -        env(credentials::accept(bob, credIssuer, credType));
    -        env.close();
    -
    -        env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)}));
    -        env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = asset(100)}));
    -        env.close();
    -
    -        auto const shares = [&env, keylet = keylet, this]() -> PrettyAsset {
    -            auto const sle = env.le(keylet);
    -            BEAST_EXPECT(sle != nullptr);
    -            return MPTIssue(sle->at(sfShareMPTID));
    -        }();
    -
    -        // Depositor loses Layer 2: their Permissioned Domain credential is revoked.
    -        auto const credKeylet = credentials::keylet(depositor, credIssuer, credType);
    -        env(credentials::deleteCred(credIssuer, depositor, credIssuer, credType));
    -        env.close();
    -        BEAST_EXPECT(env.le(credKeylet) == nullptr);
    -
    -        // Sanity check, mirrors testWithDomainCheck's "not authorized yet" case: a
    -        // brand-new depositor with no MPToken yet is still correctly blocked. The
    -        // gap below is specific to holders who already hold shares.
    -        {
    -            Account const charlie{"charlie"};
    -            env.fund(XRP(1000), charlie);
    -            env.close();
    -            auto depTx =
    -                vault.deposit({.depositor = charlie, .id = keylet.key, .amount = asset(1)});
    -            env(depTx, Ter{tecNO_AUTH});
    -        }
    -
    -        // P2P transfer: spec section 3.4 requires this blocked once Layer 2 is
    -        // lost, and it is.
    -        env(pay(depositor, bob, shares(1)), Ter{tecNO_AUTH});
    -        env.close();
    -
    -        // DEX/CLOB: spec section 3.3 requires the seller leg blocked the same way.
    -        // The offer can't even be created: preclaim treats the seller as
    -        // unfunded once their share balance reads as zero for auth purposes.
    -        env(offer(depositor, XRP(1), shares(1)), Ter{tecUNFUNDED_OFFER});
    -        env.close();
    -        BEAST_EXPECT(expectOffers(env, depositor, 0));
    -    }
    -
    -    void
    -    testDomainCheckBuyerSideOffer()
    -    {
    -        using namespace test::jtx;
    -
    -        testcase("private vault share purchase via DEX requires buyer domain membership");
    -
    -        // The "Private Vault - Access Control Rules" spec requires the buyer leg
    -        // of a DEX trade in private-vault shares to hold Layer 1 and Layer 2 as
    -        // well, not just the seller.
    -
    -        Env env{*this, testableAmendments()};
    -        Account const issuer{"issuer"};
    -        Account const owner{"owner"};
    -        Account const bob{"bob"};
    -        Account const charlie{"charlie"};
    -        Account const pdOwner{"pdOwner"};
    -        Account const credIssuer{"credIssuer"};
    -        std::string const credType = "credential";
    -        Vault const vault{env};
    -        env.fund(XRP(1000), issuer, owner, bob, charlie, pdOwner, credIssuer);
    -        env.close();
    -
    -        PrettyAsset const asset = issuer["IOU"];
    -        env.trust(asset(1000), owner);
    -        env(pay(issuer, owner, asset(500)));
    -        env.trust(asset(1000), bob);
    -        env(pay(issuer, bob, asset(500)));
    -        env.close();
    -
    -        auto [tx, keylet] = vault.create({.owner = owner, .asset = asset, .flags = tfVaultPrivate});
    -        env(tx);
    -        env.close();
    -
    -        pdomain::Credentials const credentials{{.issuer = credIssuer, .credType = credType}};
    -        env(pdomain::setTx(pdOwner, credentials));
    -        auto const domainId = [&]() {
    -            auto tx = env.tx()->getJson(JsonOptions::Values::None);
    -            return pdomain::getNewDomain(env.meta());
    -        }();
    -        {
    -            auto domainTx = vault.set({.owner = owner, .id = keylet.key});
    -            domainTx[sfDomainID] = to_string(domainId);
    -            env(domainTx);
    -            env.close();
    -        }
    -
    -        // Only bob joins the domain and deposits; charlie never does.
    -        env(credentials::create(bob, credIssuer, credType));
    -        env(credentials::accept(bob, credIssuer, credType));
    -        env.close();
    -        env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = asset(100)}));
    -        env.close();
    -
    -        auto const shares = [&env, keylet = keylet, this]() -> PrettyAsset {
    -            auto const sle = env.le(keylet);
    -            BEAST_EXPECT(sle != nullptr);
    -            return MPTIssue(sle->at(sfShareMPTID));
    -        }();
    -
    -        // Bob (domain member, holds shares) rests a sell offer.
    -        env(offer(bob, XRP(1), shares(1)));
    -        env.close();
    -        BEAST_EXPECT(expectOffers(env, bob, 1));
    -
    -        // Charlie never held the domain credential. Buying shares via a
    -        // crossing offer must be blocked the same way a direct MPTokenAuthorize
    -        // + pay attempt already is (see testWithDomainChecXRP's "cannot pay
    -        // shares to 3rd party"): checkAcceptAsset() rejects the offer outright
    -        // in preclaim, before any funding check is even reached.
    -        env(offer(charlie, shares(1), XRP(1)), Ter{tecNO_AUTH});
    -        env.close();
    -        BEAST_EXPECT(expectOffers(env, bob, 1));
    -        BEAST_EXPECT(expectOffers(env, charlie, 0));
    -    }
    -
    -    void
    -    testWithDomainChecXRP()
    -    {
    -        using namespace test::jtx;
    -
    -        testcase("private XRP vault");
    -
    -        Env env{*this, testableAmendments()};
    -        Account const owner{"owner"};
    -        Account const depositor{"depositor"};
    -        Account const alice{"charlie"};
    -        std::string const credType = "credential";
    -        Vault const vault{env};
    -        env.fund(XRP(100000), owner, depositor, alice);
    -        env.close();
    -
    -        PrettyAsset const asset = xrpIssue();
    -        auto [tx, keylet] = vault.create({.owner = owner, .asset = asset, .flags = tfVaultPrivate});
    -        env(tx);
    -        env.close();
    -
    -        auto const [vaultAccount, issuanceId] =
    -            [&env, keylet = keylet, this]() -> std::tuple {
    -            auto const vault = env.le(keylet);
    -            BEAST_EXPECT(vault != nullptr);
    -            return {vault->at(sfAccount), vault->at(sfShareMPTID)};
    -        }();
    -        BEAST_EXPECT(env.le(keylet::account(vaultAccount)));
    -        BEAST_EXPECT(env.le(keylet::mptokenIssuance(issuanceId)));
    -        PrettyAsset const shares{issuanceId};
    -
    -        {
    -            testcase("private XRP vault owner can deposit");
    -            auto tx = vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(50)});
    -            env(tx);
    -            env.close();
    -        }
    -
    -        {
    -            testcase("private XRP vault cannot pay shares to depositor yet");
    -            env(pay(owner, depositor, shares(1)), Ter{tecNO_AUTH});
    -        }
    -
    -        {
    -            testcase("private XRP vault depositor not authorized yet");
    -            auto tx =
    -                vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
    -            env(tx, Ter{tecNO_AUTH});
    -        }
    -
    -        {
    -            testcase("private XRP vault set DomainID");
    -            pdomain::Credentials const credentials{{.issuer = owner, .credType = credType}};
    -
    -            env(pdomain::setTx(owner, credentials));
    -            auto const domainId = [&]() {
    -                auto tx = env.tx()->getJson(JsonOptions::Values::None);
    -                return pdomain::getNewDomain(env.meta());
    -            }();
    -
    -            auto tx = vault.set({.owner = owner, .id = keylet.key});
    -            tx[sfDomainID] = to_string(domainId);
    -            env(tx);
    -            env.close();
    -        }
    -
    -        auto const credKeylet = credentials::keylet(depositor, owner, credType);
    -        {
    -            testcase("private XRP vault depositor now authorized");
    -            env(credentials::create(depositor, owner, credType));
    -            env(credentials::accept(depositor, owner, credType));
    -            env.close();
    -
    -            BEAST_EXPECT(env.le(credKeylet));
    -            auto tx =
    -                vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
    -            env(tx);
    -            env.close();
    -        }
    -
    -        {
    -            testcase("private XRP vault can pay shares to depositor");
    -            env(pay(owner, depositor, shares(1)));
    -        }
    -
    -        {
    -            testcase("private XRP vault cannot pay shares to 3rd party");
    -            json::Value jv;
    -            jv[sfAccount] = alice.human();
    -            jv[sfTransactionType] = jss::MPTokenAuthorize;
    -            jv[sfMPTokenIssuanceID] = to_string(issuanceId);
    -            env(jv);
    -            env.close();
    -
    -            env(pay(owner, alice, shares(1)), Ter{tecNO_AUTH});
    -        }
    -    }
    -
    -    void
    -    testFailedPseudoAccount()
    -    {
    -        using namespace test::jtx;
    -
    -        testcase("fail pseudo-account allocation");
    -        Env env{*this, testableAmendments()};
    -        Account const owner{"owner"};
    -        Vault const vault{env};
    -        env.fund(XRP(1000), 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);
    -
    -            env(pay(env.master.id(), accountId, XRP(1000)),
    -                Seq(kAutofill),
    -                Fee(kAutofill),
    -                Sig(kAutofill));
    -        }
    -
    -        auto [tx, keylet1] = vault.create({.owner = owner, .asset = xrpIssue()});
    -        BEAST_EXPECT(keylet.key == keylet1.key);
    -        env(tx, Ter{terADDRESS_COLLISION});
    -    }
    -
    -    void
    -    testScaleIOU()
    -    {
    -        using namespace test::jtx;
    -
    -        struct Data
    -        {
    -            Account const& owner;
    -            Account const& issuer;
    -            Account const& depositor;
    -            Account const& vaultAccount;
    -            MPTIssue shares;
    -            PrettyAsset const& share;
    -            Vault& vault;
    -            xrpl::Keylet keylet;
    -            Issue assets;
    -            PrettyAsset const& asset;
    -            std::function)> peek;
    -        };
    -
    -        auto testCase = [&, this](
    -                            std::uint8_t scale, std::function test) {
    -            Env env{*this, testableAmendments()};
    -            Account const owner{"owner"};
    -            Account const issuer{"issuer"};
    -            Account const depositor{"depositor"};
    -            Vault vault{env};
    -            env.fund(XRP(1000), issuer, owner, depositor);
    -            env(fset(issuer, asfAllowTrustLineClawback));
    -            env.close();
    -
    -            PrettyAsset const asset = issuer["IOU"];
    -            env.trust(asset(1000), owner);
    -            env.trust(asset(1000), depositor);
    -            env(pay(issuer, owner, asset(200)));
    -            env(pay(issuer, depositor, asset(200)));
    -            env.close();
    -
    -            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -            tx[sfScale] = scale;
    -            env(tx);
    -
    -            auto const [vaultAccount, issuanceId] =
    -                [&env](xrpl::Keylet keylet) -> std::tuple {
    -                auto const vault = env.le(keylet);
    -                return {Account("vault", vault->at(sfAccount)), vault->at(sfShareMPTID)};
    -            }(keylet);
    -            MPTIssue const shares(issuanceId);
    -            env.memoize(vaultAccount);
    -
    -            auto const peek = [keylet, &env, this](std::function fn) -> bool {
    -                return env.app().getOpenLedger().modify(
    -                    [&](OpenView& view, beast::Journal j) -> bool {
    -                        Sandbox sb(&view, TapNone);
    -                        auto vault = sb.peek(keylet::vault(keylet.key));
    -                        if (!BEAST_EXPECT(vault))
    -                            return false;
    -                        auto shares = sb.peek(keylet::mptokenIssuance(vault->at(sfShareMPTID)));
    -                        if (!BEAST_EXPECT(shares))
    -                            return false;
    -                        if (fn(*vault, *shares))
    -                        {
    -                            sb.update(vault);
    -                            sb.update(shares);
    -                            sb.apply(view);
    -                            return true;
    -                        }
    -                        return false;
    -                    });
    -            };
    -
    -            test(
    -                env,
    -                {.owner = owner,
    -                 .issuer = issuer,
    -                 .depositor = depositor,
    -                 .vaultAccount = vaultAccount,
    -                 .shares = shares,
    -                 .share = PrettyAsset(shares),
    -                 .vault = vault,
    -                 .keylet = keylet,
    -                 .assets = asset.raw().get(),
    -                 .asset = asset,
    -                 .peek = peek});
    -        };
    -
    -        testCase(18, [&, this](Env& env, Data d) {
    -            testcase("Scale deposit overflow on first deposit");
    -            auto tx = d.vault.deposit(
    -                {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(10)});
    -            env(tx, Ter{tecPATH_DRY});
    -            env.close();
    -        });
    -
    -        testCase(18, [&, this](Env& env, Data d) {
    -            testcase("Scale deposit overflow on second deposit");
    -
    -            {
    -                auto tx = d.vault.deposit(
    -                    {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(5)});
    -                env(tx);
    -                env.close();
    -            }
    -
    -            {
    -                auto tx = d.vault.deposit(
    -                    {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(10)});
    -                env(tx, Ter{tecPATH_DRY});
    -                env.close();
    -            }
    -        });
    -
    -        testCase(18, [&, this](Env& env, Data d) {
    -            testcase("Scale deposit overflow on total shares");
    -
    -            {
    -                auto tx = d.vault.deposit(
    -                    {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(5)});
    -                env(tx);
    -                env.close();
    -            }
    -
    -            {
    -                auto tx = d.vault.deposit(
    -                    {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(5)});
    -                env(tx, Ter{tecPATH_DRY});
    -                env.close();
    -            }
    -        });
    -
    -        testCase(1, [&, this](Env& env, Data d) {
    -            testcase("Scale deposit exact");
    -
    -            auto const start = env.balance(d.depositor, d.assets).number();
    -            auto tx = d.vault.deposit(
    -                {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(1)});
    -            env(tx);
    -            env.close();
    -            BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(10));
    -            BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start - 1));
    -        });
    -
    -        testCase(1, [&, this](Env& env, Data d) {
    -            testcase("Scale deposit insignificant amount");
    -
    -            auto tx = d.vault.deposit(
    -                {.depositor = d.depositor,
    -                 .id = d.keylet.key,
    -                 .amount = STAmount(d.asset, Number(9, -2))});
    -            env(tx, Ter{tecPRECISION_LOSS});
    -        });
    -
    -        testCase(1, [&, this](Env& env, Data d) {
    -            testcase("Scale deposit exact, using full precision");
    -
    -            auto const start = env.balance(d.depositor, d.assets).number();
    -            auto tx = d.vault.deposit(
    -                {.depositor = d.depositor,
    -                 .id = d.keylet.key,
    -                 .amount = STAmount(d.asset, Number(15, -1))});
    -            env(tx);
    -            env.close();
    -            BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(15));
    -            BEAST_EXPECT(
    -                env.balance(d.depositor, d.assets) == STAmount(d.asset, start - Number(15, -1)));
    -        });
    -
    -        testCase(1, [&, this](Env& env, Data d) {
    -            testcase("Scale deposit exact, truncating from .5");
    -
    -            auto const start = env.balance(d.depositor, d.assets).number();
    -            // Each of the cases below will transfer exactly 1.2 IOU to the
    -            // vault and receive 12 shares in exchange
    -            {
    -                auto tx = d.vault.deposit(
    -                    {.depositor = d.depositor,
    -                     .id = d.keylet.key,
    -                     .amount = STAmount(d.asset, Number(125, -2))});
    -                env(tx);
    -                env.close();
    -                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(12));
    -                BEAST_EXPECT(
    -                    env.balance(d.depositor, d.assets) ==
    -                    STAmount(d.asset, start - Number(12, -1)));
    -            }
    -
    -            {
    -                auto tx = d.vault.deposit(
    -                    {.depositor = d.depositor,
    -                     .id = d.keylet.key,
    -                     .amount = STAmount(d.asset, Number(1201, -3))});
    -                env(tx);
    -                env.close();
    -                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(24));
    -                BEAST_EXPECT(
    -                    env.balance(d.depositor, d.assets) ==
    -                    STAmount(d.asset, start - Number(24, -1)));
    -            }
    -
    -            {
    -                auto tx = d.vault.deposit(
    -                    {.depositor = d.depositor,
    -                     .id = d.keylet.key,
    -                     .amount = STAmount(d.asset, Number(1299, -3))});
    -                env(tx);
    -                env.close();
    -                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(36));
    -                BEAST_EXPECT(
    -                    env.balance(d.depositor, d.assets) ==
    -                    STAmount(d.asset, start - Number(36, -1)));
    -            }
    -        });
    -
    -        testCase(1, [&, this](Env& env, Data d) {
    -            testcase("Scale deposit exact, truncating from .01");
    -
    -            auto const start = env.balance(d.depositor, d.assets).number();
    -            // round to 12
    -            auto tx = d.vault.deposit(
    -                {.depositor = d.depositor,
    -                 .id = d.keylet.key,
    -                 .amount = STAmount(d.asset, Number(1201, -3))});
    -            env(tx);
    -            env.close();
    -            BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(12));
    -            BEAST_EXPECT(
    -                env.balance(d.depositor, d.assets) == STAmount(d.asset, start - Number(12, -1)));
    -
    -            {
    -                // round to 6
    -                auto tx = d.vault.deposit(
    -                    {.depositor = d.depositor,
    -                     .id = d.keylet.key,
    -                     .amount = STAmount(d.asset, Number(69, -2))});
    -                env(tx);
    -                env.close();
    -                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(18));
    -                BEAST_EXPECT(
    -                    env.balance(d.depositor, d.assets) ==
    -                    STAmount(d.asset, start - Number(18, -1)));
    -            }
    -        });
    -
    -        testCase(1, [&, this](Env& env, Data d) {
    -            testcase("Scale deposit exact, truncating from .99");
    -
    -            auto const start = env.balance(d.depositor, d.assets).number();
    -            // round to 12
    -            auto tx = d.vault.deposit(
    -                {.depositor = d.depositor,
    -                 .id = d.keylet.key,
    -                 .amount = STAmount(d.asset, Number(1299, -3))});
    -            env(tx);
    -            env.close();
    -            BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(12));
    -            BEAST_EXPECT(
    -                env.balance(d.depositor, d.assets) == STAmount(d.asset, start - Number(12, -1)));
    -
    -            {
    -                // round to 6
    -                auto tx = d.vault.deposit(
    -                    {.depositor = d.depositor,
    -                     .id = d.keylet.key,
    -                     .amount = STAmount(d.asset, Number(62, -2))});
    -                env(tx);
    -                env.close();
    -                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(18));
    -                BEAST_EXPECT(
    -                    env.balance(d.depositor, d.assets) ==
    -                    STAmount(d.asset, start - Number(18, -1)));
    -            }
    -        });
    -
    -        testCase(1, [&, this](Env& env, Data d) {
    -            // initial setup: deposit 100 IOU, receive 1000 shares
    -            auto const start = env.balance(d.depositor, d.assets).number();
    -            auto tx = d.vault.deposit(
    -                {.depositor = d.depositor,
    -                 .id = d.keylet.key,
    -                 .amount = STAmount(d.asset, Number(100, 0))});
    -            env(tx);
    -            env.close();
    -            BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(1000));
    -            BEAST_EXPECT(
    -                env.balance(d.depositor, d.assets) == STAmount(d.asset, start - Number(100, 0)));
    -            BEAST_EXPECT(
    -                env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(100, 0)));
    -            BEAST_EXPECT(
    -                env.balance(d.vaultAccount, d.shares) == STAmount(d.share, Number(-1000, 0)));
    -
    -            {
    -                testcase("Scale redeem exact");
    -                // sharesToAssetsWithdraw:
    -                //  assets = assetsTotal * (shares / sharesTotal)
    -                //  assets = 100 * 100 / 1000 = 100 * 0.1 = 10
    -
    -                auto const start = env.balance(d.depositor, d.assets).number();
    -                auto tx = d.vault.withdraw(
    -                    {.depositor = d.depositor,
    -                     .id = d.keylet.key,
    -                     .amount = STAmount(d.share, Number(100, 0))});
    -                env(tx);
    -                env.close();
    -                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(900));
    -                BEAST_EXPECT(
    -                    env.balance(d.depositor, d.assets) == STAmount(d.asset, start + Number(10, 0)));
    -                BEAST_EXPECT(
    -                    env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(90, 0)));
    -                BEAST_EXPECT(
    -                    env.balance(d.vaultAccount, d.shares) == STAmount(d.share, Number(-900, 0)));
    -            }
    -
    -            {
    -                testcase("Scale redeem with rounding");
    -                // sharesToAssetsWithdraw:
    -                //  assets = assetsTotal * (shares / sharesTotal)
    -                //  assets = 90 * 25 / 900 = 90 * 0.02777... = 2.5
    -
    -                auto const start = env.balance(d.depositor, d.assets).number();
    -                d.peek([](SLE& vault, auto&) -> bool {
    -                    vault[sfAssetsAvailable] = Number(1);
    -                    return true;
    -                });
    -
    -                // Note, this transaction fails first (because of above change
    -                // in the open ledger) but then succeeds when the ledger is
    -                // closed (because a modification like above is not persistent),
    -                // which is why the checks below are expected to pass.
    -                auto tx = d.vault.withdraw(
    -                    {.depositor = d.depositor,
    -                     .id = d.keylet.key,
    -                     .amount = STAmount(d.share, Number(25, 0))});
    -                env(tx, Ter{tecINSUFFICIENT_FUNDS});
    -                env.close();
    -                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(900 - 25));
    -                BEAST_EXPECT(
    -                    env.balance(d.depositor, d.assets) ==
    -                    STAmount(d.asset, start + Number(25, -1)));
    -                BEAST_EXPECT(
    -                    env.balance(d.vaultAccount, d.assets) ==
    -                    STAmount(d.asset, Number(900 - 25, -1)));
    -                BEAST_EXPECT(
    -                    env.balance(d.vaultAccount, d.shares) ==
    -                    STAmount(d.share, -Number(900 - 25, 0)));
    -            }
    -
    -            {
    -                testcase("Scale redeem exact");
    -                // sharesToAssetsWithdraw:
    -                //  assets = assetsTotal * (shares / sharesTotal)
    -                //  assets = 87.5 * 21 / 875 = 87.5 * 0.024 = 2.1
    -
    -                auto const start = env.balance(d.depositor, d.assets).number();
    -
    -                tx = d.vault.withdraw(
    -                    {.depositor = d.depositor,
    -                     .id = d.keylet.key,
    -                     .amount = STAmount(d.share, Number(21, 0))});
    -                env(tx);
    -                env.close();
    -                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(875 - 21));
    -                BEAST_EXPECT(
    -                    env.balance(d.depositor, d.assets) ==
    -                    STAmount(d.asset, start + Number(21, -1)));
    -                BEAST_EXPECT(
    -                    env.balance(d.vaultAccount, d.assets) ==
    -                    STAmount(d.asset, Number(875 - 21, -1)));
    -                BEAST_EXPECT(
    -                    env.balance(d.vaultAccount, d.shares) ==
    -                    STAmount(d.share, -Number(875 - 21, 0)));
    -            }
    -
    -            {
    -                testcase("Scale redeem rest");
    -                auto const rest = env.balance(d.depositor, d.shares).number();
    -
    -                tx = d.vault.withdraw(
    -                    {.depositor = d.depositor,
    -                     .id = d.keylet.key,
    -                     .amount = STAmount(d.share, rest)});
    -                env(tx);
    -                env.close();
    -                BEAST_EXPECT(env.balance(d.depositor, d.shares).number() == 0);
    -                BEAST_EXPECT(env.balance(d.vaultAccount, d.assets).number() == 0);
    -                BEAST_EXPECT(env.balance(d.vaultAccount, d.shares).number() == 0);
    -            }
    -        });
    -
    -        testCase(18, [&, this](Env& env, Data d) {
    -            testcase("Scale withdraw overflow");
    -
    -            {
    -                auto tx = d.vault.deposit(
    -                    {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(5)});
    -                env(tx);
    -                env.close();
    -            }
    -
    -            {
    -                auto tx = d.vault.withdraw(
    -                    {.depositor = d.depositor,
    -                     .id = d.keylet.key,
    -                     .amount = STAmount(d.asset, Number(10, 0))});
    -                env(tx, Ter{tecPATH_DRY});
    -                env.close();
    -            }
    -        });
    -
    -        testCase(1, [&, this](Env& env, Data d) {
    -            // initial setup: deposit 100 IOU, receive 1000 shares
    -            auto const start = env.balance(d.depositor, d.assets).number();
    -            auto tx = d.vault.deposit(
    -                {.depositor = d.depositor,
    -                 .id = d.keylet.key,
    -                 .amount = STAmount(d.asset, Number(100, 0))});
    -            env(tx);
    -            env.close();
    -            BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(1000));
    -            BEAST_EXPECT(
    -                env.balance(d.depositor, d.assets) == STAmount(d.asset, start - Number(100, 0)));
    -            BEAST_EXPECT(
    -                env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(100, 0)));
    -            BEAST_EXPECT(
    -                env.balance(d.vaultAccount, d.shares) == STAmount(d.share, Number(-1000, 0)));
    -
    -            {
    -                testcase("Scale withdraw exact");
    -                // assetsToSharesWithdraw:
    -                //  shares = sharesTotal * (assets / assetsTotal)
    -                //  shares = 1000 * 10 / 100 = 1000 * 0.1 = 100
    -                // sharesToAssetsWithdraw:
    -                //  assets = assetsTotal * (shares / sharesTotal)
    -                //  assets = 100 * 100 / 1000 = 100 * 0.1 = 10
    -
    -                auto const start = env.balance(d.depositor, d.assets).number();
    -                auto tx = d.vault.withdraw(
    -                    {.depositor = d.depositor,
    -                     .id = d.keylet.key,
    -                     .amount = STAmount(d.asset, Number(10, 0))});
    -                env(tx);
    -                env.close();
    -                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(900));
    -                BEAST_EXPECT(
    -                    env.balance(d.depositor, d.assets) == STAmount(d.asset, start + Number(10, 0)));
    -                BEAST_EXPECT(
    -                    env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(90, 0)));
    -                BEAST_EXPECT(
    -                    env.balance(d.vaultAccount, d.shares) == STAmount(d.share, Number(-900, 0)));
    -            }
    -
    -            {
    -                testcase("Scale withdraw insignificant amount");
    -                auto tx = d.vault.withdraw(
    -                    {.depositor = d.depositor,
    -                     .id = d.keylet.key,
    -                     .amount = STAmount(d.asset, Number(4, -2))});
    -                env(tx, Ter{tecPRECISION_LOSS});
    -            }
    -
    -            {
    -                testcase("Scale withdraw with rounding assets");
    -                // assetsToSharesWithdraw:
    -                //  shares = sharesTotal * (assets / assetsTotal)
    -                //  shares = 900 * 2.5 / 90 = 900 * 0.02777... = 25
    -                // sharesToAssetsWithdraw:
    -                //  assets = assetsTotal * (shares / sharesTotal)
    -                //  assets = 90 * 25 / 900 = 90 * 0.02777... = 2.5
    -
    -                auto const start = env.balance(d.depositor, d.assets).number();
    -                d.peek([](SLE& vault, auto&) -> bool {
    -                    vault[sfAssetsAvailable] = Number(1);
    -                    return true;
    -                });
    -
    -                // Note, this transaction fails first (because of above change
    -                // in the open ledger) but then succeeds when the ledger is
    -                // closed (because a modification like above is not persistent),
    -                // which is why the checks below are expected to pass.
    -                auto tx = d.vault.withdraw(
    -                    {.depositor = d.depositor,
    -                     .id = d.keylet.key,
    -                     .amount = STAmount(d.asset, Number(25, -1))});
    -                env(tx, Ter{tecINSUFFICIENT_FUNDS});
    -                env.close();
    -                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(900 - 25));
    -                BEAST_EXPECT(
    -                    env.balance(d.depositor, d.assets) ==
    -                    STAmount(d.asset, start + Number(25, -1)));
    -                BEAST_EXPECT(
    -                    env.balance(d.vaultAccount, d.assets) ==
    -                    STAmount(d.asset, Number(900 - 25, -1)));
    -                BEAST_EXPECT(
    -                    env.balance(d.vaultAccount, d.shares) ==
    -                    STAmount(d.share, -Number(900 - 25, 0)));
    -            }
    -
    -            {
    -                testcase("Scale withdraw with rounding shares up");
    -                // assetsToSharesWithdraw:
    -                //  shares = sharesTotal * (assets / assetsTotal)
    -                //  shares = 875 * 3.75 / 87.5 = 875 * 0.042857... = 37.5
    -                // sharesToAssetsWithdraw:
    -                //  assets = assetsTotal * (shares / sharesTotal)
    -                //  assets = 87.5 * 38 / 875 = 87.5 * 0.043428... = 3.8
    -
    -                auto const start = env.balance(d.depositor, d.assets).number();
    -                auto tx = d.vault.withdraw(
    -                    {.depositor = d.depositor,
    -                     .id = d.keylet.key,
    -                     .amount = STAmount(d.asset, Number(375, -2))});
    -                env(tx);
    -                env.close();
    -                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(875 - 38));
    -                BEAST_EXPECT(
    -                    env.balance(d.depositor, d.assets) ==
    -                    STAmount(d.asset, start + Number(38, -1)));
    -                BEAST_EXPECT(
    -                    env.balance(d.vaultAccount, d.assets) ==
    -                    STAmount(d.asset, Number(875 - 38, -1)));
    -                BEAST_EXPECT(
    -                    env.balance(d.vaultAccount, d.shares) ==
    -                    STAmount(d.share, -Number(875 - 38, 0)));
    -            }
    -
    -            {
    -                testcase("Scale withdraw with rounding shares down");
    -                // assetsToSharesWithdraw:
    -                //  shares = sharesTotal * (assets / assetsTotal)
    -                //  shares = 837 * 3.72 / 83.7 = 837 * 0.04444... = 37.2
    -                // sharesToAssetsWithdraw:
    -                //  assets = assetsTotal * (shares / sharesTotal)
    -                //  assets = 83.7 * 37 / 837 = 83.7 * 0.044205... = 3.7
    -
    -                auto const start = env.balance(d.depositor, d.assets).number();
    -                auto tx = d.vault.withdraw(
    -                    {.depositor = d.depositor,
    -                     .id = d.keylet.key,
    -                     .amount = STAmount(d.asset, Number(372, -2))});
    -                env(tx);
    -                env.close();
    -                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(837 - 37));
    -                BEAST_EXPECT(
    -                    env.balance(d.depositor, d.assets) ==
    -                    STAmount(d.asset, start + Number(37, -1)));
    -                BEAST_EXPECT(
    -                    env.balance(d.vaultAccount, d.assets) ==
    -                    STAmount(d.asset, Number(837 - 37, -1)));
    -                BEAST_EXPECT(
    -                    env.balance(d.vaultAccount, d.shares) ==
    -                    STAmount(d.share, -Number(837 - 37, 0)));
    -            }
    -
    -            {
    -                testcase("Scale withdraw tiny amount");
    -
    -                auto const start = env.balance(d.depositor, d.assets).number();
    -                auto tx = d.vault.withdraw(
    -                    {.depositor = d.depositor,
    -                     .id = d.keylet.key,
    -                     .amount = STAmount(d.asset, Number(9, -2))});
    -                env(tx);
    -                env.close();
    -                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(800 - 1));
    -                BEAST_EXPECT(
    -                    env.balance(d.depositor, d.assets) == STAmount(d.asset, start + Number(1, -1)));
    -                BEAST_EXPECT(
    -                    env.balance(d.vaultAccount, d.assets) ==
    -                    STAmount(d.asset, Number(800 - 1, -1)));
    -                BEAST_EXPECT(
    -                    env.balance(d.vaultAccount, d.shares) ==
    -                    STAmount(d.share, -Number(800 - 1, 0)));
    -            }
    -
    -            {
    -                testcase("Scale withdraw rest");
    -                auto const rest = env.balance(d.vaultAccount, d.assets).number();
    -
    -                tx = d.vault.withdraw(
    -                    {.depositor = d.depositor,
    -                     .id = d.keylet.key,
    -                     .amount = STAmount(d.asset, rest)});
    -                env(tx);
    -                env.close();
    -                BEAST_EXPECT(env.balance(d.depositor, d.shares).number() == 0);
    -                BEAST_EXPECT(env.balance(d.vaultAccount, d.assets).number() == 0);
    -                BEAST_EXPECT(env.balance(d.vaultAccount, d.shares).number() == 0);
    -            }
    -        });
    -
    -        testCase(18, [&, this](Env& env, Data d) {
    -            testcase("Scale clawback overflow");
    -
    -            {
    -                auto tx = d.vault.deposit(
    -                    {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(5)});
    -                env(tx);
    -                env.close();
    -            }
    -
    -            {
    -                auto tx = d.vault.clawback(
    -                    {.issuer = d.issuer,
    -                     .id = d.keylet.key,
    -                     .holder = d.depositor,
    -                     .amount = STAmount(d.asset, Number(10, 0))});
    -                env(tx, Ter{tecPATH_DRY});
    -                env.close();
    -            }
    -        });
    -
    -        testCase(1, [&, this](Env& env, Data d) {
    -            // initial setup: deposit 100 IOU, receive 1000 shares
    -            auto const start = env.balance(d.depositor, d.assets).number();
    -            auto tx = d.vault.deposit(
    -                {.depositor = d.depositor,
    -                 .id = d.keylet.key,
    -                 .amount = STAmount(d.asset, Number(100, 0))});
    -            env(tx);
    -            env.close();
    -            BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(1000));
    -            BEAST_EXPECT(
    -                env.balance(d.depositor, d.assets) == STAmount(d.asset, start - Number(100, 0)));
    -            BEAST_EXPECT(
    -                env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(100, 0)));
    -            BEAST_EXPECT(
    -                env.balance(d.vaultAccount, d.shares) == STAmount(d.share, -Number(1000, 0)));
    -            {
    -                testcase("Scale clawback exact");
    -                // assetsToSharesWithdraw:
    -                //  shares = sharesTotal * (assets / assetsTotal)
    -                //  shares = 1000 * 10 / 100 = 1000 * 0.1 = 100
    -                // sharesToAssetsWithdraw:
    -                //  assets = assetsTotal * (shares / sharesTotal)
    -                //  assets = 100 * 100 / 1000 = 100 * 0.1 = 10
    -
    -                auto const start = env.balance(d.depositor, d.assets).number();
    -                auto tx = d.vault.clawback(
    -                    {.issuer = d.issuer,
    -                     .id = d.keylet.key,
    -                     .holder = d.depositor,
    -                     .amount = STAmount(d.asset, Number(10, 0))});
    -                env(tx);
    -                env.close();
    -                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(900));
    -                BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start));
    -                BEAST_EXPECT(
    -                    env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(90, 0)));
    -                BEAST_EXPECT(
    -                    env.balance(d.vaultAccount, d.shares) == STAmount(d.share, -Number(900, 0)));
    -            }
    -
    -            {
    -                testcase("Scale clawback insignificant amount");
    -                auto tx = d.vault.clawback(
    -                    {.issuer = d.issuer,
    -                     .id = d.keylet.key,
    -                     .holder = d.depositor,
    -                     .amount = STAmount(d.asset, Number(4, -2))});
    -                env(tx, Ter{tecPRECISION_LOSS});
    -            }
    -
    -            {
    -                testcase("Scale clawback with rounding assets");
    -                // assetsToSharesWithdraw:
    -                //  shares = sharesTotal * (assets / assetsTotal)
    -                //  shares = 900 * 2.5 / 90 = 900 * 0.02777... = 25
    -                // sharesToAssetsWithdraw:
    -                //  assets = assetsTotal * (shares / sharesTotal)
    -                //  assets = 90 * 25 / 900 = 90 * 0.02777... = 2.5
    -
    -                auto const start = env.balance(d.depositor, d.assets).number();
    -                auto tx = d.vault.clawback(
    -                    {.issuer = d.issuer,
    -                     .id = d.keylet.key,
    -                     .holder = d.depositor,
    -                     .amount = STAmount(d.asset, Number(25, -1))});
    -                env(tx);
    -                env.close();
    -                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(900 - 25));
    -                BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start));
    -                BEAST_EXPECT(
    -                    env.balance(d.vaultAccount, d.assets) ==
    -                    STAmount(d.asset, Number(900 - 25, -1)));
    -                BEAST_EXPECT(
    -                    env.balance(d.vaultAccount, d.shares) ==
    -                    STAmount(d.share, -Number(900 - 25, 0)));
    -            }
    -
    -            {
    -                testcase("Scale clawback with rounding shares up");
    -                // assetsToSharesWithdraw:
    -                //  shares = sharesTotal * (assets / assetsTotal)
    -                //  shares = 875 * 3.75 / 87.5 = 875 * 0.042857... = 37.5
    -                // sharesToAssetsWithdraw:
    -                //  assets = assetsTotal * (shares / sharesTotal)
    -                //  assets = 87.5 * 38 / 875 = 87.5 * 0.043428... = 3.8
    -
    -                auto const start = env.balance(d.depositor, d.assets).number();
    -                auto tx = d.vault.clawback(
    -                    {.issuer = d.issuer,
    -                     .id = d.keylet.key,
    -                     .holder = d.depositor,
    -                     .amount = STAmount(d.asset, Number(375, -2))});
    -                env(tx);
    -                env.close();
    -                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(875 - 38));
    -                BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start));
    -                BEAST_EXPECT(
    -                    env.balance(d.vaultAccount, d.assets) ==
    -                    STAmount(d.asset, Number(875 - 38, -1)));
    -                BEAST_EXPECT(
    -                    env.balance(d.vaultAccount, d.shares) ==
    -                    STAmount(d.share, -Number(875 - 38, 0)));
    -            }
    -
    -            {
    -                testcase("Scale clawback with rounding shares down");
    -                // assetsToSharesWithdraw:
    -                //  shares = sharesTotal * (assets / assetsTotal)
    -                //  shares = 837 * 3.72 / 83.7 = 837 * 0.04444... = 37.2
    -                // sharesToAssetsWithdraw:
    -                //  assets = assetsTotal * (shares / sharesTotal)
    -                //  assets = 83.7 * 37 / 837 = 83.7 * 0.044205... = 3.7
    -
    -                auto const start = env.balance(d.depositor, d.assets).number();
    -                auto tx = d.vault.clawback(
    -                    {.issuer = d.issuer,
    -                     .id = d.keylet.key,
    -                     .holder = d.depositor,
    -                     .amount = STAmount(d.asset, Number(372, -2))});
    -                env(tx);
    -                env.close();
    -                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(837 - 37));
    -                BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start));
    -                BEAST_EXPECT(
    -                    env.balance(d.vaultAccount, d.assets) ==
    -                    STAmount(d.asset, Number(837 - 37, -1)));
    -                BEAST_EXPECT(
    -                    env.balance(d.vaultAccount, d.shares) ==
    -                    STAmount(d.share, -Number(837 - 37, 0)));
    -            }
    -
    -            {
    -                testcase("Scale clawback tiny amount");
    -
    -                auto const start = env.balance(d.depositor, d.assets).number();
    -                auto tx = d.vault.clawback(
    -                    {.issuer = d.issuer,
    -                     .id = d.keylet.key,
    -                     .holder = d.depositor,
    -                     .amount = STAmount(d.asset, Number(9, -2))});
    -                env(tx);
    -                env.close();
    -                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(800 - 1));
    -                BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start));
    -                BEAST_EXPECT(
    -                    env.balance(d.vaultAccount, d.assets) ==
    -                    STAmount(d.asset, Number(800 - 1, -1)));
    -                BEAST_EXPECT(
    -                    env.balance(d.vaultAccount, d.shares) ==
    -                    STAmount(d.share, -Number(800 - 1, 0)));
    -            }
    -
    -            {
    -                testcase("Scale clawback rest");
    -                auto const rest = env.balance(d.vaultAccount, d.assets).number();
    -                d.peek([](SLE& vault, auto&) -> bool {
    -                    vault[sfAssetsAvailable] = Number(5);
    -                    return true;
    -                });
    -
    -                // Note, this transaction yields two different results:
    -                // * in the open ledger, with AssetsAvailable = 5
    -                // * when the ledger is closed with unmodified AssetsAvailable
    -                //   because a modification like above is not persistent.
    -                tx = d.vault.clawback(
    -                    {.issuer = d.issuer,
    -                     .id = d.keylet.key,
    -                     .holder = d.depositor,
    -                     .amount = STAmount(d.asset, rest)});
    -                env(tx);
    -                env.close();
    -                BEAST_EXPECT(env.balance(d.depositor, d.shares).number() == 0);
    -                BEAST_EXPECT(env.balance(d.vaultAccount, d.assets).number() == 0);
    -                BEAST_EXPECT(env.balance(d.vaultAccount, d.shares).number() == 0);
    -            }
    -        });
    -
    -        // Non-1:1 ratio (scale=1, 10:1 shares:assets) with an outstanding loan.
    -        // Deposit 100 IOU → 1000 shares. Borrow 40 → assetsAvailable=60.
    -        // Clawback 80 IOU → clamped to 60, then share math uses truncation.
    -        testCase(1, [&, this](Env& env, Data d) {
    -            using namespace loan_broker;
    -            using namespace loan;
    -
    -            testcase("Scale clawback clamped with outstanding loan");
    -
    -            auto tx = d.vault.deposit(
    -                {.depositor = d.depositor,
    -                 .id = d.keylet.key,
    -                 .amount = STAmount(d.asset, Number(100, 0))});
    -            env(tx);
    -            env.close();
    -            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(), SeqProxy::rawSequence(env.seq(d.owner)));
    -            env(set(d.owner, d.keylet.key));
    -            env.close();
    -
    -            // Borrow 40: assetsAvailable=60, assetsTotal=100
    -            env(set(d.depositor, brokerKeylet.key, STAmount(d.asset, Number(40, 0))),
    -                loan::kInterestRate(TenthBips32(0)),
    -                kGracePeriod(60),
    -                kPaymentInterval(120),
    -                kPaymentTotal(10),
    -                Sig(sfCounterpartySignature, d.owner),
    -                Fee(env.current()->fees().base * 2),
    -                Ter(tesSUCCESS));
    -            env.close();
    -
    -            {
    -                auto const sle = env.le(d.keylet);
    -                BEAST_EXPECT(sle->at(sfAssetsAvailable) == STAmount(d.asset, Number(60, 0)));
    -                BEAST_EXPECT(sle->at(sfAssetsTotal) == STAmount(d.asset, Number(100, 0)));
    -            }
    -
    -            // Request 80 IOU clawback — clamped to assetsAvailable (60)
    -            // With scale=1 (10:1), 60 assets = 600 shares destroyed
    -            tx = d.vault.clawback(
    -                {.issuer = d.issuer,
    -                 .id = d.keylet.key,
    -                 .holder = d.depositor,
    -                 .amount = STAmount(d.asset, Number(80, 0))});
    -            env(tx, Ter(tesSUCCESS));
    -            env.close();
    -
    -            {
    -                auto const sle = env.le(d.keylet);
    -                BEAST_EXPECT(sle != nullptr);
    -                BEAST_EXPECT(sle->at(sfAssetsAvailable) == STAmount(d.asset, Number(0, 0)));
    -                BEAST_EXPECT(sle->at(sfAssetsTotal) == STAmount(d.asset, Number(40, 0)));
    -
    -                // 600 of 1000 shares destroyed, 400 remain
    -                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(400));
    -            }
    -        });
    -    }
    -
    -    void
    -    testRPC()
    -    {
    -        using namespace test::jtx;
    -
    -        testcase("RPC");
    -        Env env{*this, testableAmendments()};
    -        Account const owner{"owner"};
    -        Account const issuer{"issuer"};
    -        Vault const vault{env};
    -        env.fund(XRP(1000), issuer, owner);
    -        env.close();
    -
    -        PrettyAsset const asset = issuer["IOU"];
    -        env.trust(asset(1000), owner);
    -        env(pay(issuer, owner, asset(200)));
    -        env.close();
    -
    -        auto const sequence = env.seq(owner);
    -        auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -        env(tx);
    -        env.close();
    -
    -        // Set some fields
    -        {
    -            auto tx1 = vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(50)});
    -            env(tx1);
    -
    -            auto tx2 = vault.set({.owner = owner, .id = keylet.key});
    -            tx2[sfAssetsMaximum] = asset(1000).number();
    -            env(tx2);
    -            env.close();
    -        }
    -
    -        auto const sleVault = [&env, keylet = keylet, this]() {
    -            auto const vault = env.le(keylet);
    -            BEAST_EXPECT(vault != nullptr);
    -            return vault;
    -        }();
    -
    -        auto const check = [&, keylet = keylet, sle = sleVault, this](
    -                               json::Value const& vault,
    -                               json::Value const& issuance = json::ValueType::Null) {
    -            BEAST_EXPECT(vault.isObject());
    -
    -            static constexpr auto kCheckString =
    -                [](auto& node, SField const& field, std::string v) -> bool {
    -                return node.isMember(field.fieldName) && node[field.fieldName].isString() &&
    -                    node[field.fieldName] == v;
    -            };
    -            static constexpr auto kCheckObject =
    -                [](auto& node, SField const& field, json::Value v) -> bool {
    -                return node.isMember(field.fieldName) && node[field.fieldName].isObject() &&
    -                    node[field.fieldName] == v;
    -            };
    -            static constexpr auto kCheckInt = [](auto& node, SField const& field, int v) -> bool {
    -                return node.isMember(field.fieldName) &&
    -                    ((node[field.fieldName].isInt() && node[field.fieldName] == json::Int(v)) ||
    -                     (node[field.fieldName].isUInt() && node[field.fieldName] == json::UInt(v)));
    -            };
    -
    -            BEAST_EXPECT(vault["LedgerEntryType"].asString() == "Vault");
    -            BEAST_EXPECT(vault[jss::index].asString() == strHex(keylet.key));
    -            BEAST_EXPECT(kCheckInt(vault, sfFlags, 0));
    -            // Ignore all other standard fields, this test doesn't care
    -
    -            BEAST_EXPECT(kCheckString(vault, sfAccount, toBase58(sle->at(sfAccount))));
    -            BEAST_EXPECT(kCheckObject(vault, sfAsset, toJson(sle->at(sfAsset))));
    -            BEAST_EXPECT(kCheckString(vault, sfAssetsAvailable, "50"));
    -            BEAST_EXPECT(kCheckString(vault, sfAssetsMaximum, "1000"));
    -            BEAST_EXPECT(kCheckString(vault, sfAssetsTotal, "50"));
    -            BEAST_EXPECT(!vault.isMember(sfLossUnrealized.getJsonName()));
    -
    -            auto const strShareID = strHex(sle->at(sfShareMPTID));
    -            BEAST_EXPECT(kCheckString(vault, sfShareMPTID, strShareID));
    -            BEAST_EXPECT(kCheckString(vault, sfOwner, toBase58(owner.id())));
    -            BEAST_EXPECT(kCheckInt(vault, sfSequence, sequence));
    -            BEAST_EXPECT(kCheckInt(vault, sfWithdrawalPolicy, kVaultStrategyFirstComeFirstServe));
    -
    -            if (issuance.isObject())
    -            {
    -                BEAST_EXPECT(issuance["LedgerEntryType"].asString() == "MPTokenIssuance");
    -                BEAST_EXPECT(issuance[jss::mpt_issuance_id].asString() == strShareID);
    -                BEAST_EXPECT(kCheckInt(issuance, sfSequence, 1));
    -                BEAST_EXPECT(kCheckInt(
    -                    issuance, sfFlags, int(lsfMPTCanEscrow | lsfMPTCanTrade | lsfMPTCanTransfer)));
    -                BEAST_EXPECT(kCheckString(issuance, sfOutstandingAmount, "50000000"));
    -            }
    -        };
    -
    -        {
    -            testcase("RPC ledger_entry selected by key");
    -            json::Value jvParams;
    -            jvParams[jss::ledger_index] = jss::validated;
    -            jvParams[jss::vault] = strHex(keylet.key);
    -            auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
    -
    -            BEAST_EXPECT(!jvVault[jss::result].isMember(jss::error));
    -            BEAST_EXPECT(jvVault[jss::result].isMember(jss::node));
    -            check(jvVault[jss::result][jss::node]);
    -        }
    -
    -        {
    -            testcase("RPC ledger_entry selected by owner and seq");
    -            json::Value jvParams;
    -            jvParams[jss::ledger_index] = jss::validated;
    -            jvParams[jss::vault][jss::owner] = owner.human();
    -            jvParams[jss::vault][jss::seq] = sequence;
    -            auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
    -
    -            BEAST_EXPECT(!jvVault[jss::result].isMember(jss::error));
    -            BEAST_EXPECT(jvVault[jss::result].isMember(jss::node));
    -            check(jvVault[jss::result][jss::node]);
    -        }
    -
    -        {
    -            testcase("RPC ledger_entry cannot find vault by key");
    -            json::Value jvParams;
    -            jvParams[jss::ledger_index] = jss::validated;
    -            jvParams[jss::vault] = to_string(uint256(42));
    -            auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
    -            BEAST_EXPECT(jvVault[jss::result][jss::error].asString() == "entryNotFound");
    -        }
    -
    -        {
    -            testcase("RPC ledger_entry cannot find vault by owner and seq");
    -            json::Value jvParams;
    -            jvParams[jss::ledger_index] = jss::validated;
    -            jvParams[jss::vault][jss::owner] = issuer.human();
    -            jvParams[jss::vault][jss::seq] = 1'000'000;
    -            auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
    -            BEAST_EXPECT(jvVault[jss::result][jss::error].asString() == "entryNotFound");
    -        }
    -
    -        {
    -            testcase("RPC ledger_entry malformed key");
    -            json::Value jvParams;
    -            jvParams[jss::ledger_index] = jss::validated;
    -            jvParams[jss::vault] = 42;
    -            auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
    -            BEAST_EXPECT(jvVault[jss::result][jss::error].asString() == "malformedRequest");
    -        }
    -
    -        {
    -            testcase("RPC ledger_entry malformed owner");
    -            json::Value jvParams;
    -            jvParams[jss::ledger_index] = jss::validated;
    -            jvParams[jss::vault][jss::owner] = 42;
    -            jvParams[jss::vault][jss::seq] = sequence;
    -            auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
    -            BEAST_EXPECT(jvVault[jss::result][jss::error].asString() == "malformedOwner");
    -        }
    -
    -        {
    -            testcase("RPC ledger_entry malformed seq");
    -            json::Value jvParams;
    -            jvParams[jss::ledger_index] = jss::validated;
    -            jvParams[jss::vault][jss::owner] = issuer.human();
    -            jvParams[jss::vault][jss::seq] = "foo";
    -            auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
    -            BEAST_EXPECT(jvVault[jss::result][jss::error].asString() == "malformedRequest");
    -        }
    -
    -        {
    -            testcase("RPC ledger_entry negative seq");
    -            json::Value jvParams;
    -            jvParams[jss::ledger_index] = jss::validated;
    -            jvParams[jss::vault][jss::owner] = issuer.human();
    -            jvParams[jss::vault][jss::seq] = -1;
    -            auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
    -            BEAST_EXPECT(jvVault[jss::result][jss::error].asString() == "malformedRequest");
    -        }
    -
    -        {
    -            testcase("RPC ledger_entry oversized seq");
    -            json::Value jvParams;
    -            jvParams[jss::ledger_index] = jss::validated;
    -            jvParams[jss::vault][jss::owner] = issuer.human();
    -            jvParams[jss::vault][jss::seq] = 1e20;
    -            auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
    -            BEAST_EXPECT(jvVault[jss::result][jss::error].asString() == "malformedRequest");
    -        }
    -
    -        {
    -            testcase("RPC ledger_entry bool seq");
    -            json::Value jvParams;
    -            jvParams[jss::ledger_index] = jss::validated;
    -            jvParams[jss::vault][jss::owner] = issuer.human();
    -            jvParams[jss::vault][jss::seq] = true;
    -            auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
    -            BEAST_EXPECT(jvVault[jss::result][jss::error].asString() == "malformedRequest");
    -        }
    -
    -        {
    -            testcase("RPC account_objects");
    -
    -            json::Value jvParams;
    -            jvParams[jss::account] = owner.human();
    -            jvParams[jss::type] = jss::vault;
    -            auto jv = env.rpc("json", "account_objects", to_string(jvParams))[jss::result];
    -
    -            BEAST_EXPECT(jv[jss::account_objects].size() == 1);
    -            check(jv[jss::account_objects][0u]);
    -        }
    -
    -        {
    -            testcase("RPC ledger_data");
    -
    -            json::Value jvParams;
    -            jvParams[jss::ledger_index] = jss::validated;
    -            jvParams[jss::binary] = false;
    -            jvParams[jss::type] = jss::vault;
    -            json::Value jv = env.rpc("json", "ledger_data", to_string(jvParams));
    -            BEAST_EXPECT(jv[jss::result][jss::state].size() == 1);
    -            check(jv[jss::result][jss::state][0u]);
    -        }
    -
    -        {
    -            testcase("RPC vault_info command line");
    -            json::Value jv = env.rpc("vault_info", strHex(keylet.key), "validated");
    -
    -            BEAST_EXPECT(!jv[jss::result].isMember(jss::error));
    -            BEAST_EXPECT(jv[jss::result].isMember(jss::vault));
    -            check(jv[jss::result][jss::vault], jv[jss::result][jss::vault][jss::shares]);
    -        }
    -
    -        {
    -            testcase("RPC vault_info json");
    -            json::Value jvParams;
    -            jvParams[jss::ledger_index] = jss::validated;
    -            jvParams[jss::vault_id] = strHex(keylet.key);
    -            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
    -
    -            BEAST_EXPECT(!jv[jss::result].isMember(jss::error));
    -            BEAST_EXPECT(jv[jss::result].isMember(jss::vault));
    -            check(jv[jss::result][jss::vault], jv[jss::result][jss::vault][jss::shares]);
    -        }
    -
    -        {
    -            testcase("RPC vault_info invalid vault_id");
    -            json::Value jvParams;
    -            jvParams[jss::ledger_index] = jss::validated;
    -            jvParams[jss::vault_id] = "foobar";
    -            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
    -            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
    -        }
    -
    -        {
    -            testcase("RPC vault_info json invalid index");
    -            json::Value jvParams;
    -            jvParams[jss::ledger_index] = jss::validated;
    -            jvParams[jss::vault_id] = 0;
    -            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
    -            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
    -        }
    -
    -        {
    -            testcase("RPC vault_info json by owner and sequence");
    -            json::Value jvParams;
    -            jvParams[jss::ledger_index] = jss::validated;
    -            jvParams[jss::owner] = owner.human();
    -            jvParams[jss::seq] = sequence;
    -            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
    -
    -            BEAST_EXPECT(!jv[jss::result].isMember(jss::error));
    -            BEAST_EXPECT(jv[jss::result].isMember(jss::vault));
    -            check(jv[jss::result][jss::vault], jv[jss::result][jss::vault][jss::shares]);
    -        }
    -
    -        {
    -            testcase("RPC vault_info json malformed sequence");
    -            json::Value jvParams;
    -            jvParams[jss::ledger_index] = jss::validated;
    -            jvParams[jss::owner] = owner.human();
    -            jvParams[jss::seq] = "foobar";
    -            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
    -            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
    -        }
    -
    -        {
    -            testcase("RPC vault_info json invalid sequence");
    -            json::Value jvParams;
    -            jvParams[jss::ledger_index] = jss::validated;
    -            jvParams[jss::owner] = owner.human();
    -            jvParams[jss::seq] = 0;
    -            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
    -            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
    -        }
    -
    -        {
    -            testcase("RPC vault_info json negative sequence");
    -            json::Value jvParams;
    -            jvParams[jss::ledger_index] = jss::validated;
    -            jvParams[jss::owner] = owner.human();
    -            jvParams[jss::seq] = -1;
    -            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
    -            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
    -        }
    -
    -        {
    -            testcase("RPC vault_info json oversized sequence");
    -            json::Value jvParams;
    -            jvParams[jss::ledger_index] = jss::validated;
    -            jvParams[jss::owner] = owner.human();
    -            jvParams[jss::seq] = 1e20;
    -            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
    -            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
    -        }
    -
    -        {
    -            testcase("RPC vault_info json bool sequence");
    -            json::Value jvParams;
    -            jvParams[jss::ledger_index] = jss::validated;
    -            jvParams[jss::owner] = owner.human();
    -            jvParams[jss::seq] = true;
    -            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
    -            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
    -        }
    -
    -        {
    -            testcase("RPC vault_info json malformed owner");
    -            json::Value jvParams;
    -            jvParams[jss::ledger_index] = jss::validated;
    -            jvParams[jss::owner] = "foobar";
    -            jvParams[jss::seq] = sequence;
    -            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
    -            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
    -        }
    -
    -        {
    -            testcase("RPC vault_info json invalid combination only owner");
    -            json::Value jvParams;
    -            jvParams[jss::ledger_index] = jss::validated;
    -            jvParams[jss::owner] = owner.human();
    -            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
    -            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
    -        }
    -
    -        {
    -            testcase("RPC vault_info json invalid combination only seq");
    -            json::Value jvParams;
    -            jvParams[jss::ledger_index] = jss::validated;
    -            jvParams[jss::seq] = sequence;
    -            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
    -            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
    -        }
    -
    -        {
    -            testcase("RPC vault_info json invalid combination seq vault_id");
    -            json::Value jvParams;
    -            jvParams[jss::ledger_index] = jss::validated;
    -            jvParams[jss::vault_id] = strHex(keylet.key);
    -            jvParams[jss::seq] = sequence;
    -            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
    -            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
    -        }
    -
    -        {
    -            testcase("RPC vault_info json invalid combination owner vault_id");
    -            json::Value jvParams;
    -            jvParams[jss::ledger_index] = jss::validated;
    -            jvParams[jss::vault_id] = strHex(keylet.key);
    -            jvParams[jss::owner] = owner.human();
    -            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
    -            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
    -        }
    -
    -        {
    -            testcase(
    -                "RPC vault_info json invalid combination owner seq "
    -                "vault_id");
    -            json::Value jvParams;
    -            jvParams[jss::ledger_index] = jss::validated;
    -            jvParams[jss::vault_id] = strHex(keylet.key);
    -            jvParams[jss::seq] = sequence;
    -            jvParams[jss::owner] = owner.human();
    -            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
    -            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
    -        }
    -
    -        {
    -            testcase("RPC vault_info json no input");
    -            json::Value jvParams;
    -            jvParams[jss::ledger_index] = jss::validated;
    -            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
    -            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
    -        }
    -
    -        {
    -            testcase("RPC vault_info command line invalid index");
    -            json::Value jv = env.rpc("vault_info", "foobar", "validated");
    -            BEAST_EXPECT(jv[jss::error].asString() == "invalidParams");
    -        }
    -
    -        {
    -            testcase("RPC vault_info command line invalid index");
    -            json::Value jv = env.rpc("vault_info", "0", "validated");
    -            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
    -        }
    -
    -        {
    -            testcase("RPC vault_info command line invalid index");
    -            json::Value jv = env.rpc("vault_info", strHex(uint256(42)), "validated");
    -            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "entryNotFound");
    -        }
    -
    -        {
    -            testcase("RPC vault_info command line invalid ledger");
    -            json::Value jv = env.rpc("vault_info", strHex(keylet.key), "0");
    -            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "lgrNotFound");
    -        }
    -    }
    -
    -    // RPC coverage: closed-ended vaults must return VaultKind, SubscriptionDate and RedemptionDate
    -    // in both vault_info and ledger_entry responses. Open-ended vaults must not.
    -    void
    -    testRPCClosedEnded()
    -    {
    -        using namespace test::jtx;
    -
    -        testcase("RPC closed-ended vault fields");
    -        Env env{*this, testableAmendments()};
    -        Account const owner{"owner"};
    -        Account const owner2{"owner2"};
    -        env.fund(XRP(1000), owner, owner2);
    -        env.close();
    -
    -        auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded);
    -        Asset const asset = xrpIssue();
    -        auto const sub = env.now().time_since_epoch().count() + 60;
    -        auto const red = sub + kMinInvestmentPeriod;
    -
    -        Vault const vault{env};
    -        auto [tx, keylet] = vault.create(
    -            {.owner = owner,
    -             .asset = asset,
    -             .vaultKind = closedEnded,
    -             .subscriptionDate = sub,
    -             .redemptionDate = red});
    -        env(tx);
    -        env.close();
    -
    -        auto [tx2, keylet2] = vault.create({.owner = owner2, .asset = asset});
    -        env(tx2);
    -        env.close();
    -
    -        auto const asUInt = [](json::Value const& jv) -> json::UInt {
    -            return jv.isUInt() ? jv.asUInt() : json::UInt(jv.asInt());
    -        };
    -        auto const checkClosedEnded = [&](json::Value const& v) {
    -            BEAST_EXPECT(v.isObject());
    -            BEAST_EXPECT(v.isMember(sfVaultKind.fieldName));
    -            BEAST_EXPECT(asUInt(v[sfVaultKind.fieldName]) == json::UInt(closedEnded));
    -            BEAST_EXPECT(v.isMember(sfSubscriptionDate.fieldName));
    -            BEAST_EXPECT(asUInt(v[sfSubscriptionDate.fieldName]) == json::UInt(sub));
    -            BEAST_EXPECT(v.isMember(sfRedemptionDate.fieldName));
    -            BEAST_EXPECT(asUInt(v[sfRedemptionDate.fieldName]) == json::UInt(red));
    -        };
    -        auto const checkOpenEnded = [&](json::Value const& v) {
    -            BEAST_EXPECT(v.isObject());
    -            BEAST_EXPECT(!v.isMember(sfVaultKind.fieldName));
    -            BEAST_EXPECT(!v.isMember(sfSubscriptionDate.fieldName));
    -            BEAST_EXPECT(!v.isMember(sfRedemptionDate.fieldName));
    -        };
    -
    -        {
    -            json::Value jvParams;
    -            jvParams[jss::vault_id] = strHex(keylet.key);
    -            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
    -            BEAST_EXPECT(!jv[jss::result].isMember(jss::error));
    -            checkClosedEnded(jv[jss::result][jss::vault]);
    -        }
    -        {
    -            json::Value jvParams;
    -            jvParams[jss::ledger_index] = jss::validated;
    -            jvParams[jss::vault] = strHex(keylet.key);
    -            auto jv = env.rpc("json", "ledger_entry", to_string(jvParams));
    -            BEAST_EXPECT(!jv[jss::result].isMember(jss::error));
    -            checkClosedEnded(jv[jss::result][jss::node]);
    -        }
    -        {
    -            json::Value jvParams;
    -            jvParams[jss::vault_id] = strHex(keylet2.key);
    -            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
    -            BEAST_EXPECT(!jv[jss::result].isMember(jss::error));
    -            checkOpenEnded(jv[jss::result][jss::vault]);
    -        }
    -        {
    -            json::Value jvParams;
    -            jvParams[jss::ledger_index] = jss::validated;
    -            jvParams[jss::vault] = strHex(keylet2.key);
    -            auto jv = env.rpc("json", "ledger_entry", to_string(jvParams));
    -            BEAST_EXPECT(!jv[jss::result].isMember(jss::error));
    -            checkOpenEnded(jv[jss::result][jss::node]);
    -        }
    -    }
    -
    -    void
    -    testVaultClawbackBurnShares()
    -    {
    -        using namespace test::jtx;
    -        using namespace loan_broker;
    -        using namespace loan;
    -        Env env(*this, beast::Severity::Warning);
    -
    -        auto const vaultAssetBalance = [&](Keylet const& vaultKeylet) {
    -            auto const sleVault = env.le(vaultKeylet);
    -            BEAST_EXPECT(sleVault != nullptr);
    -
    -            return std::make_pair(sleVault->at(sfAssetsAvailable), sleVault->at(sfAssetsTotal));
    -        };
    -
    -        auto const vaultShareBalance = [&](Keylet const& vaultKeylet) {
    -            auto const sleVault = env.le(vaultKeylet);
    -            BEAST_EXPECT(sleVault != nullptr);
    -
    -            auto const sleIssuance = env.le(keylet::mptokenIssuance(sleVault->at(sfShareMPTID)));
    -            BEAST_EXPECT(sleIssuance != nullptr);
    -
    -            return sleIssuance->at(sfOutstandingAmount);
    -        };
    -
    -        auto const setupVault = [&](PrettyAsset const& asset,
    -                                    Account const& owner,
    -                                    Account const& depositor) -> std::pair {
    -            Vault const vault{env};
    -
    -            auto const& [tx, vaultKeylet] = vault.create({.owner = owner, .asset = asset});
    -            env(tx, Ter(tesSUCCESS));
    -            env.close();
    -
    -            auto const& vaultSle = env.le(vaultKeylet);
    -            BEAST_EXPECT(vaultSle != nullptr);
    -
    -            Asset const share = vaultSle->at(sfShareMPTID);
    -
    -            env(vault.deposit(
    -                    {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}),
    -                Ter(tesSUCCESS));
    -            env.close();
    -
    -            auto const& [availablePreDefault, totalPreDefault] = vaultAssetBalance(vaultKeylet);
    -            BEAST_EXPECT(availablePreDefault == totalPreDefault);
    -            BEAST_EXPECT(availablePreDefault == asset(100).value());
    -
    -            // attempt to clawback shares while there are assets fails
    -            env(vault.clawback(
    -                    {.issuer = owner,
    -                     .id = vaultKeylet.key,
    -                     .holder = depositor,
    -                     .amount = share(0).value()}),
    -                Ter(tecNO_PERMISSION));
    -            env.close();
    -
    -            auto const& sharesAvailable = vaultShareBalance(vaultKeylet);
    -            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, SeqProxy::rawSequence(1));
    -
    -            // Create a simple Loan for the full amount of Vault assets
    -            env(set(depositor, brokerKeylet.key, asset(100).value()),
    -                loan::kInterestRate(TenthBips32(0)),
    -                kGracePeriod(60),
    -                kPaymentInterval(120),
    -                kPaymentTotal(10),
    -                Sig(sfCounterpartySignature, owner),
    -                Fee(env.current()->fees().base * 2),
    -                Ter(tesSUCCESS));
    -            env.close();
    -
    -            // attempt to clawback shares while there assetsAvailable == 0 and
    -            // assetsTotal > 0 fails
    -            env(vault.clawback(
    -                    {.issuer = owner,
    -                     .id = vaultKeylet.key,
    -                     .holder = depositor,
    -                     .amount = share(0).value()}),
    -                Ter(tecNO_PERMISSION));
    -            env.close();
    -
    -            env.close(std::chrono::seconds{120 + 60});
    -
    -            env(manage(owner, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS));
    -
    -            auto const& [availablePostDefault, totalPostDefault] = vaultAssetBalance(vaultKeylet);
    -
    -            BEAST_EXPECT(availablePostDefault == totalPostDefault);
    -            BEAST_EXPECT(availablePostDefault == asset(0).value());
    -            BEAST_EXPECT(vaultShareBalance(vaultKeylet) == sharesAvailable);
    -
    -            return std::make_pair(vault, vaultKeylet);
    -        };
    -
    -        auto const testCase = [&](PrettyAsset const& asset,
    -                                  std::string const& prefix,
    -                                  Account const& owner,
    -                                  Account const& depositor) {
    -            {
    -                testcase("VaultClawback (share) - " + prefix + " owner asset clawback fails");
    -                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor);
    -                // when asset is XRP or owner is not issuer clawback fail
    -                // when owner is issuer precision loss occurs as vault is
    -                // empty
    -                auto const expectedTer = [&]() {
    -                    if (asset.native())
    -                        return Ter(temMALFORMED);
    -                    if (asset.raw().getIssuer() != owner.id())
    -                        return Ter(tecNO_PERMISSION);
    -                    return Ter(tecPRECISION_LOSS);
    -                }();
    -                env(vault.clawback({
    -                        .issuer = owner,
    -                        .id = vaultKeylet.key,
    -                        .holder = depositor,
    -                        .amount = asset(100).value(),
    -                    }),
    -                    expectedTer);
    -                env.close();
    -            }
    -
    -            {
    -                testcase(
    -                    "VaultClawback (share) - " + prefix + " owner incomplete share clawback fails");
    -                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor);
    -                auto const& vaultSle = env.le(vaultKeylet);
    -                if (!BEAST_EXPECT(vaultSle))
    -                    return;
    -                Asset const share = vaultSle->at(sfShareMPTID);
    -                env(vault.clawback({
    -                        .issuer = owner,
    -                        .id = vaultKeylet.key,
    -                        .holder = depositor,
    -                        .amount = share(1).value(),
    -                    }),
    -                    Ter(tecLIMIT_EXCEEDED));
    -                env.close();
    -            }
    -
    -            {
    -                testcase(
    -                    "VaultClawback (share) - " + prefix +
    -                    " owner implicit complete share clawback");
    -                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor);
    -                env(vault.clawback({
    -                        .issuer = owner,
    -                        .id = vaultKeylet.key,
    -                        .holder = depositor,
    -                    }),
    -                    // when owner is issuer implicit clawback fails
    -                    asset.native() || asset.raw().getIssuer() != owner.id() ? Ter(tesSUCCESS)
    -                                                                            : Ter(tecWRONG_ASSET));
    -                env.close();
    -            }
    -
    -            {
    -                testcase(
    -                    "VaultClawback (share) - " + prefix +
    -                    " owner explicit complete share clawback succeeds");
    -                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor);
    -                auto const& vaultSle = env.le(vaultKeylet);
    -                if (!BEAST_EXPECT(vaultSle))
    -                    return;
    -                Asset const share = vaultSle->at(sfShareMPTID);
    -                env(vault.clawback({
    -                        .issuer = owner,
    -                        .id = vaultKeylet.key,
    -                        .holder = depositor,
    -                        .amount = share(vaultShareBalance(vaultKeylet)).value(),
    -                    }),
    -                    Ter(tesSUCCESS));
    -                env.close();
    -            }
    -            {
    -                testcase("VaultClawback (share) - " + prefix + " owner can clawback own shares");
    -                auto [vault, vaultKeylet] = setupVault(asset, owner, owner);
    -                auto const& vaultSle = env.le(vaultKeylet);
    -                if (!BEAST_EXPECT(vaultSle))
    -                    return;
    -                Asset const share = vaultSle->at(sfShareMPTID);
    -                env(vault.clawback({
    -                        .issuer = owner,
    -                        .id = vaultKeylet.key,
    -                        .holder = owner,
    -                        .amount = share(vaultShareBalance(vaultKeylet)).value(),
    -                    }),
    -                    Ter(tesSUCCESS));
    -                env.close();
    -            }
    -
    -            {
    -                testcase("VaultClawback (share) - " + prefix + " empty vault share clawback fails");
    -                auto [vault, vaultKeylet] = setupVault(asset, owner, owner);
    -                auto const& vaultSle = env.le(vaultKeylet);
    -                if (!BEAST_EXPECT(vaultSle))
    -                    return;
    -                Asset const share = vaultSle->at(sfShareMPTID);
    -                env(vault.clawback({
    -                        .issuer = owner,
    -                        .id = vaultKeylet.key,
    -                        .holder = owner,
    -                        .amount = share(vaultShareBalance(vaultKeylet)).value(),
    -                    }),
    -                    Ter(tesSUCCESS));
    -
    -                // Now the vault is empty, clawback again fails
    -                env(vault.clawback({
    -                        .issuer = owner,
    -                        .id = vaultKeylet.key,
    -                        .holder = owner,
    -                        .amount = share(vaultShareBalance(vaultKeylet)).value(),
    -                    }),
    -                    Ter(tecNO_PERMISSION));
    -                env.close();
    -            }
    -        };
    -
    -        Account const owner{"alice"};
    -        Account const depositor{"bob"};
    -        Account const issuer{"issuer"};
    -
    -        env.fund(XRP(10000), issuer, owner, depositor);
    -        env.close();
    -
    -        // Test XRP
    -        PrettyAsset const xrp = xrpIssue();
    -        testCase(xrp, "XRP", owner, depositor);
    -        testCase(xrp, "XRP (depositor is owner)", owner, owner);
    -
    -        // Test IOU
    -        PrettyAsset const iou = issuer["IOU"];
    -        env(fset(issuer, asfAllowTrustLineClawback));
    -        env.close();
    -
    -        env.trust(iou(1000), owner);
    -        env.trust(iou(1000), depositor);
    -        env(pay(issuer, owner, iou(100)));
    -        env(pay(issuer, depositor, iou(100)));
    -        env.close();
    -        testCase(iou, "IOU", owner, depositor);
    -        testCase(iou, "IOU (owner is issuer)", issuer, depositor);
    -
    -        // Test MPT
    -        MPTTester mptt{env, issuer, kMptInitNoFund};
    -        mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
    -        PrettyAsset const mpt = mptt.issuanceID();
    -        mptt.authorize({.account = owner});
    -        mptt.authorize({.account = depositor});
    -        env(pay(issuer, owner, mpt(1000)));
    -        env(pay(issuer, depositor, mpt(1000)));
    -        env.close();
    -        testCase(mpt, "MPT", owner, depositor);
    -        testCase(mpt, "MPT (owner is issuer)", issuer, depositor);
    -    }
    -
    -    void
    -    testVaultClawbackAssets()
    -    {
    -        using namespace test::jtx;
    -        using namespace loan_broker;
    -        using namespace loan;
    -        Env env(*this);
    -        env.enableFeature(fixCleanup3_1_3);
    -
    -        auto const setupVault = [&](PrettyAsset const& asset,
    -                                    Account const& owner,
    -                                    Account const& depositor,
    -                                    Account const& issuer) -> std::pair {
    -            Vault const vault{env};
    -
    -            auto const& [tx, vaultKeylet] = vault.create({.owner = owner, .asset = asset});
    -            env(tx, Ter(tesSUCCESS));
    -            env.close();
    -
    -            auto const& vaultSle = env.le(vaultKeylet);
    -            BEAST_EXPECT(vaultSle != nullptr);
    -            env.memoize(Account("vault", vaultSle->at(sfAccount)));
    -            env(vault.deposit(
    -                    {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}),
    -                Ter(tesSUCCESS));
    -            env.close();
    -
    -            return std::make_pair(vault, vaultKeylet);
    -        };
    -
    -        auto const testCase = [&](PrettyAsset const& asset,
    -                                  std::string const& prefix,
    -                                  Account const& owner,
    -                                  Account const& depositor,
    -                                  Account const& issuer) {
    -            if (asset.native())
    -            {
    -                testcase("VaultClawback (asset) - " + prefix + " issuer XRP clawback fails");
    -                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
    -                // If the asset is XRP, clawback with amount fails as malformed
    -                // when asset is specified.
    -                env(vault.clawback({
    -                        .issuer = issuer,
    -                        .id = vaultKeylet.key,
    -                        .holder = issuer,
    -                        .amount = asset(1).value(),
    -                    }),
    -                    Ter(temMALFORMED));
    -                // When asset is implicit, clawback fails as no permission.
    -                env(vault.clawback({
    -                        .issuer = issuer,
    -                        .id = vaultKeylet.key,
    -                        .holder = issuer,
    -                    }),
    -                    Ter(tecNO_PERMISSION));
    -                return;
    -            }
    -
    -            {
    -                testcase(
    -                    "VaultClawback (asset) - " + prefix + " clawback for different asset fails");
    -                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
    -
    -                Account const issuer2{"issuer2"};
    -                PrettyAsset const asset2 = issuer2["FOO"];
    -                env(vault.clawback({
    -                        .issuer = issuer,
    -                        .id = vaultKeylet.key,
    -                        .holder = depositor,
    -                        .amount = asset2(1).value(),
    -                    }),
    -                    Ter(tecWRONG_ASSET));
    -            }
    -
    -            {
    -                testcase(
    -                    "VaultClawback (asset) - " + prefix +
    -                    " ambiguous owner/issuer asset clawback fails");
    -                auto [vault, vaultKeylet] = setupVault(asset, issuer, depositor, issuer);
    -                env(vault.clawback({
    -                        .issuer = issuer,
    -                        .id = vaultKeylet.key,
    -                        .holder = issuer,
    -                    }),
    -                    Ter(tecWRONG_ASSET));
    -            }
    -
    -            {
    -                testcase("VaultClawback (asset) - " + prefix + " non-issuer asset clawback fails");
    -                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
    -
    -                env(vault.clawback({
    -                        .issuer = owner,
    -                        .id = vaultKeylet.key,
    -                        .holder = depositor,
    -                    }),
    -                    Ter(tecNO_PERMISSION));
    -
    -                env(vault.clawback({
    -                        .issuer = owner,
    -                        .id = vaultKeylet.key,
    -                        .holder = depositor,
    -                        .amount = asset(1).value(),
    -                    }),
    -                    Ter(tecNO_PERMISSION));
    -            }
    -
    -            {
    -                testcase("VaultClawback (asset) - " + prefix + " issuer clawback from self fails");
    -                auto [vault, vaultKeylet] = setupVault(asset, owner, issuer, issuer);
    -                env(vault.clawback({
    -                        .issuer = issuer,
    -                        .id = vaultKeylet.key,
    -                        .holder = issuer,
    -                    }),
    -                    Ter(tecNO_PERMISSION));
    -            }
    -
    -            {
    -                testcase("VaultClawback (asset) - " + prefix + " issuer share clawback fails");
    -                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
    -                auto const& vaultSle = env.le(vaultKeylet);
    -                if (!BEAST_EXPECT(vaultSle))
    -                    return;
    -                Asset const share = vaultSle->at(sfShareMPTID);
    -
    -                env(vault.clawback({
    -                        .issuer = issuer,
    -                        .id = vaultKeylet.key,
    -                        .holder = depositor,
    -                        .amount = share(1).value(),
    -                    }),
    -                    Ter(tecNO_PERMISSION));
    -            }
    -
    -            {
    -                testcase(
    -                    "VaultClawback (asset) - " + prefix +
    -                    " partial issuer asset clawback succeeds");
    -                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
    -
    -                env(vault.clawback({
    -                        .issuer = issuer,
    -                        .id = vaultKeylet.key,
    -                        .holder = depositor,
    -                        .amount = asset(1).value(),
    -                    }),
    -                    Ter(tesSUCCESS));
    -            }
    -
    -            {
    -                testcase(
    -                    "VaultClawback (asset) - " + prefix + " full issuer asset clawback succeeds");
    -                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
    -
    -                env(vault.clawback({
    -                        .issuer = issuer,
    -                        .id = vaultKeylet.key,
    -                        .holder = depositor,
    -                        .amount = asset(100).value(),
    -                    }),
    -                    Ter(tesSUCCESS));
    -            }
    -
    -            {
    -                testcase(
    -                    "VaultClawback (asset) - " + prefix +
    -                    " implicit full issuer asset clawback succeeds");
    -                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
    -
    -                env(vault.clawback({
    -                        .issuer = issuer,
    -                        .id = vaultKeylet.key,
    -                        .holder = depositor,
    -                    }),
    -                    Ter(tesSUCCESS));
    -            }
    -
    -            {
    -                testcase(
    -                    "VaultClawback (asset) - " + prefix +
    -                    " zero-amount clawback clamped with outstanding loan");
    -                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
    -
    -                auto const vaultSle = env.le(vaultKeylet);
    -                if (!BEAST_EXPECT(vaultSle))
    -                    return;
    -
    -                PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
    -
    -                // Create a loan broker backed by this vault
    -                auto const brokerKeylet =
    -                    keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
    -                env(set(owner, vaultKeylet.key));
    -                env.close();
    -
    -                // Depositor borrows 40 units, reducing assetsAvailable to 60
    -                // while assetsTotal stays at 100
    -                env(set(depositor, brokerKeylet.key, asset(40).value()),
    -                    loan::kInterestRate(TenthBips32(0)),
    -                    kGracePeriod(60),
    -                    kPaymentInterval(120),
    -                    kPaymentTotal(10),
    -                    Sig(sfCounterpartySignature, owner),
    -                    Fee(env.current()->fees().base * 2),
    -                    Ter(tesSUCCESS));
    -                env.close();
    -
    -                {
    -                    auto const sle = env.le(vaultKeylet);
    -                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(60).value());
    -                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(100).value());
    -                }
    -
    -                // Zero-amount clawback (= "clawback all") should succeed,
    -                // clamped to assetsAvailable (60) rather than the full
    -                // share value (100).
    -                env(vault.clawback({
    -                        .issuer = issuer,
    -                        .id = vaultKeylet.key,
    -                        .holder = depositor,
    -                    }),
    -                    Ter(tesSUCCESS));
    -                env.close();
    -
    -                // Only 60 assets clawed back; loan's 40 still outstanding
    -                {
    -                    auto const sle = env.le(vaultKeylet);
    -                    BEAST_EXPECT(sle != nullptr);
    -                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(0).value());
    -                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(40).value());
    -
    -                    // 60 of 100 shares destroyed (1:1 ratio), 40 remain
    -                    auto const sharesAfter = env.balance(depositor, shares);
    -                    BEAST_EXPECT(sharesAfter == shares(Number{4, sle->at(sfScale) + 1}));
    -                }
    -            }
    -
    -            {
    -                testcase(
    -                    "VaultClawback (asset) - " + prefix +
    -                    " non-zero clawback clamped with outstanding loan");
    -                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
    -
    -                auto const vaultSle = env.le(vaultKeylet);
    -                if (!BEAST_EXPECT(vaultSle))
    -                    return;
    -                PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
    -
    -                // Create a loan broker backed by this vault
    -                auto const brokerKeylet =
    -                    keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
    -                env(set(owner, vaultKeylet.key));
    -                env.close();
    -
    -                // Depositor borrows 40 units
    -                env(set(depositor, brokerKeylet.key, asset(40).value()),
    -                    loan::kInterestRate(TenthBips32(0)),
    -                    kGracePeriod(60),
    -                    kPaymentInterval(120),
    -                    kPaymentTotal(10),
    -                    Sig(sfCounterpartySignature, owner),
    -                    Fee(env.current()->fees().base * 2),
    -                    Ter(tesSUCCESS));
    -                env.close();
    -
    -                {
    -                    auto const sle = env.le(vaultKeylet);
    -                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(60).value());
    -                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(100).value());
    -                }
    -
    -                // Request 100 but only 60 available — clamped to 60
    -                env(vault.clawback({
    -                        .issuer = issuer,
    -                        .id = vaultKeylet.key,
    -                        .holder = depositor,
    -                        .amount = asset(100).value(),
    -                    }),
    -                    Ter(tesSUCCESS));
    -                env.close();
    -
    -                {
    -                    auto const sle = env.le(vaultKeylet);
    -                    BEAST_EXPECT(sle != nullptr);
    -                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(0).value());
    -                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(40).value());
    -
    -                    // 60 of 100 shares destroyed (1:1 ratio), 40 remain
    -                    auto const sharesAfter = env.balance(depositor, shares);
    -                    BEAST_EXPECT(sharesAfter == shares(Number{4, sle->at(sfScale) + 1}));
    -                }
    -            }
    -
    -            {
    -                testcase(
    -                    "VaultClawback (asset) - " + prefix +
    -                    " partial clawback below available with outstanding loan");
    -                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
    -
    -                auto const vaultSle = env.le(vaultKeylet);
    -                if (!BEAST_EXPECT(vaultSle))
    -                    return;
    -                PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
    -
    -                // Create a loan broker backed by this vault
    -                auto const brokerKeylet =
    -                    keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
    -                env(set(owner, vaultKeylet.key));
    -                env.close();
    -
    -                // Depositor borrows 40 units: assetsAvailable=60, assetsTotal=100
    -                env(set(depositor, brokerKeylet.key, asset(40).value()),
    -                    loan::kInterestRate(TenthBips32(0)),
    -                    kGracePeriod(60),
    -                    kPaymentInterval(120),
    -                    kPaymentTotal(10),
    -                    Sig(sfCounterpartySignature, owner),
    -                    Fee(env.current()->fees().base * 2),
    -                    Ter(tesSUCCESS));
    -                env.close();
    -
    -                {
    -                    auto const sle = env.le(vaultKeylet);
    -                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(60).value());
    -                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(100).value());
    -                }
    -
    -                // Clawback 30 — well under available (60), no clamping needed
    -                env(vault.clawback({
    -                        .issuer = issuer,
    -                        .id = vaultKeylet.key,
    -                        .holder = depositor,
    -                        .amount = asset(30).value(),
    -                    }),
    -                    Ter(tesSUCCESS));
    -                env.close();
    -
    -                {
    -                    auto const sle = env.le(vaultKeylet);
    -                    BEAST_EXPECT(sle != nullptr);
    -                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(30).value());
    -                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(70).value());
    -
    -                    // 30 of 100 shares destroyed (1:1 ratio), 70 remain
    -                    auto const sharesAfter = env.balance(depositor, shares);
    -                    BEAST_EXPECT(sharesAfter == shares(Number{7, sle->at(sfScale) + 1}));
    -                }
    -            }
    -
    -            {
    -                testcase(
    -                    "VaultClawback (asset) - " + prefix +
    -                    " clawback exactly equal to available with outstanding loan");
    -                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
    -
    -                auto const vaultSle = env.le(vaultKeylet);
    -                if (!BEAST_EXPECT(vaultSle))
    -                    return;
    -                PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
    -
    -                auto const brokerKeylet =
    -                    keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
    -                env(set(owner, vaultKeylet.key));
    -                env.close();
    -
    -                // Depositor borrows 40 units: assetsAvailable=60, assetsTotal=100
    -                env(set(depositor, brokerKeylet.key, asset(40).value()),
    -                    loan::kInterestRate(TenthBips32(0)),
    -                    kGracePeriod(60),
    -                    kPaymentInterval(120),
    -                    kPaymentTotal(10),
    -                    Sig(sfCounterpartySignature, owner),
    -                    Fee(env.current()->fees().base * 2),
    -                    Ter(tesSUCCESS));
    -                env.close();
    -
    -                // Clawback exactly 60 — at the boundary, no clamping needed
    -                env(vault.clawback({
    -                        .issuer = issuer,
    -                        .id = vaultKeylet.key,
    -                        .holder = depositor,
    -                        .amount = asset(60).value(),
    -                    }),
    -                    Ter(tesSUCCESS));
    -                env.close();
    -
    -                {
    -                    auto const sle = env.le(vaultKeylet);
    -                    BEAST_EXPECT(sle != nullptr);
    -                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(0).value());
    -                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(40).value());
    -
    -                    // 60 of 100 shares destroyed (1:1 ratio), 40 remain
    -                    auto const sharesAfter = env.balance(depositor, shares);
    -                    BEAST_EXPECT(sharesAfter == shares(Number{4, sle->at(sfScale) + 1}));
    -                }
    -            }
    -
    -            {
    -                testcase(
    -                    "VaultClawback (asset) - " + prefix +
    -                    " clawback with zero available (fully borrowed)");
    -                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
    -
    -                auto const vaultSle = env.le(vaultKeylet);
    -                if (!BEAST_EXPECT(vaultSle))
    -                    return;
    -                PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
    -
    -                auto const brokerKeylet =
    -                    keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
    -                env(set(owner, vaultKeylet.key));
    -                env.close();
    -
    -                // Depositor borrows all 100 units: assetsAvailable=0, assetsTotal=100
    -                env(set(depositor, brokerKeylet.key, asset(100).value()),
    -                    loan::kInterestRate(TenthBips32(0)),
    -                    kGracePeriod(60),
    -                    kPaymentInterval(120),
    -                    kPaymentTotal(10),
    -                    Sig(sfCounterpartySignature, owner),
    -                    Fee(env.current()->fees().base * 2),
    -                    Ter(tesSUCCESS));
    -                env.close();
    -
    -                {
    -                    auto const sle = env.le(vaultKeylet);
    -                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(0).value());
    -                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(100).value());
    -                }
    -
    -                auto const sharesBefore = env.balance(depositor, shares);
    -
    -                // Zero-amount clawback — nothing available, clamped to 0,
    -                // resulting in zero shares destroyed → tecPRECISION_LOSS
    -                env(vault.clawback({
    -                        .issuer = issuer,
    -                        .id = vaultKeylet.key,
    -                        .holder = depositor,
    -                    }),
    -                    Ter(tecPRECISION_LOSS));
    -                env.close();
    -
    -                // Explicit amount clawback — also nothing available
    -                env(vault.clawback({
    -                        .issuer = issuer,
    -                        .id = vaultKeylet.key,
    -                        .holder = depositor,
    -                        .amount = asset(50).value(),
    -                    }),
    -                    Ter(tecPRECISION_LOSS));
    -                env.close();
    -
    -                {
    -                    // Nothing changed — vault and shares unchanged
    -                    auto const sle = env.le(vaultKeylet);
    -                    BEAST_EXPECT(sle != nullptr);
    -                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(0).value());
    -                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(100).value());
    -                    auto const sharesAfter = env.balance(depositor, shares);
    -                    BEAST_EXPECT(sharesAfter == sharesBefore);
    -                }
    -            }
    -        };
    -
    -        Account const owner{"alice"};
    -        Account const depositor{"bob"};
    -        Account const issuer{"issuer"};
    -
    -        env.fund(XRP(10000), issuer, owner, depositor);
    -        env.close();
    -
    -        // Test XRP
    -        PrettyAsset const xrp = xrpIssue();
    -        testCase(xrp, "XRP", owner, depositor, issuer);
    -
    -        // Test IOU
    -        PrettyAsset const iou = issuer["IOU"];
    -        env(fset(issuer, asfAllowTrustLineClawback));
    -        env.close();
    -        env.trust(iou(2000), owner);
    -        env.trust(iou(2000), depositor);
    -        env(pay(issuer, owner, iou(2000)));
    -        env(pay(issuer, depositor, iou(2000)));
    -        env.close();
    -        testCase(iou, "IOU", owner, depositor, issuer);
    -
    -        // Test MPT
    -        MPTTester mptt{env, issuer, kMptInitNoFund};
    -        mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
    -
    -        PrettyAsset const mpt = mptt.issuanceID();
    -        mptt.authorize({.account = owner});
    -        mptt.authorize({.account = depositor});
    -        env(pay(issuer, depositor, mpt(2000)));
    -        env.close();
    -        testCase(mpt, "MPT", owner, depositor, issuer);
    -
    -        // Test pre-fixCleanup3_1_3 legacy path: zero-amount clawback
    -        // returns early without clamping to assetsAvailable.
    -        {
    -            testcase(
    -                "VaultClawback (asset) - IOU pre-fixCleanup3_1_3"
    -                " zero-amount clawback unclamped with outstanding loan");
    -
    -            env.disableFeature(fixCleanup3_1_3);
    -
    -            auto [vault, vaultKeylet] = setupVault(iou, owner, depositor, issuer);
    -
    -            auto const vaultSle = env.le(vaultKeylet);
    -            BEAST_EXPECT(vaultSle != nullptr);
    -            if (!vaultSle)
    -                return;
    -
    -            PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
    -
    -            // Create a loan broker backed by this vault
    -            auto const brokerKeylet =
    -                keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
    -            env(set(owner, vaultKeylet.key));
    -            env.close();
    -
    -            // Depositor borrows 40 units, reducing assetsAvailable to 60
    -            // while assetsTotal stays at 100
    -            env(set(depositor, brokerKeylet.key, iou(40).value()),
    -                loan::kInterestRate(TenthBips32(0)),
    -                kGracePeriod(60),
    -                kPaymentInterval(120),
    -                kPaymentTotal(10),
    -                Sig(sfCounterpartySignature, owner),
    -                Fee(env.current()->fees().base * 2),
    -                Ter(tesSUCCESS));
    -            env.close();
    -
    -            {
    -                auto const sle = env.le(vaultKeylet);
    -                BEAST_EXPECT(sle->at(sfAssetsAvailable) == iou(60).value());
    -                BEAST_EXPECT(sle->at(sfAssetsTotal) == iou(100).value());
    -            }
    -
    -            auto const sharesBefore = env.balance(depositor, shares);
    -
    -            // Legacy: zero-amount clawback tries to recover the full
    -            // share value (100) without clamping to assetsAvailable (60).
    -            // This causes the vault balance to go negative, triggering
    -            // the sanity check in doApply → tefINTERNAL.
    -            env(vault.clawback({
    -                    .issuer = issuer,
    -                    .id = vaultKeylet.key,
    -                    .holder = depositor,
    -                }),
    -                Ter(tefINTERNAL));
    -            env.close();
    -
    -            {
    -                // Transaction rolled back — vault and shares unchanged
    -                auto const sle = env.le(vaultKeylet);
    -                BEAST_EXPECT(sle != nullptr);
    -                BEAST_EXPECT(sle->at(sfAssetsAvailable) == iou(60).value());
    -                BEAST_EXPECT(sle->at(sfAssetsTotal) == iou(100).value());
    -                auto const sharesAfter = env.balance(depositor, shares);
    -                BEAST_EXPECT(sharesAfter == sharesBefore);
    -            }
    -
    -            env.enableFeature(fixCleanup3_1_3);
    -        }
    -    }
    -
    -    void
    -    testAssetsMaximum()
    -    {
    -        testcase("Assets Maximum");
    -
    -        using namespace test::jtx;
    -
    -        Env env{*this, testableAmendments()};
    -        Account const owner{"owner"};
    -        Account const issuer{"issuer"};
    -
    -        Vault const vault{env};
    -        env.fund(XRP(1'000'000), issuer, owner);
    -        env.close();
    -
    -        auto const maxInt64 = std::to_string(std::numeric_limits::max());
    -        BEAST_EXPECT(maxInt64 == "9223372036854775807");
    -
    -        auto const maxInt64Plus1 = std::to_string(
    -            static_cast(std::numeric_limits::max()) + 1);
    -        BEAST_EXPECT(maxInt64Plus1 == "9223372036854775808");
    -
    -        // Naming things is hard
    -        auto const maxInt64Plus2 = std::to_string(
    -            static_cast(std::numeric_limits::max()) + 2);
    -        BEAST_EXPECT(maxInt64Plus2 == "9223372036854775809");
    -
    -        auto const initialXRP = to_string(kInitialXrp);
    -        BEAST_EXPECT(initialXRP == "100000000000000000");
    -
    -        auto const initialXRPPlus1 = to_string(kInitialXrp + 1);
    -        BEAST_EXPECT(initialXRPPlus1 == "100000000000000001");
    -
    -        {
    -            testcase("Assets Maximum: XRP");
    -
    -            PrettyAsset const xrpAsset = xrpIssue();
    -
    -            auto [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset});
    -            tx[sfData] = "4D65746144617461";
    -
    -            tx[sfAssetsMaximum] = maxInt64;
    -            env(tx, Ter(tefEXCEPTION));
    -            env.close();
    -
    -            tx[sfAssetsMaximum] = initialXRPPlus1;
    -            env(tx, Ter(tefEXCEPTION));
    -            env.close();
    -
    -            tx[sfAssetsMaximum] = initialXRP;
    -            env(tx);
    -            env.close();
    -
    -            // There are several parse failures expected in this function, so just disable it once.
    -            env.setParseFailureExpected(true);
    -            try
    -            {
    -                tx[sfAssetsMaximum] = maxInt64Plus1;
    -                env(tx, Ter(tefEXCEPTION));
    -                env.close();
    -                // should throw in parser
    -                fail();
    -            }
    -            catch (std::exception const& e)
    -            {
    -                BEAST_EXPECT(
    -                    std::string(e.what()) ==
    -                    "invalidParamsField 'tx_json.AssetsMaximum' has invalid data.");
    -            }
    -
    -            try
    -            {
    -                tx[sfAssetsMaximum] = maxInt64Plus2;
    -                env(tx, Ter(tefEXCEPTION));
    -                // should throw in parser
    -                fail();
    -            }
    -            catch (std::exception const& e)
    -            {
    -                BEAST_EXPECT(
    -                    std::string(e.what()) ==
    -                    "invalidParamsField 'tx_json.AssetsMaximum' has invalid data.");
    -            }
    -
    -            auto const newKeylet = keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
    -            try
    -            {
    -                auto const insertAt = maxInt64Plus2.size() - 3;
    -                auto const decimalTest = maxInt64Plus2.substr(0, insertAt) + "." +
    -                    maxInt64Plus2.substr(insertAt);  // (max int64+2) / 1000
    -                BEAST_EXPECT(decimalTest == "9223372036854775.809");
    -                tx[sfAssetsMaximum] = decimalTest;
    -                env(tx);
    -                // should throw in parser
    -                fail();
    -            }
    -            catch (std::exception const& e)
    -            {
    -                BEAST_EXPECT(
    -                    std::string(e.what()) ==
    -                    "invalidParamsField 'tx_json.AssetsMaximum' has invalid data.");
    -            }
    -
    -            auto const vaultSle = env.le(newKeylet);
    -            BEAST_EXPECT(!vaultSle);
    -        }
    -
    -        {
    -            testcase("Assets Maximum: MPT");
    -
    -            PrettyAsset const mptAsset = [&]() {
    -                MPTTester mptt{env, issuer, kMptInitNoFund};
    -                mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
    -                env.close();
    -                PrettyAsset const mptAsset = mptt["MPT"];
    -                mptt.authorize({.account = owner});
    -                env.close();
    -                return mptAsset;
    -            }();
    -
    -            env(pay(issuer, owner, mptAsset(100'000)));
    -            env.close();
    -
    -            auto [tx, keylet] = vault.create({.owner = owner, .asset = mptAsset});
    -            tx[sfData] = "4D65746144617461";
    -
    -            tx[sfAssetsMaximum] = maxInt64;
    -            env(tx);
    -            env.close();
    -
    -            tx[sfAssetsMaximum] = initialXRPPlus1;
    -            env(tx);
    -            env.close();
    -
    -            tx[sfAssetsMaximum] = initialXRP;
    -            env(tx);
    -            env.close();
    -
    -            try
    -            {
    -                tx[sfAssetsMaximum] = maxInt64Plus2;
    -                env(tx, Ter(tefEXCEPTION));
    -                // should throw in parser
    -                fail();
    -            }
    -            catch (std::exception const& e)
    -            {
    -                BEAST_EXPECT(
    -                    std::string(e.what()) ==
    -                    "invalidParamsField 'tx_json.AssetsMaximum' has invalid data.");
    -            }
    -
    -            auto const newKeylet = keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
    -            try
    -            {
    -                auto const insertAt = maxInt64Plus2.size() - 1;
    -                auto const decimalTest = maxInt64Plus2.substr(0, insertAt) + "." +
    -                    maxInt64Plus2.substr(insertAt);  // (max int64+2) / 10
    -                BEAST_EXPECT(decimalTest == "922337203685477580.9");
    -                tx[sfAssetsMaximum] = decimalTest;
    -                env(tx);
    -                // should throw in parser
    -                fail();
    -            }
    -            catch (std::exception const& e)
    -            {
    -                BEAST_EXPECT(
    -                    std::string(e.what()) ==
    -                    "invalidParamsField 'tx_json.AssetsMaximum' has invalid data.");
    -            }
    -
    -            auto const vaultSle = env.le(newKeylet);
    -            BEAST_EXPECT(!vaultSle);
    -        }
    -
    -        {
    -            testcase("Assets Maximum: IOU");
    -
    -            // Almost anything goes with IOUs
    -            PrettyAsset const iouAsset = issuer["IOU"];
    -            env.trust(iouAsset(1000), owner);
    -            env(pay(issuer, owner, iouAsset(200)));
    -            env.close();
    -
    -            auto [tx, keylet] = vault.create({.owner = owner, .asset = iouAsset});
    -            tx[sfData] = "4D65746144617461";
    -
    -            tx[sfAssetsMaximum] = maxInt64;
    -            env(tx);
    -            env.close();
    -
    -            tx[sfAssetsMaximum] = initialXRPPlus1;
    -            env(tx);
    -            env.close();
    -
    -            tx[sfAssetsMaximum] = initialXRP;
    -            env(tx);
    -            env.close();
    -
    -            // Since several tests are expected to have parser failures, leave this flag set for the
    -            // remainder of this function.
    -            env.setParseFailureExpected(true);
    -            try
    -            {
    -                tx[sfAssetsMaximum] = maxInt64Plus2;
    -                env(tx);
    -                // should throw in parser
    -                fail();
    -            }
    -            catch (std::exception const& e)
    -            {
    -                BEAST_EXPECT(
    -                    std::string(e.what()) ==
    -                    "invalidParamsField 'tx_json.AssetsMaximum' has invalid data.");
    -            }
    -
    -            tx[sfAssetsMaximum] = "1000000000000000e80";
    -            env.close();
    -
    -            tx[sfAssetsMaximum] = "1000000000000000e-96";
    -            env.close();
    -
    -            // These values will be rounded to 15 significant digits
    -            {
    -                auto const newKeylet =
    -                    keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
    -                try
    -                {
    -                    auto const insertAt = maxInt64Plus2.size() - 1;
    -                    auto const decimalTest = maxInt64Plus2.substr(0, insertAt) + "." +
    -                        maxInt64Plus2.substr(insertAt);  // (max int64+2) / 10
    -                    BEAST_EXPECT(decimalTest == "922337203685477580.9");
    -                    tx[sfAssetsMaximum] = decimalTest;
    -                    env(tx);
    -                    // should throw in parser
    -                    fail();
    -                }
    -                catch (std::exception const& e)
    -                {
    -                    BEAST_EXPECT(
    -                        std::string(e.what()) ==
    -                        "invalidParamsField 'tx_json.AssetsMaximum' has invalid data.");
    -                }
    -
    -                auto const vaultSle = env.le(newKeylet);
    -                BEAST_EXPECT(!vaultSle);
    -            }
    -            {
    -                tx[sfAssetsMaximum] = "9223372036854775807e40";  // max int64 * 10^40
    -                auto const newKeylet =
    -                    keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
    -                env(tx);
    -                env.close();
    -
    -                auto const vaultSle = env.le(newKeylet);
    -                if (!BEAST_EXPECT(vaultSle))
    -                    return;
    -
    -                BEAST_EXPECT(
    -                    (vaultSle->at(sfAssetsMaximum) ==
    -                     Number{9223372036854776, 43, Number::Normalized{}}));
    -            }
    -            {
    -                tx[sfAssetsMaximum] = "9223372036854775807e-40";  // max int64 * 10^-40
    -                auto const newKeylet =
    -                    keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
    -                env(tx);
    -                env.close();
    -
    -                auto const vaultSle = env.le(newKeylet);
    -                if (!BEAST_EXPECT(vaultSle))
    -                    return;
    -
    -                BEAST_EXPECT(
    -                    (vaultSle->at(sfAssetsMaximum) ==
    -                     Number{9223372036854776, -37, Number::Normalized{}}));
    -            }
    -            {
    -                tx[sfAssetsMaximum] = "9223372036854775807e-100";  // max int64 * 10^-100
    -                auto const newKeylet =
    -                    keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
    -                env(tx);
    -                env.close();
    -
    -                // Field 'AssetsMaximum' may not be explicitly set to default.
    -                auto const vaultSle = env.le(newKeylet);
    -                if (!BEAST_EXPECT(vaultSle))
    -                    return;
    -
    -                BEAST_EXPECT(vaultSle->at(sfAssetsMaximum) == kNumZero);
    -            }
    -
    -            // What _can't_ IOUs do?
    -            // 1. Exceed maximum exponent / offset
    -            tx[sfAssetsMaximum] = "1000000000000000e81";
    -            env(tx, Ter(tefEXCEPTION));
    -            env.close();
    -
    -            // 2. Mantissa larger than uint64 max
    -            try
    -            {
    -                auto const g = env.getParseFailureGuard(true);
    -                tx[sfAssetsMaximum] = "18446744073709551617e5";  // uint64 max + 1
    -                env(tx);
    -                BEAST_EXPECTS(false, "Expected parse_error for mantissa larger than uint64 max");
    -            }
    -            catch (ParseError const& e)
    -            {
    -                using namespace std::string_literals;
    -                BEAST_EXPECT(
    -                    e.what() == "invalidParamsField 'tx_json.AssetsMaximum' has invalid data."s);
    -            }
    -        }
    -    }
    -
    -    void
    -    testVaultEscrowedMPT()
    -    {
    -        using namespace test::jtx;
    -        using namespace std::literals;
    -
    -        // Verify vault deposit/withdraw/clawback respect sfLockedAmount.
    -        // When MPT tokens are escrowed, sfMPTAmount is reduced and
    -        // sfLockedAmount is increased. Vault operations go through
    -        // accountSend/accountHolds which read sfMPTAmount, so escrowed
    -        // tokens are naturally excluded.
    -
    -        {
    -            testcase("Vault deposit fails when MPT asset is escrowed");
    -
    -            Env env{*this, testableAmendments()};
    -            auto const baseFee = env.current()->fees().base;
    -            Account const owner{"owner"};
    -            Account const depositor{"depositor"};
    -            Account const issuer{"issuer"};
    -            Account const bob{"bob"};
    -
    -            env.fund(XRP(10000), issuer, owner, depositor, bob);
    -            env.close();
    -
    -            MPTTester mptt{env, issuer, kMptInitNoFund};
    -            mptt.create(
    -                {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock | tfMPTCanEscrow});
    -            mptt.authorize({.account = owner});
    -            mptt.authorize({.account = depositor});
    -            mptt.authorize({.account = bob});
    -            PrettyAsset const asset = mptt.issuanceID();
    -            env(pay(issuer, depositor, asset(100)));
    -            env.close();
    -
    -            // Escrow 60 of 100 MPT tokens: sfMPTAmount drops to 40
    -            auto const escrowSeq = env.seq(depositor);
    -            env(escrow::create(depositor, bob, asset(60)),
    -                escrow::kCondition(escrow::kCb1),
    -                escrow::kFinishTime(env.now() + 1s),
    -                Fee(baseFee * 150),
    -                Ter(tesSUCCESS));
    -            env.close();
    -
    -            Vault const vault{env};
    -            auto [tx, vaultKeylet] = vault.create({.owner = owner, .asset = asset});
    -            env(tx, Ter(tesSUCCESS));
    -            env.close();
    -
    -            // Deposit 100 should fail — only 40 spendable
    -            env(vault.deposit(
    -                    {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}),
    -                Ter(tecINSUFFICIENT_FUNDS));
    -            env.close();
    -
    -            // Deposit 40 (the unlocked balance) should succeed
    -            env(vault.deposit({.depositor = depositor, .id = vaultKeylet.key, .amount = asset(40)}),
    -                Ter(tesSUCCESS));
    -            env.close();
    -
    -            {
    -                auto const sle = env.le(vaultKeylet);
    -                BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(40).value());
    -            }
    -
    -            // Clean up escrow
    -            env(escrow::finish(bob, depositor, escrowSeq),
    -                escrow::kCondition(escrow::kCb1),
    -                escrow::kFulfillment(escrow::kFb1),
    -                Fee(baseFee * 150),
    -                Ter(tesSUCCESS));
    -            env.close();
    -        }
    -
    -        {
    -            testcase("Vault withdraw respects escrowed shares");
    -
    -            Env env{*this, testableAmendments()};
    -            auto const baseFee = env.current()->fees().base;
    -            Account const owner{"owner"};
    -            Account const depositor{"depositor"};
    -            Account const issuer{"issuer"};
    -            Account const bob{"bob"};
    -
    -            env.fund(XRP(10000), issuer, owner, depositor, bob);
    -            env.close();
    -
    -            MPTTester mptt{env, issuer, kMptInitNoFund};
    -            mptt.create(
    -                {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock | tfMPTCanEscrow});
    -            mptt.authorize({.account = owner});
    -            mptt.authorize({.account = depositor});
    -            PrettyAsset const asset = mptt.issuanceID();
    -            env(pay(issuer, depositor, asset(100)));
    -            env.close();
    -
    -            Vault const vault{env};
    -            auto [tx, vaultKeylet] = vault.create({.owner = owner, .asset = asset});
    -            env(tx, Ter(tesSUCCESS));
    -            env.close();
    -
    -            // Deposit 100 → get shares
    -            env(vault.deposit(
    -                    {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}),
    -                Ter(tesSUCCESS));
    -            env.close();
    -
    -            auto const vaultSle = env.le(vaultKeylet);
    -            if (!BEAST_EXPECT(vaultSle))
    -                return;
    -            env.memoize(Account("vault", vaultSle->at(sfAccount)));
    -            PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
    -
    -            // Authorize bob for share MPT so he can receive escrowed shares
    -            auto const shareMPTID = vaultSle->at(sfShareMPTID);
    -            {
    -                json::Value jv;
    -                jv[jss::Account] = bob.human();
    -                jv[sfMPTokenIssuanceID] = to_string(shareMPTID);
    -                jv[jss::TransactionType] = jss::MPTokenAuthorize;
    -                env(jv, Ter(tesSUCCESS));
    -                env.close();
    -            }
    -
    -            // Escrow 60% of shares
    -            auto const escrowAmount = shares(Number{6, vaultSle->at(sfScale) + 1});
    -            env(escrow::create(depositor, bob, escrowAmount),
    -                escrow::kCondition(escrow::kCb1),
    -                escrow::kFinishTime(env.now() + 1s),
    -                Fee(baseFee * 150),
    -                Ter(tesSUCCESS));
    -            env.close();
    -
    -            // Withdraw all 100 should fail — only 40% of shares are unlocked
    -            env(vault.withdraw(
    -                    {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}),
    -                Ter(tecINSUFFICIENT_FUNDS));
    -            env.close();
    -
    -            // Withdraw 40 (matching unlocked shares) should succeed
    -            env(vault.withdraw(
    -                    {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(40)}),
    -                Ter(tesSUCCESS));
    -            env.close();
    -
    -            {
    -                auto const sle = env.le(vaultKeylet);
    -                BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(60).value());
    -            }
    -        }
    -
    -        {
    -            testcase("Vault clawback only recovers unlocked shares");
    -
    -            Env env{*this, testableAmendments() | fixCleanup3_1_3};
    -            auto const baseFee = env.current()->fees().base;
    -            Account const owner{"owner"};
    -            Account const depositor{"depositor"};
    -            Account const issuer{"issuer"};
    -            Account const bob{"bob"};
    -
    -            env.fund(XRP(10000), issuer, owner, depositor, bob);
    -            env.close();
    -
    -            MPTTester mptt{env, issuer, kMptInitNoFund};
    -            mptt.create(
    -                {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock | tfMPTCanEscrow});
    -            mptt.authorize({.account = owner});
    -            mptt.authorize({.account = depositor});
    -            PrettyAsset const asset = mptt.issuanceID();
    -            env(pay(issuer, depositor, asset(100)));
    -            env.close();
    -
    -            Vault const vault{env};
    -            auto [tx, vaultKeylet] = vault.create({.owner = owner, .asset = asset});
    -            env(tx, Ter(tesSUCCESS));
    -            env.close();
    -
    -            // Deposit 100 → get shares
    -            env(vault.deposit(
    -                    {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}),
    -                Ter(tesSUCCESS));
    -            env.close();
    -
    -            auto const vaultSle = env.le(vaultKeylet);
    -            if (!BEAST_EXPECT(vaultSle))
    -                return;
    -            env.memoize(Account("vault", vaultSle->at(sfAccount)));
    -            PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
    -
    -            // Authorize bob for share MPT so he can receive escrowed shares
    -            auto const shareMPTID = vaultSle->at(sfShareMPTID);
    -            {
    -                json::Value jv;
    -                jv[jss::Account] = bob.human();
    -                jv[sfMPTokenIssuanceID] = to_string(shareMPTID);
    -                jv[jss::TransactionType] = jss::MPTokenAuthorize;
    -                env(jv, Ter(tesSUCCESS));
    -                env.close();
    -            }
    -
    -            // Escrow 60% of shares
    -            auto const escrowAmount = shares(Number{6, vaultSle->at(sfScale) + 1});
    -            env(escrow::create(depositor, bob, escrowAmount),
    -                escrow::kCondition(escrow::kCb1),
    -                escrow::kFinishTime(env.now() + 1s),
    -                Fee(baseFee * 150),
    -                Ter(tesSUCCESS));
    -            env.close();
    -
    -            // Zero-amount clawback ("all") — should only recover assets
    -            // corresponding to unlocked shares (40%)
    -            env(vault.clawback({
    -                    .issuer = issuer,
    -                    .id = vaultKeylet.key,
    -                    .holder = depositor,
    -                }),
    -                Ter(tesSUCCESS));
    -            env.close();
    -
    -            {
    -                auto const sle = env.le(vaultKeylet);
    -                BEAST_EXPECT(sle != nullptr);
    -                // Only 40 of 100 assets recovered (matching 40% unlocked shares)
    -                BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(60).value());
    -                BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(60).value());
    -
    -                // Depositor's unlocked shares are now 0
    -                auto const sharesAfter = env.balance(depositor, shares);
    -                BEAST_EXPECT(sharesAfter == shares(0));
    -            }
    -        }
    -    }
    -
    -    // Reproduction: canWithdraw IOU limit check bypassed when
    -    // withdrawal amount is specified in shares (MPT) rather than in assets.
    -    void
    -    testBug6LimitBypassWithShares()
    -    {
    -        using namespace test::jtx;
    -        testcase("Bug6 - limit bypass with share-denominated withdrawal");
    -
    -        auto const allAmendments = testableAmendments() | featureSingleAssetVault;
    -
    -        for (auto const& features : {allAmendments, allAmendments - fixCleanup3_1_3})
    -        {
    -            bool const withFix = features[fixCleanup3_1_3];
    -
    -            Env env{*this, features};
    -            Account const owner{"owner"};
    -            Account const issuer{"issuer"};
    -            Account const depositor{"depositor"};
    -            Account const charlie{"charlie"};
    -            Vault const vault{env};
    -
    -            env.fund(XRP(1000), issuer, owner, depositor, charlie);
    -            env(fset(issuer, asfAllowTrustLineClawback));
    -            env.close();
    -
    -            PrettyAsset const asset = issuer["IOU"];
    -            env.trust(asset(1000), owner);
    -            env.trust(asset(1000), depositor);
    -            env(pay(issuer, owner, asset(200)));
    -            env(pay(issuer, depositor, asset(200)));
    -            env.close();
    -
    -            // Charlie gets a LOW trustline limit of 5
    -            env.trust(asset(5), charlie);
    -            env.close();
    -
    -            auto const [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -            env(tx);
    -            env.close();
    -
    -            auto const depositTx =
    -                vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)});
    -            env(depositTx);
    -            env.close();
    -
    -            // Get the share MPT info
    -            auto const vaultSle = env.le(keylet);
    -            if (!BEAST_EXPECT(vaultSle))
    -                return;
    -            auto const mptIssuanceID = vaultSle->at(sfShareMPTID);
    -            MPTIssue const shares(mptIssuanceID);
    -            PrettyAsset const share(shares);
    -
    -            // CONTROL: Withdraw 10 IOU (asset-denominated) to charlie.
    -            // Charlie's limit is 5, so this should be rejected with tecNO_LINE
    -            // regardless of the amendment.
    -            {
    -                auto withdrawTx =
    -                    vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(10)});
    -                withdrawTx[sfDestination] = charlie.human();
    -                env(withdrawTx, Ter{tecNO_LINE});
    -                env.close();
    -            }
    -            auto const charlieBalanceBefore = env.balance(charlie, asset.raw().get());
    -
    -            // Withdraw the equivalent amount in shares to charlie.
    -            // Post-fix: rejected (tecNO_LINE) because the share amount is
    -            //   converted to assets and the trustline limit is checked.
    -            // Pre-fix: succeeds (tesSUCCESS) because the limit check was
    -            //   skipped for share-denominated withdrawals.
    -            {
    -                auto withdrawTx = vault.withdraw(
    -                    {.depositor = depositor,
    -                     .id = keylet.key,
    -                     .amount = STAmount(share, 10'000'000)});
    -                withdrawTx[sfDestination] = charlie.human();
    -                env(withdrawTx, Ter{withFix ? TER{tecNO_LINE} : TER{tesSUCCESS}});
    -                env.close();
    -
    -                auto const charlieBalanceAfter = env.balance(charlie, asset.raw().get());
    -                if (withFix)
    -                {
    -                    // Post-fix: charlie's balance is unchanged — the withdrawal
    -                    // was correctly rejected despite being share-denominated.
    -                    BEAST_EXPECT(charlieBalanceAfter == charlieBalanceBefore);
    -                }
    -                else
    -                {
    -                    // Pre-fix: charlie received the assets, bypassing the
    -                    // trustline limit.
    -                    BEAST_EXPECT(charlieBalanceAfter > charlieBalanceBefore);
    -                }
    -            }
    -        }
    -    }
    -
    -    void
    -    testRemoveEmptyHoldingLockedAmount()
    -    {
    -        testcase("removeEmptyHolding deletes MPToken with sfLockedAmount");
    -        using namespace test::jtx;
    -        using namespace std::literals;
    -
    -        auto const amendments = testableAmendments();
    -        auto runTest = [&](FeatureBitset f) {
    -            Env env{*this, f};
    -            auto const baseFee = env.current()->fees().base;
    -
    -            Account const issuer{"issuer"};
    -            Account const owner{"owner"};
    -            Account const depositor{"depositor"};
    -            Account const bob{"bob"};
    -
    -            env.fund(XRP(100000), issuer, owner, depositor, bob);
    -            env.close();
    -
    -            Vault const vault{env};
    -
    -            // Create an MPT asset for the vault
    -            MPTTester mptt{env, issuer, kMptInitNoFund};
    -            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
    -            PrettyAsset const asset = mptt.issuanceID();
    -            mptt.authorize({.account = owner});
    -            mptt.authorize({.account = depositor});
    -            env(pay(issuer, depositor, asset(1000)));
    -            env.close();
    -
    -            // Create vault
    -            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -            env(tx);
    -            env.close();
    -
    -            auto const vaultSle = env.le(keylet);
    -            BEAST_EXPECT(vaultSle != nullptr);
    -            auto const shareMptID = vaultSle->at(sfShareMPTID);
    -            MPTIssue const shareIssue{shareMptID};
    -
    -            // Depositor deposits 1000 asset units into vault, receiving shares
    -            env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1000)}));
    -            env.close();
    -
    -            // Check depositor has shares
    -            {
    -                auto const sleMpt = env.le(keylet::mptoken(shareMptID, depositor));
    -                BEAST_EXPECT(sleMpt != nullptr);
    -                BEAST_EXPECT(sleMpt->at(sfMPTAmount) == 1000);
    -            }
    -
    -            // Escrow 500 of those shares
    -            env(escrow::create(depositor, bob, STAmount{shareIssue, 500}),
    -                escrow::kCondition(escrow::kCb1),
    -                escrow::kFinishTime(env.now() + 1s),
    -                Fee(baseFee * 150),
    -                Ter(tesSUCCESS));
    -            env.close();
    -
    -            // Verify: sfMPTAmount=500, sfLockedAmount=500
    -            {
    -                auto const sleMpt = env.le(keylet::mptoken(shareMptID, depositor));
    -                BEAST_EXPECT(sleMpt != nullptr);
    -                BEAST_EXPECT(sleMpt->at(sfLockedAmount) == 500);
    -                BEAST_EXPECT(sleMpt->at(sfMPTAmount) == 500);
    -            }
    -
    -            // Withdraw remaining spendable shares — triggers removeEmptyHolding
    -            env(vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(500)}),
    -                Ter(tesSUCCESS));
    -            env.close();
    -
    -            auto const sleMptAfter = env.le(keylet::mptoken(shareMptID, depositor));
    -            if (!f[fixCleanup3_1_3])
    -            {
    -                // Without the fix, removeEmptyHolding deletes the MPToken
    -                // even though sfLockedAmount > 0, leaving the escrow's locked
    -                // amount untracked.
    -                BEAST_EXPECT(sleMptAfter == nullptr);
    -            }
    -            else
    -            {
    -                // With the fix, MPToken must still exist with sfLockedAmount > 0
    -                // and sfMPTAmount == 0 (all spendable shares withdrawn).
    -                BEAST_EXPECT(sleMptAfter != nullptr);
    -                if (sleMptAfter)
    -                {
    -                    BEAST_EXPECT(sleMptAfter->at(sfLockedAmount) == 500);
    -                    BEAST_EXPECT(sleMptAfter->at(sfMPTAmount) == 0);
    -                }
    -            }
    -        };
    -
    -        runTest(amendments - fixCleanup3_1_3);
    -        runTest(amendments);
    -    }
    -
    -    void
    -    testRemoveEmptyHoldingConfidentialBalances()
    -    {
    -        testcase("removeEmptyHolding keeps MPToken with confidential balances");
    -        using namespace test::jtx;
    -
    -        Env env{*this, testableAmendments()};
    -
    -        Account const issuer{"issuer"};
    -        Account const holder{"holder"};
    -        MPTTester mpt{env, issuer, {.holders = {holder}}};
    -        mpt.create({.authorize = MPTCreate::allHolders});
    -
    -        auto const tokenKeylet = keylet::mptoken(mpt.issuanceID(), holder.id());
    -        auto const encryptedBalanceFields = {
    -            &sfConfidentialBalanceInbox,
    -            &sfConfidentialBalanceSpending,
    -            &sfIssuerEncryptedBalance,
    -            &sfAuditorEncryptedBalance};
    -
    -        env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal j) {
    -            for (auto const field : encryptedBalanceFields)
    -            {
    -                Sandbox sb(&view, TapNone);
    -                auto const token = sb.peek(tokenKeylet);
    -                if (!BEAST_EXPECT(token))
    -                    return false;
    -
    -                token->setFieldVL(*field, gMakeZeroBuffer(kEcGamalEncryptedTotalLength));
    -                sb.update(token);
    -
    -                auto const dummyTx = *env.jt(noop(holder)).stx;
    -                BEAST_EXPECT(
    -                    removeEmptyHolding({sb, dummyTx}, holder.id(), MPTIssue(mpt.issuanceID()), j) ==
    -                    tecHAS_OBLIGATIONS);
    -                BEAST_EXPECT(sb.peek(tokenKeylet) != nullptr);
    -            }
    -            return true;
    -        });
    -    }
    -
    -    // -----------------------------------------------------------------------
    -    // Helpers and tests: sole-shareholder / stuck-depositor (XLS-0065 +
    -    // fixCleanup3_2_0). The vault-level withdraw behavior is tested here;
    -    // the loan-protocol setup is incidental.
    -    // -----------------------------------------------------------------------
    -
    -    FeatureBitset const all_{test::jtx::testableAmendments()};
    -    std::string const iouCurrency_{"IOU"};
    -
    -    // design doc:
    -    //     AssetsAvailable ≈ 3,333.50
    -    //     AssetsTotal     ≈ 6,666.50  (3,333.50 cash + 3,333 receivable)
    -    //     LossUnrealized  =  3,333
    -    //     OutstandingShares = sharesLender   (5e9 at IOU scale 1e6)
    -    struct StuckDepositorFixture
    -    {
    -        test::jtx::Account issuer{"issuer"};
    -        test::jtx::Account lender{"lender"};
    -        test::jtx::Account bob{"bob"};
    -        test::jtx::Account borrower{"borrower"};
    -        std::optional asset;
    -        std::optional vaultKeylet;
    -        uint256 brokerID;
    -        std::optional loanKeylet;
    -        MPTID shareAsset;
    -        std::uint64_t sharesLender = 0;
    -    };
    -
    -    static constexpr std::int64_t kStuckFunding = 1'000'000;
    -    static constexpr std::int64_t kStuckDepositorIOU = 1'000'000;
    -    static constexpr std::int64_t kStuckBorrowerIOU = 100'000;
    -    static constexpr std::int64_t kStuckDeposit = 5'000;
    -    static constexpr std::int64_t kStuckPrincipal = 3'333;
    -    static constexpr std::uint32_t kStuckPayInterval = 600;
    -    static constexpr std::uint32_t kStuckPayTotal = 2;
    -
    -    [[nodiscard]] StuckDepositorFixture
    -    setupStuckDepositor(test::jtx::Env& env)
    -    {
    -        using namespace test::jtx;
    -
    -        StuckDepositorFixture f;
    -        f.asset = f.issuer[iouCurrency_];
    -
    -        env.fund(XRP(kStuckFunding), f.issuer, f.lender, f.bob, f.borrower);
    -        env.close();
    -
    -        env(trust(f.lender, (*f.asset)(10'000'000)));
    -        env(trust(f.bob, (*f.asset)(10'000'000)));
    -        env(trust(f.borrower, (*f.asset)(10'000'000)));
    -        env.close();
    -
    -        env(pay(f.issuer, f.lender, (*f.asset)(kStuckDepositorIOU)));
    -        env(pay(f.issuer, f.bob, (*f.asset)(kStuckDepositorIOU)));
    -        env(pay(f.issuer, f.borrower, (*f.asset)(kStuckBorrowerIOU)));
    -        env.close();
    -
    -        // Vault: Lender creates and seeds it; Bob matches the deposit for a
    -        // clean 50/50 split.
    -        Vault const v{env};
    -        auto [createTx, vaultKeylet] = v.create({.owner = f.lender, .asset = *f.asset});
    -        env(createTx);
    -        env.close();
    -        if (!BEAST_EXPECT(env.le(vaultKeylet)))
    -            return f;
    -        f.vaultKeylet = vaultKeylet;
    -
    -        env(v.deposit({
    -                .depositor = f.lender,
    -                .id = vaultKeylet.key,
    -                .amount = (*f.asset)(kStuckDeposit),
    -            }),
    -            Ter(tesSUCCESS));
    -        env(v.deposit({
    -                .depositor = f.bob,
    -                .id = vaultKeylet.key,
    -                .amount = (*f.asset)(kStuckDeposit),
    -            }),
    -            Ter(tesSUCCESS));
    -        env.close();
    -
    -        // Loan broker: no cover, no management fee, debt cap 10x principal.
    -        f.brokerID =
    -            keylet::loanBroker(f.lender.id(), SeqProxy::rawSequence(env.seq(f.lender))).key;
    -        {
    -            using namespace loan_broker;
    -            env(set(f.lender, vaultKeylet.key),
    -                kDebtMaximum((*f.asset)(kStuckPrincipal * 10).value()));
    -            env.close();
    -        }
    -
    -        // Loan: 3,333 USD principal, impaired immediately.
    -        auto const sleBroker = env.le(keylet::loanBroker(f.brokerID));
    -        if (!BEAST_EXPECT(sleBroker))
    -            return f;
    -        f.loanKeylet =
    -            keylet::loan(f.brokerID, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence)));
    -
    -        {
    -            using namespace loan;
    -            env(set(f.borrower, f.brokerID, kStuckPrincipal),
    -                Sig(sfCounterpartySignature, f.lender),
    -                kPaymentTotal(kStuckPayTotal),
    -                kPaymentInterval(kStuckPayInterval),
    -                Fee(env.current()->fees().base * 2),
    -                Ter(tesSUCCESS));
    -            env.close();
    -            env(manage(f.lender, f.loanKeylet->key, tfLoanImpair), Ter(tesSUCCESS));
    -            env.close();
    -        }
    -
    -        auto const vaultSle = env.le(vaultKeylet);
    -        if (!BEAST_EXPECT(vaultSle))
    -            return f;
    -        BEAST_EXPECT(vaultSle->at(sfLossUnrealized) == (*f.asset)(kStuckPrincipal).value());
    -
    -        f.shareAsset = vaultSle->at(sfShareMPTID);
    -
    -        auto const tokenBob = env.le(keylet::mptoken(f.shareAsset, f.bob.id()));
    -        if (!BEAST_EXPECT(tokenBob))
    -            return f;
    -        std::uint64_t const sharesBob = tokenBob->getFieldU64(sfMPTAmount);
    -
    -        // Bob (non-sole) exits at the discounted rate. Always succeeds.
    -        STAmount const bobShareAmt{MPTIssue{f.shareAsset}, Number(sharesBob)};
    -        env(v.withdraw({
    -                .depositor = f.bob,
    -                .id = vaultKeylet.key,
    -                .amount = bobShareAmt,
    -            }),
    -            Ter(tesSUCCESS));
    -        env.close();
    -
    -        auto const tokenLender = env.le(keylet::mptoken(f.shareAsset, f.lender.id()));
    -        if (!BEAST_EXPECT(tokenLender))
    -            return f;
    -        f.sharesLender = tokenLender->getFieldU64(sfMPTAmount);
    -
    -        auto const sleIssuance = env.le(keylet::mptokenIssuance(f.shareAsset));
    -        if (!BEAST_EXPECT(sleIssuance))
    -            return f;
    -        BEAST_EXPECT(sleIssuance->getFieldU64(sfOutstandingAmount) == f.sharesLender);
    -
    -        auto const vaultAfterBob = env.le(vaultKeylet);
    -        if (!BEAST_EXPECT(vaultAfterBob))
    -            return f;
    -        // After Bob's exit: loss is unchanged (3,333 receivable), and the
    -        // gap between assetsTotal and assetsAvailable equals exactly that
    -        // receivable.
    -        BEAST_EXPECT(vaultAfterBob->at(sfLossUnrealized) == (*f.asset)(kStuckPrincipal).value());
    -        BEAST_EXPECT(
    -            vaultAfterBob->at(sfAssetsTotal) - vaultAfterBob->at(sfAssetsAvailable) ==
    -            vaultAfterBob->at(sfLossUnrealized));
    -
    -        return f;
    -    }
    -
    -    // Reproduces the worked example from the XLS-0065 design doc. The sole
    -    // remaining shareholder asks (via fixed-asset input) for the vault's
    -    // entire AssetsAvailable. Pre-fix this fails with the zero-sized-vault
    -    // invariant violation. Post-fix the full-price exchange rate burns
    -    // only a portion of the shares, the depositor receives all of
    -    // AssetsAvailable, and the residual shares remain backed by the
    -    // impaired-loan receivable.
    -    void
    -    testWithdrawSoleShareholderFixedAssetExit(FeatureBitset features)
    -    {
    -        using namespace test::jtx;
    -
    -        bool const withFix = features[fixCleanup3_2_0];
    -        testcase(
    -            std::string{"Vault withdraw: sole shareholder exits via "
    -                        "fixed-asset amount with impaired loan"} +
    -            (withFix ? " (fixCleanup3_2_0)" : " (pre-fix)"));
    -
    -        std::string logs;
    -        Env env(*this, features, std::make_unique(&logs));
    -        auto const f = setupStuckDepositor(env);
    -        if (!f.vaultKeylet || !f.asset || f.sharesLender == 0)
    -        {
    -            BEAST_EXPECT(false);
    -            return;
    -        }
    -        Keylet const& vaultKey = *f.vaultKeylet;
    -        PrettyAsset const& asset = *f.asset;
    -
    -        auto const vaultBefore = env.le(vaultKey);
    -        if (!BEAST_EXPECT(vaultBefore))
    -            return;
    -        Number const availableBefore = vaultBefore->at(sfAssetsAvailable);
    -        Number const totalBefore = vaultBefore->at(sfAssetsTotal);
    -        Number const lossBefore = vaultBefore->at(sfLossUnrealized);
    -
    -        STAmount const lenderBalanceBefore = env.balance(f.lender, asset);
    -
    -        // The requested amount differs between feature regimes because
    -        // the two regimes are testing different behaviors:
    -        //
    -        // - Pre-fix: request the full AssetsAvailable (3,333.50). Under
    -        //   the discounted formula this would burn every outstanding
    -        //   share, hitting the zero-sized-vault invariant. The
    -        //   transaction is rejected with tecINVARIANT_FAILED — the
    -        //   stuck-depositor bug.
    -        //
    -        // - Post-fix: request a strictly smaller amount (1,000 USD).
    -        //   The full-price formula burns only ~30% of the outstanding
    -        //   shares; the vault retains the rest, backed by the impaired
    -        //   receivable. Requesting *exactly* AssetsAvailable post-fix
    -        //   would currently fail with tecINSUFFICIENT_FUNDS due to the
    -        //   round-to-nearest used by assetsToSharesWithdraw (the
    -        //   recomputed payout can overshoot the request by a few ULPs).
    -        //   The "force payout to AssetsAvailable" branch in doApply
    -        //   only triggers when every share is burned, which is covered
    -        //   by the loan-repayment test.
    -        STAmount const requestAssets =
    -            withFix ? asset(1000).value() : STAmount{asset.raw(), availableBefore};
    -        Vault const v{env};
    -        env(v.withdraw({
    -                .depositor = f.lender,
    -                .id = vaultKey.key,
    -                .amount = requestAssets,
    -            }),
    -            Ter(withFix ? TER{tesSUCCESS} : TER{tecINVARIANT_FAILED}));
    -        env.close();
    -
    -        auto const vaultAfter = env.le(vaultKey);
    -        if (!BEAST_EXPECT(vaultAfter))
    -            return;
    -        auto const issuanceAfter = env.le(keylet::mptokenIssuance(f.shareAsset));
    -        if (!BEAST_EXPECT(issuanceAfter))
    -            return;
    -
    -        std::uint64_t const sharesAfter = issuanceAfter->getFieldU64(sfOutstandingAmount);
    -        Number const availableAfter = vaultAfter->at(sfAssetsAvailable);
    -        Number const totalAfter = vaultAfter->at(sfAssetsTotal);
    -        Number const lossAfter = vaultAfter->at(sfLossUnrealized);
    -
    -        if (!withFix)
    -        {
    -            // Pre-fix: rejected — vault state unchanged.
    -            BEAST_EXPECT(sharesAfter == f.sharesLender);
    -            BEAST_EXPECT(availableAfter == availableBefore);
    -            BEAST_EXPECT(totalAfter == totalBefore);
    -            BEAST_EXPECT(lossAfter == lossBefore);
    -            return;
    -        }
    -
    -        // Post-fix exact-value derivation (fixture: sharesLender=5e9,
    -        // totalBefore=6666.5, request=1000):
    -        //   sharesRedeemed = round(sharesLender * request / totalBefore)
    -        //                  = round(750,018,750.469) = 750,018,750
    -        //   received       = totalBefore * sharesRedeemed / sharesLender
    -        //                  = 999.999999375  (slightly under 1,000 due to
    -        //                                    integer-share rounding)
    -        constexpr std::uint64_t kExpectedSharesRedeemed = 750'018'750;
    -        Number const expectedReceived =
    -            totalBefore * Number(kExpectedSharesRedeemed) / Number(f.sharesLender);
    -
    -        BEAST_EXPECT(sharesAfter == f.sharesLender - kExpectedSharesRedeemed);
    -
    -        // LossUnrealized is unchanged: the loan-protocol side is untouched.
    -        BEAST_EXPECT(lossAfter == lossBefore);
    -
    -        // The entire (total - available) gap is the impaired receivable,
    -        // i.e. equal to lossUnrealized.
    -        BEAST_EXPECT(totalAfter - availableAfter == lossAfter);
    -
    -        STAmount const lenderBalanceAfter = env.balance(f.lender, asset);
    -        Number const received{lenderBalanceAfter - lenderBalanceBefore};
    -        BEAST_EXPECT(received == expectedReceived);
    -
    -        // Conservation: assets removed from the vault equal what the
    -        // depositor received.
    -        BEAST_EXPECT(totalBefore - totalAfter == received);
    -        BEAST_EXPECT(availableBefore - availableAfter == received);
    -    }
    -
    -    // Sole shareholder attempts to burn ALL outstanding shares via
    -    // fixed-shares input while the vault still holds an impaired
    -    // receivable. Pre-fix this fails with the zero-sized-vault invariant
    -    // violation. Post-fix the full-price rate causes assetsWithdrawn to
    -    // equal assetsTotal, which exceeds assetsAvailable, so the transaction
    -    // is rejected with tecINSUFFICIENT_FUNDS.
    -    void
    -    testWithdrawSoleShareholderFullSharesRejected(FeatureBitset features)
    -    {
    -        using namespace test::jtx;
    -
    -        bool const withFix = features[fixCleanup3_2_0];
    -        testcase(
    -            std::string{"Vault withdraw: sole shareholder full-shares "
    -                        "burn is rejected while loss outstanding"} +
    -            (withFix ? " (fixCleanup3_2_0)" : " (pre-fix)"));
    -
    -        std::string logs;
    -        Env env(*this, features, std::make_unique(&logs));
    -        auto const f = setupStuckDepositor(env);
    -        if (!f.vaultKeylet || f.sharesLender == 0)
    -        {
    -            BEAST_EXPECT(false);
    -            return;
    -        }
    -        Keylet const& vaultKey = *f.vaultKeylet;
    -
    -        auto const vaultBefore = env.le(vaultKey);
    -        if (!BEAST_EXPECT(vaultBefore))
    -            return;
    -        Number const availableBefore = vaultBefore->at(sfAssetsAvailable);
    -        Number const totalBefore = vaultBefore->at(sfAssetsTotal);
    -        Number const lossBefore = vaultBefore->at(sfLossUnrealized);
    -
    -        // Fixed-shares input: ask for ALL outstanding shares.
    -        STAmount const shareAmt{MPTIssue{f.shareAsset}, Number(f.sharesLender)};
    -        Vault const v{env};
    -        env(v.withdraw({
    -                .depositor = f.lender,
    -                .id = vaultKey.key,
    -                .amount = shareAmt,
    -            }),
    -            Ter(withFix ? TER{tecINSUFFICIENT_FUNDS} : TER{tecINVARIANT_FAILED}));
    -        env.close();
    -
    -        // Either way the transaction was rejected; vault state unchanged.
    -        auto const vaultAfter = env.le(vaultKey);
    -        if (!BEAST_EXPECT(vaultAfter))
    -            return;
    -        auto const issuanceAfter = env.le(keylet::mptokenIssuance(f.shareAsset));
    -        if (!BEAST_EXPECT(issuanceAfter))
    -            return;
    -        BEAST_EXPECT(issuanceAfter->getFieldU64(sfOutstandingAmount) == f.sharesLender);
    -        BEAST_EXPECT(vaultAfter->at(sfAssetsAvailable) == availableBefore);
    -        BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == totalBefore);
    -        BEAST_EXPECT(vaultAfter->at(sfLossUnrealized) == lossBefore);
    -    }
    -
    -    // Post-fix end-to-end resolution: after the sole-shareholder partial
    -    // exit, the loan is repaid in full. With unrealized loss cleared and
    -    // all assets back as cash, the depositor can burn all remaining
    -    // shares and fully exit the vault. The final withdrawal hits the
    -    // "force payout to assetsAvailable" branch in doApply.
    -    void
    -    testWithdrawSoleShareholderLoanRepaymentExit()
    -    {
    -        using namespace test::jtx;
    -        using namespace loan;
    -
    -        testcase(
    -            "Vault withdraw: sole shareholder fully exits after impaired "
    -            "loan is repaid (fixCleanup3_2_0)");
    -
    -        Env env(*this, all_ | fixCleanup3_2_0);
    -        auto const f = setupStuckDepositor(env);
    -        if (!f.vaultKeylet || !f.asset || !f.loanKeylet || f.sharesLender == 0)
    -        {
    -            BEAST_EXPECT(false);
    -            return;
    -        }
    -        Keylet const& vaultKey = *f.vaultKeylet;
    -        Keylet const& loanKey = *f.loanKeylet;
    -        PrettyAsset const& asset = *f.asset;
    -
    -        Vault const v{env};
    -
    -        // Sole-shareholder partial exit (see comment in
    -        // testWithdrawSoleShareholderFixedAssetExit for why we request
    -        // less than full AssetsAvailable).
    -        {
    -            STAmount const requestAssets = asset(1000).value();
    -            env(v.withdraw({
    -                    .depositor = f.lender,
    -                    .id = vaultKey.key,
    -                    .amount = requestAssets,
    -                }),
    -                Ter(tesSUCCESS));
    -            env.close();
    -        }
    -
    -        // Confirm the "dormant-but-alive" state from the design doc. The
    -        // partial exit burned exactly 750,018,750 shares (see derivation
    -        // in testWithdrawSoleShareholderFixedAssetExit).
    -        auto const tokenAfterExit = env.le(keylet::mptoken(f.shareAsset, f.lender.id()));
    -        if (!BEAST_EXPECT(tokenAfterExit))
    -            return;
    -        std::uint64_t const retainedShares = tokenAfterExit->getFieldU64(sfMPTAmount);
    -        BEAST_EXPECT(retainedShares == f.sharesLender - 750'018'750);
    -
    -        // Borrower repays the loan in full (pays more than the outstanding
    -        // total; the loan transactor caps the receivable).
    -        env(pay(f.borrower, loanKey.key, asset(kStuckPrincipal * 2)), Ter(tesSUCCESS));
    -        env.close();
    -
    -        auto const vaultAfterRepay = env.le(vaultKey);
    -        if (!BEAST_EXPECT(vaultAfterRepay))
    -            return;
    -        // Repayment converts the 3,333 receivable back to cash; assetsTotal
    -        // is unchanged but assetsAvailable jumps by exactly the same amount,
    -        // and lossUnrealized clears to zero.
    -        BEAST_EXPECT(vaultAfterRepay->at(sfLossUnrealized) == beast::kZero);
    -        BEAST_EXPECT(vaultAfterRepay->at(sfAssetsAvailable) == vaultAfterRepay->at(sfAssetsTotal));
    -
    -        STAmount const lenderBalanceBeforeFinal = env.balance(f.lender, asset);
    -        Number const availableBeforeFinal = vaultAfterRepay->at(sfAssetsAvailable);
    -
    -        // Burn all remaining shares — the clean-state preconditions of
    -        // the "final withdrawal" guard are now satisfied.
    -        STAmount const allShares{MPTIssue{f.shareAsset}, Number(retainedShares)};
    -        env(v.withdraw({
    -                .depositor = f.lender,
    -                .id = vaultKey.key,
    -                .amount = allShares,
    -            }),
    -            Ter(tesSUCCESS));
    -        env.close();
    -
    -        auto const vaultFinal = env.le(vaultKey);
    -        if (!BEAST_EXPECT(vaultFinal))
    -            return;
    -        auto const issuanceFinal = env.le(keylet::mptokenIssuance(f.shareAsset));
    -        if (!BEAST_EXPECT(issuanceFinal))
    -            return;
    -
    -        // Zero-sized vault invariant satisfied: 0 shares, 0 assets.
    -        BEAST_EXPECT(issuanceFinal->getFieldU64(sfOutstandingAmount) == 0);
    -        BEAST_EXPECT(vaultFinal->at(sfAssetsTotal) == beast::kZero);
    -        BEAST_EXPECT(vaultFinal->at(sfAssetsAvailable) == beast::kZero);
    -        BEAST_EXPECT(vaultFinal->at(sfLossUnrealized) == beast::kZero);
    -
    -        // The final payout equals exactly the AssetsAvailable that
    -        // existed before the call (the "force payout" branch).
    -        STAmount const lenderBalanceAfter = env.balance(f.lender, asset);
    -        Number const finalReceived{lenderBalanceAfter - lenderBalanceBeforeFinal};
    -        BEAST_EXPECT(finalReceived == availableBeforeFinal);
    -    }
    -
    -    // Clean-state regression: with no impaired loan, a sole shareholder
    -    // burning all their shares fully empties the vault under both the
    -    // pre-fix and post-fix code paths. Confirms the new logic doesn't
    -    // break the existing happy-path close-out.
    -    void
    -    testWithdrawSoleShareholderCleanVaultUnaffected(FeatureBitset features)
    -    {
    -        using namespace test::jtx;
    -
    -        bool const withFix = features[fixCleanup3_2_0];
    -        testcase(
    -            std::string{"Vault withdraw: sole shareholder clean-state "
    -                        "close-out unchanged"} +
    -            (withFix ? " (fixCleanup3_2_0)" : " (pre-fix)"));
    -
    -        Env env(*this, features);
    -
    -        Account const issuer{"issuer"};
    -        Account const lender{"lender"};
    -
    -        env.fund(XRP(kStuckFunding), issuer, lender);
    -        env.close();
    -
    -        PrettyAsset const asset = issuer[iouCurrency_];
    -        env(trust(lender, asset(10'000'000)));
    -        env.close();
    -        env(pay(issuer, lender, asset(kStuckDepositorIOU)));
    -        env.close();
    -
    -        // Sole shareholder of a clean vault — no loan broker needed.
    -        Vault const v{env};
    -        auto [createTx, vaultKeylet] = v.create({.owner = lender, .asset = asset});
    -        env(createTx);
    -        env.close();
    -
    -        env(v.deposit({
    -                .depositor = lender,
    -                .id = vaultKeylet.key,
    -                .amount = asset(kStuckDeposit),
    -            }),
    -            Ter(tesSUCCESS));
    -        env.close();
    -
    -        auto const vaultBefore = env.le(vaultKeylet);
    -        if (!BEAST_EXPECT(vaultBefore))
    -            return;
    -        auto const shareAsset = vaultBefore->at(sfShareMPTID);
    -        auto const tokenLender = env.le(keylet::mptoken(shareAsset, lender.id()));
    -        if (!BEAST_EXPECT(tokenLender))
    -            return;
    -        std::uint64_t const sharesLender = tokenLender->getFieldU64(sfMPTAmount);
    -
    -        // Sole shareholder, no loans, no loss. Burn everything.
    -        STAmount const allShares{MPTIssue{shareAsset}, Number(sharesLender)};
    -        env(v.withdraw({
    -                .depositor = lender,
    -                .id = vaultKeylet.key,
    -                .amount = allShares,
    -            }),
    -            Ter(tesSUCCESS));
    -        env.close();
    -
    -        auto const vaultFinal = env.le(vaultKeylet);
    -        if (!BEAST_EXPECT(vaultFinal))
    -            return;
    -        auto const issuanceFinal = env.le(keylet::mptokenIssuance(shareAsset));
    -        if (!BEAST_EXPECT(issuanceFinal))
    -            return;
    -        BEAST_EXPECT(issuanceFinal->getFieldU64(sfOutstandingAmount) == 0);
    -        BEAST_EXPECT(vaultFinal->at(sfAssetsTotal) == beast::kZero);
    -        BEAST_EXPECT(vaultFinal->at(sfAssetsAvailable) == beast::kZero);
    -        BEAST_EXPECT(vaultFinal->at(sfLossUnrealized) == beast::kZero);
    -
    -        // (Pre-fix path takes the regular code path; post-fix path enters
    -        // the new final-withdrawal guard, which forces payout to exactly
    -        // assetsAvailable. Either way the result is identical for a clean
    -        // vault.)
    -        (void)withFix;
    -    }
    -
    -    // Sole shareholder in an impaired vault redeems a *partial* count of
    -    // shares via fixed-shares input. Pre-fix the discounted formula is
    -    // used; post-fix the full-price formula is used (waiveUnrealizedLoss
    -    // = Yes). The relative payout therefore differs, and post-fix the
    -    // depositor recovers proportionally more of the residual cash for
    -    // the shares burned. In both cases the vault is left in a valid
    -    // (non-empty) state.
    -    void
    -    testWithdrawSoleShareholderPartialFixedSharesUsesFullPrice()
    -    {
    -        using namespace test::jtx;
    -
    -        testcase(
    -            "Vault withdraw: sole-shareholder partial fixed-shares uses "
    -            "full-price rate (fixCleanup3_2_0)");
    -
    -        Env env(*this, all_ | fixCleanup3_2_0);
    -        auto const f = setupStuckDepositor(env);
    -        if (!f.vaultKeylet || !f.asset || f.sharesLender == 0)
    -        {
    -            BEAST_EXPECT(false);
    -            return;
    -        }
    -        Keylet const& vaultKey = *f.vaultKeylet;
    -        PrettyAsset const& asset = *f.asset;
    -
    -        auto const vaultBefore = env.le(vaultKey);
    -        if (!BEAST_EXPECT(vaultBefore))
    -            return;
    -        Number const totalBefore = vaultBefore->at(sfAssetsTotal);
    -        Number const availableBefore = vaultBefore->at(sfAssetsAvailable);
    -        Number const lossBefore = vaultBefore->at(sfLossUnrealized);
    -
    -        // Burn exactly half of the outstanding shares.
    -        std::uint64_t const halfShares = f.sharesLender / 2;
    -        STAmount const halfAmt{MPTIssue{f.shareAsset}, Number(halfShares)};
    -
    -        STAmount const lenderBalanceBefore = env.balance(f.lender, asset);
    -
    -        Vault const v{env};
    -        env(v.withdraw({
    -                .depositor = f.lender,
    -                .id = vaultKey.key,
    -                .amount = halfAmt,
    -            }),
    -            Ter(tesSUCCESS));
    -        env.close();
    -
    -        // Expected payout under the full-price formula:
    -        //   assets = totalBefore * halfShares / sharesLender
    -        // which (with halfShares == sharesLender/2) is roughly
    -        //   totalBefore / 2.
    -        STAmount const lenderBalanceAfter = env.balance(f.lender, asset);
    -        Number const received{lenderBalanceAfter - lenderBalanceBefore};
    -        Number const expected = totalBefore * Number(halfShares) / Number(f.sharesLender);
    -        BEAST_EXPECT(received == expected);
    -
    -        // The full-price payout exceeds the discounted formula by exactly
    -        // lossBefore * halfShares / sharesLender — that's the whole point
    -        // of the waive.
    -        Number const discounted =
    -            (totalBefore - lossBefore) * Number(halfShares) / Number(f.sharesLender);
    -        Number const expectedDelta = lossBefore * Number(halfShares) / Number(f.sharesLender);
    -        BEAST_EXPECT(received - discounted == expectedDelta);
    -
    -        auto const vaultAfter = env.le(vaultKey);
    -        if (!BEAST_EXPECT(vaultAfter))
    -            return;
    -        auto const issuanceAfter = env.le(keylet::mptokenIssuance(f.shareAsset));
    -        if (!BEAST_EXPECT(issuanceAfter))
    -            return;
    -
    -        // Vault remains valid: half the shares remain, lossUnrealized
    -        // is untouched, and the entire (total - available) gap is still
    -        // the impaired receivable.
    -        BEAST_EXPECT(
    -            issuanceAfter->getFieldU64(sfOutstandingAmount) == f.sharesLender - halfShares);
    -        BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == totalBefore - received);
    -        BEAST_EXPECT(vaultAfter->at(sfLossUnrealized) == lossBefore);
    -        BEAST_EXPECT(
    -            vaultAfter->at(sfAssetsTotal) - vaultAfter->at(sfAssetsAvailable) ==
    -            vaultAfter->at(sfLossUnrealized));
    -
    -        // Conservation: vault delta matches the depositor's gain.
    -        BEAST_EXPECT(totalBefore - vaultAfter->at(sfAssetsTotal) == received);
    -        BEAST_EXPECT(availableBefore - vaultAfter->at(sfAssetsAvailable) == received);
    -    }
    -
    -    // Bug: DeltaInfo::makeDelta uses max(scale(after), scale(before)) for the
    -    // sfAssetsTotal and sfAssetsAvailable deltas, and visitEntry applies the
    -    // same max() for the vault pseudo-account RippleState.  When
    -    // sfAssetsTotal sits exactly at 1e16 (IOU exponent 1, ULP = 10) and a
    -    // withdrawal of 5 USD brings it to 9.999...995e15 (IOU exponent 0,
    -    // ULP = 1), all three computations pick the anterior coarser scale 1.
    -    // roundToAsset(-5, scale=1) collapses to 0, so the invariant check
    -    // vaultPseudoDeltaAssets >= kZero fires even though the state change is
    -    // valid and fully consistent at IOU precision.
    -    //
    -    // Fix (fixCleanup3_2_0): finalize compares the vault pseudo-account and
    -    // sfAssetsTotal/Available deltas directly in Number space, bypassing
    -    // scale-coarsened rounding.
    -    void
    -    testBugMakeDeltaAnteriorScale()
    -    {
    -        using namespace test::jtx;
    -
    -        auto runScenario = [this](FeatureBitset features, TER expected) {
    -            std::string logs;
    -            Env env(*this, features, std::make_unique(&logs));
    -
    -            Account const issuer{"issuer"};
    -            Account const alice{"alice"};
    -
    -            env.fund(XRP(100'000), issuer, alice);
    -            env.close();
    -            env(fset(issuer, asfDefaultRipple));
    -            env.close();
    -
    -            PrettyAsset const usd{issuer["USD"]};
    -            // Trust limit of 2e16, fund exactly 1e16 so deposit lands at the
    -            // IOU scale-1 boundary (exponent 1, ULP = 10).
    -            STAmount const fundAndDeposit{usd.raw(), Number{1, 16}};
    -
    -            env(trust(alice, STAmount{usd.raw(), 2, 16}));
    -            env.close();
    -            env(pay(issuer, alice, fundAndDeposit));
    -            env.close();
    -
    -            Vault const vault{env};
    -            auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = usd});
    -            vaultTx[sfScale] = 0;
    -            env(vaultTx);
    -            env.close();
    -
    -            // sfAssetsTotal = sfAssetsAvailable = 1e16 (exponent 1, ULP = 10).
    -            env(vault.deposit(
    -                {.depositor = alice, .id = vaultKeylet.key, .amount = fundAndDeposit}));
    -            env.close();
    -
    -            // Withdraw 5 USD: -5 is sub-ULP at the anterior scale (ULP = 10)
    -            // but exact at the posterior scale (ULP = 1).  The state change is
    -            // consistent; only the invariant's scale selection is wrong.
    -            env(vault.withdraw({.depositor = alice, .id = vaultKeylet.key, .amount = usd(5)}),
    -                Ter(expected));
    -            env.close();
    -        };
    -
    -        {
    -            testcase(
    -                "bug: VaultWithdraw across IOU scale boundary fires invariant "
    -                "(pre-fixCleanup3_2_0)");
    -            runScenario(testableAmendments() - fixCleanup3_2_0, tecINVARIANT_FAILED);
    -        }
    -        {
    -            testcase(
    -                "bug: VaultWithdraw across IOU scale boundary succeeds "
    -                "(post-fixCleanup3_2_0)");
    -            runScenario(testableAmendments(), tesSUCCESS);
    -        }
    -    }
    -
    -    // Bug: DeltaInfo::makeDelta uses max(scale(after), scale(before)) for
    -    // sfAssetsTotal/Available deltas.  This is symmetric to
    -    // testBugMakeDeltaAnteriorScale but in the opposite direction: a deposit
    -    // pushes assetsTotal from just below 1e16 (IOU exponent 0, ULP = 1) to just
    -    // above it (exponent 1, ULP = 10).  makeDelta picks the coarser *posterior*
    -    // scale 1.  The trust line balance rounds from atEdge + 2 = 10,000,000,000,000,001
    -    // → 1e16, so the pseudo-account delta is only +1 in IOU space.
    -    // roundToAsset(+1, scale=1) = 0 fires "deposit must increase vault balance"
    -    // even though the state change is consistent at every precision boundary.
    -    //
    -    // Fix (fixCleanup3_2_0): computeVaultMinScale uses the posterior Number-space
    -    // scale of sfAssetsTotal (which retains the full value 10,000,000,000,000,001,
    -    // exponent 0), giving minScale = 0.  roundToAsset(+1, scale=0) = 1 > 0 and
    -    // the invariant passes.  However the transactor's own precision guard fires
    -    // first (bob pays 2 USD, vault receives only 1 due to IOU rounding), so the
    -    // post-amendment result is tecPRECISION_LOSS rather than tesSUCCESS —
    -    // the depositor is protected from silently losing 1 USD to rounding.
    -    void
    -    testBugMakeDeltaPosteriorScale()
    -    {
    -        using namespace test::jtx;
    -
    -        auto runScenario = [this](FeatureBitset features, TER expected) {
    -            std::string logs;
    -            Env env(*this, features, std::make_unique(&logs));
    -
    -            Account const issuer{"issuer"};
    -            Account const alice{"alice"};
    -            Account const bob{"bob"};
    -
    -            env.fund(XRP(100'000), issuer, alice, bob);
    -            env.close();
    -            env(fset(issuer, asfDefaultRipple));
    -            env.close();
    -
    -            PrettyAsset const usd{issuer["USD"]};
    -            // atEdge is the largest IOU value with exponent 0 (ULP = 1).
    -            // A deposit of 2 USD brings assetsTotal to 10,000,000,000,000,001
    -            // in Number space, crossing the 1e16 boundary in IOU space.
    -            STAmount const atEdge{usd.raw(), Number{9'999'999'999'999'999LL}};
    -
    -            env(trust(alice, STAmount{usd.raw(), 2, 16}));
    -            env(trust(bob, usd(100)));
    -            env.close();
    -            env(pay(issuer, alice, atEdge));
    -            env(pay(issuer, bob, usd(2)));
    -            env.close();
    -
    -            Vault const vault{env};
    -            auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = usd});
    -            vaultTx[sfScale] = 0;
    -            env(vaultTx);
    -            env.close();
    -
    -            // sfAssetsTotal = sfAssetsAvailable = atEdge (exponent 0, ULP = 1)
    -            env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = atEdge}));
    -            env.close();
    -
    -            // Deposit 2 USD: +2 is sub-ULP at the posterior IOU scale (ULP = 10)
    -            // but exact at the Number scale retained by sfAssetsTotal.
    -            env(vault.deposit({.depositor = bob, .id = vaultKeylet.key, .amount = usd(2)}),
    -                Ter(expected));
    -            env.close();
    -        };
    -
    -        {
    -            testcase(
    -                "bug: VaultDeposit across IOU scale boundary fires invariant "
    -                "(pre-fixCleanup3_2_0)");
    -            runScenario(testableAmendments() - fixCleanup3_2_0, tecINVARIANT_FAILED);
    -        }
    -        {
    -            testcase(
    -                "bug: VaultDeposit across IOU scale boundary succeeds "
    -                "(post-fixCleanup3_2_0)");
    -            runScenario(testableAmendments(), tecPRECISION_LOSS);
    -        }
    -    }
    -
    -    // Bug: ValidVault::visitEntry computes destinationDelta.scale as
    -    // max(before_exponent, after_exponent) for RippleState entries.  When a
    -    // withdrawal credits a destination whose IOU balance sits just below a
    -    // power-of-10 boundary (atEdge = 9'999'999'999'999'999), the post-credit
    -    // STAmount rounds up one exponent (exponent 0 → 1), making
    -    // destinationDelta.scale = 1.  The invariant then calls
    -    // roundToAsset(+2 USD, scale=1) = 0 and incorrectly fires
    -    // "withdrawal must increase destination balance".
    -    //
    -    // Fix (fixCleanup3_2_0): finalize compares destination delta directly in
    -    // Number space, bypassing scale-coarsened rounding.  The transaction
    -    // itself succeeds because the effective IOU credit is non-trivial at
    -    // Number precision even though the STAmount exponent shifted.
    -    void
    -    testVaultWithdrawCanonicalizeToZero()
    -    {
    -        using namespace test::jtx;
    -
    -        enum class DestKind : bool { ThirdParty = false, Self = true };
    -
    -        auto runScenario = [this](FeatureBitset features, DestKind destKind, TER expected) {
    -            std::string logs;
    -            Env env(*this, features, std::make_unique(&logs));
    -
    -            Account const issuer{"issuer"};
    -            Account const alice{"alice"};
    -            Account const bob{"bob"};
    -
    -            env.fund(XRP(100'000), issuer, alice, bob);
    -            env.close();
    -            env(fset(issuer, asfDefaultRipple));
    -            env.close();
    -
    -            PrettyAsset const usd{issuer["USD"]};
    -            STAmount const aliceLimit{usd.raw(), 2, 16};
    -            STAmount const bobLimit{usd.raw(), 2, 16};
    -            STAmount const atEdge{usd.raw(), Number{9'999'999'999'999'999LL}};
    -
    -            env(trust(alice, aliceLimit));
    -            if (destKind == DestKind::ThirdParty)
    -                env(trust(bob, bobLimit));
    -            env.close();
    -
    -            env(pay(issuer, alice, usd(1'000)));
    -            if (destKind == DestKind::ThirdParty)
    -                env(pay(issuer, bob, atEdge));
    -            env.close();
    -
    -            Vault const vault{env};
    -            auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = usd});
    -            vaultTx[sfScale] = 0;
    -            env(vaultTx);
    -            env.close();
    -
    -            env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = usd(1'000)}));
    -            env.close();
    -
    -            // For the self-destination case, push alice's own trust line to
    -            // the IOU edge so the next withdraw inflow crosses the boundary.
    -            if (destKind == DestKind::Self)
    -            {
    -                env(pay(issuer, alice, atEdge));
    -                env.close();
    -            }
    -
    -            auto tx = vault.withdraw({.depositor = alice, .id = vaultKeylet.key, .amount = usd(2)});
    -            if (destKind == DestKind::ThirdParty)
    -                tx[sfDestination] = bob.human();
    -            env(tx, Ter(expected));
    -            env.close();
    -        };
    -
    -        {
    -            testcase(
    -                "bug: VaultWithdraw to third-party at IOU edge fires invariant "
    -                "(pre-fixCleanup3_2_0)");
    -            runScenario(
    -                testableAmendments() - fixCleanup3_2_0, DestKind::ThirdParty, tecINVARIANT_FAILED);
    -        }
    -        {
    -            testcase(
    -                "bug: VaultWithdraw to third-party at IOU edge succeeds "
    -                "(post-fixCleanup3_2_0)");
    -            runScenario(testableAmendments(), DestKind::ThirdParty, tesSUCCESS);
    -        }
    -        {
    -            testcase(
    -                "bug: VaultWithdraw to self at IOU edge fires invariant "
    -                "(pre-fixCleanup3_2_0)");
    -            runScenario(
    -                testableAmendments() - fixCleanup3_2_0, DestKind::Self, tecINVARIANT_FAILED);
    -        }
    -        {
    -            testcase(
    -                "bug: VaultWithdraw to self at IOU edge succeeds "
    -                "(post-fixCleanup3_2_0)");
    -            runScenario(testableAmendments(), DestKind::Self, tesSUCCESS);
    -        }
    -    }
    -
    -    // Bug: the equality check (vault outflow == destination inflow) was
    -    // skipped whenever the destination delta rounded to zero at localMinScale,
    -    // including cases where the vault outflow rounded to a non-zero value and
    -    // a representable amount of value was genuinely destroyed.
    -    //
    -    // Scenario: Bob's IOU balance sits 5 units below the 10^16 STAmount
    -    // precision boundary (atEdge2 = 9,999,999,999,999,995).  A withdrawal of
    -    // 6 USD shifts his balance across that boundary: the exponent increments
    -    // (0 → 1), so his effective inflow in Number space is only +5 — 1 USD is
    -    // consumed by the precision-boundary rounding and cannot be credited.
    -    //
    -    // The destroyed amount (1 USD) is sub-ULP at destinationScale=1 (step=10),
    -    // so the check treats it as an unavoidable IOU-precision artefact and
    -    // lets the transaction succeed.
    -    //
    -    // Contrast: if 15 USD were destroyed at the same scale (destroyed ≥ step),
    -    // floor(15/10)=1 ≠ 0 and the invariant would fire — that discrepancy IS
    -    // representable and indicates a real accounting bug.
    -    //
    -    // Pre-fixCleanup3_2_0: the "must increase destination balance" check fires
    -    // because roundedDestinationDelta = 0 ≤ 0.
    -    void
    -    testVaultWithdrawEqualityEnforced()
    -    {
    -        using namespace test::jtx;
    -
    -        auto runScenario = [this](FeatureBitset features, TER expected) {
    -            std::string logs;
    -            Env env(*this, features, std::make_unique(&logs));
    -
    -            Account const issuer{"issuer"};
    -            Account const alice{"alice"};
    -            Account const bob{"bob"};
    -
    -            env.fund(XRP(100'000), issuer, alice, bob);
    -            env.close();
    -            env(fset(issuer, asfDefaultRipple));
    -            env.close();
    -
    -            PrettyAsset const usd{issuer["USD"]};
    -            STAmount const aliceLimit{usd.raw(), 2, 16};
    -            STAmount const bobLimit{usd.raw(), 2, 16};
    -            // Bob's balance sits 5 units below the 10^16 STAmount precision
    -            // boundary.  Receiving 6 USD shifts his exponent 0 → 1; the
    -            // STAmount records +5, not +6 (1 USD is lost to rounding).
    -            STAmount const atEdge2{usd.raw(), Number{9'999'999'999'999'995LL}};
    -
    -            env(trust(alice, aliceLimit));
    -            env(trust(bob, bobLimit));
    -            env.close();
    -
    -            env(pay(issuer, alice, usd(1'000)));
    -            env(pay(issuer, bob, atEdge2));
    -            env.close();
    -
    -            Vault const vault{env};
    -            auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = usd});
    -            vaultTx[sfScale] = 0;
    -            env(vaultTx);
    -            env.close();
    -
    -            env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = usd(1'000)}));
    -            env.close();
    -
    -            // Withdraw 6 USD to Bob: vault loses 6, Bob gains only 5.
    -            // Destroyed amount = 1 USD, which is sub-ULP at destinationScale=1.
    -            auto tx = vault.withdraw({.depositor = alice, .id = vaultKeylet.key, .amount = usd(6)});
    -            tx[sfDestination] = bob.human();
    -            env(tx, Ter(expected));
    -            env.close();
    -        };
    -
    -        {
    -            testcase(
    -                "bug: VaultWithdraw to destination at IOU precision boundary fires "
    -                "invariant (pre-fixCleanup3_2_0)");
    -            runScenario(testableAmendments() - fixCleanup3_2_0, tecINVARIANT_FAILED);
    -        }
    -        {
    -            testcase(
    -                "bug: VaultWithdraw to destination at IOU precision boundary succeeds "
    -                "when destroyed amount is sub-ULP (post-fixCleanup3_2_0)");
    -            runScenario(testableAmendments(), tesSUCCESS);
    -        }
    -    }
    -
    -    // Bug: when a depositor's IOU trustline balance is very large (e.g.
    -    // ~1e17), adding a small deposit (e.g. 1 USD) leaves sfAssetsTotal
    -    // unchanged at IOU precision because the increment is sub-ULP at the
    -    // vault's current asset scale.  The vault records the deposit, mints
    -    // shares, and decrements the depositor's trustline, but sfAssetsTotal
    -    // does not change — the conservation invariant fires because the rail
    -    // delta is zero.
    -    //
    -    // Two sub-cases are exercised:
    -    //   1. First-ever deposit into an empty vault: the depositor's own
    -    //      trustline has a large balance so 1 USD canonicalizes to zero
    -    //      when written back through the IOU rail.
    -    //   2. Subsequent deposit after the vault already holds a large
    -    //      sfAssetsTotal: a different depositor (bob, with a small balance)
    -    //      sends 1 USD, which again rounds to zero at the vault's coarse
    -    //      asset scale.
    -    //
    -    // Fix (fixCleanup3_2_0): the deposit transactor checks whether
    -    // roundToAsset(amount, vault_scale) == 0 and rejects early with
    -    // tecPRECISION_LOSS before any state is modified.
    -    void
    -    testVaultDepositCanonicalizeToZero()
    -    {
    -        using namespace test::jtx;
    -        auto runScenario = [this](FeatureBitset features, TER expected) {
    -            std::string logs;
    -            Env env(*this, features, std::make_unique(&logs));
    -
    -            Account const issuer{"issuer"};
    -            Account const alice{"alice"};
    -            Account const bob{"bob"};
    -
    -            env.fund(XRP(100'000), issuer, alice, bob);
    -            env.close();
    -
    -            env(fset(issuer, asfDefaultRipple));
    -            env.close();
    -
    -            PrettyAsset const usd{issuer["USD"]};
    -
    -            STAmount const trustLimit{usd.raw(), Number{99'999'999'999'999'999LL}};
    -            STAmount const aliceFund{usd.raw(), Number{99'999'999'999'999'999LL}};
    -
    -            env(trust(alice, trustLimit));
    -            env(trust(bob, trustLimit));
    -            env.close();
    -
    -            env(pay(issuer, alice, aliceFund));
    -            env(pay(issuer, bob, usd(1000)));
    -            env.close();
    -
    -            Vault const vault{env};
    -
    -            // Scale=0 so sfAssetsTotal stores whole USD
    -            auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = usd});
    -            vaultTx[sfScale] = 0;
    -            env(vaultTx);
    -            env.close();
    -
    -            // Alice's deposit canonicalizes to zero at her own trustline scale
    -            env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = usd(1)}),
    -                Ter(expected));
    -
    -            // Increase vault-scale
    -            env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = aliceFund}));
    -            env.close();
    -
    -            env(vault.deposit({.depositor = bob, .id = vaultKeylet.key, .amount = usd(1)}),
    -                Ter(expected));
    -            env.close();
    -        };
    -
    -        {
    -            testcase(
    -                "bug: VaultDeposit below Vault precision canonicalized to zero "
    -                "(pre-fixCleanup3_2_0)");
    -            runScenario(testableAmendments() - fixCleanup3_2_0, tecINVARIANT_FAILED);
    -        }
    -        {
    -            testcase(
    -                "bug: VaultDeposit below Vault precision canonicalized to zero "
    -                "(post-fixCleanup3_2_0)");
    -            runScenario(testableAmendments(), tecPRECISION_LOSS);
    -        }
    -    }
    -
    -    // VaultDeposit by issuer with the vault parked at the IOU 16-digit
    -    // edge (9.999e15). Issuer mints 2 more USD; the vault trust line
    -    // goes 9.999e15 → 10^16, gaining 1 unit instead of 2 (canonicalization).
    -    //
    -    // Pre-fixCleanup3_2_0: the proactive check is absent; the deposit
    -    // applies, then VaultInvariant's "deposit must increase vault
    -    // balance" assertion fires at finalize time on the rounded vault
    -    // delta of zero, returning tecINVARIANT_FAILED.
    -    // Post-amendment: reject deposit that is not representable at Vault scale.
    -    void
    -    testBugIssuerVaultDepositAtEdge()
    -    {
    -        using namespace test::jtx;
    -
    -        auto runScenario = [this](FeatureBitset features, TER expected) {
    -            std::string logs;
    -            Env env(*this, features, std::make_unique(&logs));
    -
    -            Account const issuer{"issuer"};
    -            Account const owner{"owner"};
    -
    -            env.fund(XRP(100'000), issuer, owner);
    -            env.close();
    -            env(fset(issuer, asfDefaultRipple));
    -            env.close();
    -
    -            PrettyAsset const usd{issuer["USD"]};
    -            STAmount const trustLimit{usd.raw(), 2, 16};
    -            STAmount const ownerFund{usd.raw(), Number{9'999'999'999'999'999LL}};
    -
    -            env(trust(owner, trustLimit));
    -            env.close();
    -            env(pay(issuer, owner, ownerFund));
    -            env.close();
    -
    -            Vault const vault{env};
    -            auto [vaultTx, vaultKeylet] = vault.create({.owner = owner, .asset = usd});
    -            vaultTx[sfScale] = 0;
    -            env(vaultTx);
    -            env.close();
    -            env(vault.deposit({.depositor = owner, .id = vaultKeylet.key, .amount = ownerFund}));
    -            env.close();
    -
    -            // Vault pseudo-account is now at 9.999e15. Issuer mints 2
    -            // more USD. Pre: tecINVARIANT_FAILED at finalize. Post:
    -            // tecPRECISION_LOSS proactively. Either way, no value moves.
    -            env(vault.deposit({.depositor = issuer, .id = vaultKeylet.key, .amount = usd(2)}),
    -                Ter(expected));
    -            env.close();
    -        };
    -
    -        {
    -            testcase(
    -                "bug: VaultDeposit by issuer at IOU edge fires "
    -                "tecINVARIANT_FAILED at finalize (pre-fixCleanup3_2_0)");
    -            runScenario(testableAmendments() - fixCleanup3_2_0, tecINVARIANT_FAILED);
    -        }
    -        {
    -            testcase(
    -                "bug: VaultDeposit by issuer at IOU edge rejects with "
    -                "tecPRECISION_LOSS proactively (post-fixCleanup3_2_0)");
    -            runScenario(testableAmendments(), tecPRECISION_LOSS);
    -        }
    -    }
    -
    -    void
    -    testReferenceHolding()
    -    {
    -        using namespace test::jtx;
    -
    -        auto readReferenceHolding = [&](Env const& env,
    -                                        Keylet const& vaultKeylet) -> std::optional {
    -            auto const sleVault = env.le(vaultKeylet);
    -            if (!sleVault)
    -                return std::nullopt;
    -            auto const sleIssuance = env.le(keylet::mptokenIssuance(sleVault->at(sfShareMPTID)));
    -            if (!sleIssuance || !sleIssuance->isFieldPresent(sfReferenceHolding))
    -                return std::nullopt;
    -            return sleIssuance->getFieldH256(sfReferenceHolding);
    -        };
    -
    -        // Post-fixCleanup3_2_0: vault share carries sfReferenceHolding
    -        // pointing to the vault pseudo's MPToken (for MPT-backed vaults)
    -        // or RippleState (for IOU-backed vaults).
    -        {
    -            testcase("sfReferenceHolding: MPT-backed vault, post-amendment");
    -            Env env{*this, testableAmendments()};
    -            Account const issuer{"issuer"};
    -            Account const owner{"owner"};
    -            env.fund(XRP(10'000), issuer, owner);
    -            env.close();
    -
    -            MPTTester mptt{env, issuer, kMptInitNoFund};
    -            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
    -            PrettyAsset const asset = mptt.issuanceID();
    -            mptt.authorize({.account = owner});
    -
    -            Vault const vault{env};
    -            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -            env(tx);
    -            env.close();
    -
    -            auto const sleVault = env.le(keylet);
    -            BEAST_EXPECT(sleVault != nullptr);
    -            auto const pseudoId = sleVault->at(sfAccount);
    -            auto const expected = keylet::mptoken(mptt.issuanceID(), pseudoId).key;
    -
    -            auto const stored = readReferenceHolding(env, keylet);
    -            BEAST_EXPECT(stored.has_value());
    -            BEAST_EXPECT(stored && *stored == expected);
    -            // The pointed-to MPToken must actually exist.
    -            BEAST_EXPECT(env.le(keylet::mptoken(mptt.issuanceID(), pseudoId)) != nullptr);
    -        }
    -
    -        {
    -            testcase("sfReferenceHolding: IOU-backed vault, post-amendment");
    -            Env env{*this, testableAmendments()};
    -            Account const issuer{"issuer"};
    -            Account const owner{"owner"};
    -            env.fund(XRP(10'000), issuer, owner);
    -            env(fset(issuer, asfDefaultRipple));
    -            env.close();
    -
    -            PrettyAsset const asset = issuer["IOU"];
    -            env.trust(asset(1'000'000), owner);
    -            env.close();
    -
    -            Vault const vault{env};
    -            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -            env(tx);
    -            env.close();
    -
    -            auto const sleVault = env.le(keylet);
    -            BEAST_EXPECT(sleVault != nullptr);
    -            auto const pseudoId = sleVault->at(sfAccount);
    -            auto const expected = keylet::trustLine(pseudoId, asset.raw().get()).key;
    -
    -            auto const stored = readReferenceHolding(env, keylet);
    -            BEAST_EXPECT(stored.has_value());
    -            BEAST_EXPECT(stored && *stored == expected);
    -            // The pointed-to RippleState must actually exist.
    -            BEAST_EXPECT(env.le(keylet::trustLine(pseudoId, asset.raw().get())) != nullptr);
    -        }
    -
    -        // XRP-backed vaults leave the field absent: XRP has no separate
    -        // holding ledger entry and no transferability concept to inherit.
    -        {
    -            testcase("sfReferenceHolding: XRP-backed vault, field absent");
    -            Env env{*this, testableAmendments()};
    -            Account const owner{"owner"};
    -            env.fund(XRP(10'000), owner);
    -            env.close();
    -
    -            PrettyAsset const asset{xrpIssue(), 1'000'000};
    -            Vault const vault{env};
    -            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -            env(tx);
    -            env.close();
    -
    -            BEAST_EXPECT(!readReferenceHolding(env, keylet).has_value());
    -        }
    -
    -        // Pre-fixCleanup3_2_0: vault share has the field absent regardless
    -        // of underlying type.
    -        {
    -            testcase("sfReferenceHolding: vault share, pre-amendment");
    -            Env env{*this, testableAmendments() - fixCleanup3_2_0};
    -            Account const issuer{"issuer"};
    -            Account const owner{"owner"};
    -            env.fund(XRP(10'000), issuer, owner);
    -            env.close();
    -
    -            MPTTester mptt{env, issuer, kMptInitNoFund};
    -            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
    -            PrettyAsset const asset = mptt.issuanceID();
    -            mptt.authorize({.account = owner});
    -
    -            Vault const vault{env};
    -            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -            env(tx);
    -            env.close();
    -
    -            BEAST_EXPECT(!readReferenceHolding(env, keylet).has_value());
    -        }
    -
    -        // Plain MPTokenIssuanceCreate (not a vault share) must never
    -        // populate the field. Only the post-amendment case is
    -        // interesting; pre-amendment nothing writes the field at all.
    -        {
    -            testcase("sfReferenceHolding: plain MPT issuance never set");
    -            Env env{*this, testableAmendments()};
    -            Account const issuer{"issuer"};
    -            env.fund(XRP(10'000), issuer);
    -            env.close();
    -
    -            MPTTester mptt{env, issuer, kMptInitNoFund};
    -            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
    -            env.close();
    -
    -            auto const sleIssuance = env.le(keylet::mptokenIssuance(mptt.issuanceID()));
    -            if (BEAST_EXPECT(sleIssuance))
    -                BEAST_EXPECT(!sleIssuance->isFieldPresent(sfReferenceHolding));
    -        }
    -    }
    -
    -    // Probe every transactor surface that might delete the vault pseudo-
    -    // account's underlying holding (the MPToken or RippleState pointed to
    -    // by sfReferenceHolding). Each scenario asserts either that the
    -    // existing pseudo-account guards stop the deletion at preclaim, or
    -    // that the ledger leaves the holding intact afterwards. This is a
    -    // regression guard: if any of these guards regresses, the share's
    -    // sfReferenceHolding pointer would dangle and the new ValidMPTIssuance
    -    // invariant would catch it - but we want to fail much earlier, at
    -    // the transactor's preclaim / doApply, not at invariant time.
    -    void
    -    testHoldingDeletionBlocked()
    -    {
    -        using namespace test::jtx;
    -
    -        // Helper: read the share's referenced holding and confirm the
    -        // pointed-to SLE still exists after the probe.
    -        auto referencedHoldingExists = [&](Env const& env, Keylet const& vaultKeylet) -> bool {
    -            auto const sleVault = env.le(vaultKeylet);
    -            if (!sleVault)
    -                return false;
    -            auto const sleIssuance = env.le(keylet::mptokenIssuance(sleVault->at(sfShareMPTID)));
    -            if (!sleIssuance || !sleIssuance->isFieldPresent(sfReferenceHolding))
    -                return false;
    -            auto const holdingKey = sleIssuance->getFieldH256(sfReferenceHolding);
    -            return env.le(keylet::unchecked(holdingKey)) != nullptr;
    -        };
    -
    -        // ---- MPT-backed vault ----------------------------------------
    -        {
    -            testcase("vault pseudo MPToken: Clawback blocked by tecPSEUDO_ACCOUNT");
    -            Env env{*this, testableAmendments()};
    -            Account const issuer{"issuer"};
    -            Account const owner{"owner"};
    -            Account const depositor{"depositor"};
    -            env.fund(XRP(10'000), issuer, owner, depositor);
    -            env.close();
    -
    -            MPTTester mptt{env, issuer, kMptInitNoFund};
    -            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanClawback});
    -            PrettyAsset const asset = mptt.issuanceID();
    -            mptt.authorize({.account = owner});
    -            mptt.authorize({.account = depositor});
    -            env(pay(issuer, depositor, asset(1'000)));
    -            env.close();
    -
    -            Vault const vault{env};
    -            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -            env(tx);
    -            env.close();
    -
    -            env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(500)}));
    -            env.close();
    -
    -            BEAST_EXPECT(referencedHoldingExists(env, keylet));
    -
    -            Account const pseudoAccount{"vault-pseudo", env.le(keylet)->at(sfAccount)};
    -            // Issuer attempts to claw back the FULL underlying balance
    -            // (500) directly from the vault pseudo-account. With the
    -            // full amount, the doApply path would drain the pseudo's
    -            // MPToken to zero and removeEmptyHolding would erase it -
    -            // if doApply ever ran. SAV's pseudo-account guard at
    -            // Clawback.cpp:201 refuses at preclaim with
    -            // tecPSEUDO_ACCOUNT before any state change.
    -            env(claw(issuer, asset(500), pseudoAccount), Ter{tecPSEUDO_ACCOUNT});
    -            env.close();
    -            BEAST_EXPECT(referencedHoldingExists(env, keylet));
    -            // Sanity: pseudo's full balance is intact.
    -            BEAST_EXPECT(env.balance(pseudoAccount, asset).number() == 500);
    -        }
    -
    -        {
    -            testcase("vault pseudo MPToken: Issuer cannot Unauthorize pseudo");
    -            Env env{*this, testableAmendments()};
    -            Account const issuer{"issuer"};
    -            Account const owner{"owner"};
    -            env.fund(XRP(10'000), issuer, owner);
    -            env.close();
    -
    -            MPTTester mptt{env, issuer, kMptInitNoFund};
    -            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTRequireAuth});
    -            PrettyAsset const asset = mptt.issuanceID();
    -            mptt.authorize({.account = owner});
    -            mptt.authorize({.account = issuer, .holder = owner});
    -            env.close();
    -
    -            Vault const vault{env};
    -            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -            env(tx);
    -            env.close();
    -
    -            BEAST_EXPECT(referencedHoldingExists(env, keylet));
    -
    -            auto const pseudoId = env.le(keylet)->at(sfAccount);
    -            // Issuer attempts MPTokenAuthorize against the pseudo with
    -            // tfMPTUnauthorize. MPTokenAuthorize.cpp blocks pseudo
    -            // accounts via isPseudoAccount; the pseudo's MPToken is
    -            // preserved. Construct the tx manually since the pseudo
    -            // lacks a signing key, and the issuer-driven flavour is
    -            // expressed via sfHolder.
    -            json::Value jv;
    -            jv[sfAccount] = issuer.human();
    -            jv[sfHolder] = toBase58(pseudoId);
    -            jv[sfMPTokenIssuanceID] = to_string(mptt.issuanceID());
    -            jv[sfFlags] = tfMPTUnauthorize;
    -            jv[sfTransactionType] = jss::MPTokenAuthorize;
    -            env(jv, Ter{tecNO_PERMISSION});
    -            env.close();
    -            BEAST_EXPECT(referencedHoldingExists(env, keylet));
    -        }
    -
    -        {
    -            testcase("vault pseudo MPToken: MPTokenIssuanceDestroy blocked while vault holds");
    -            Env env{*this, testableAmendments()};
    -            Account const issuer{"issuer"};
    -            Account const owner{"owner"};
    -            Account const depositor{"depositor"};
    -            env.fund(XRP(10'000), issuer, owner, depositor);
    -            env.close();
    -
    -            MPTTester mptt{env, issuer, kMptInitNoFund};
    -            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
    -            PrettyAsset const asset = mptt.issuanceID();
    -            mptt.authorize({.account = owner});
    -            mptt.authorize({.account = depositor});
    -            env(pay(issuer, depositor, asset(1'000)));
    -            env.close();
    -
    -            Vault const vault{env};
    -            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -            env(tx);
    -            env.close();
    -
    -            env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(500)}));
    -            env.close();
    -
    -            BEAST_EXPECT(referencedHoldingExists(env, keylet));
    -
    -            // While the vault holds outstanding underlying, the issuer
    -            // cannot destroy the issuance. tecHAS_OBLIGATIONS confirms
    -            // the protection - and as a side effect, the share's
    -            // sfReferenceHolding pointer cannot be left pointing at a
    -            // ghost issuance.
    -            mptt.destroy({.id = mptt.issuanceID(), .err = tecHAS_OBLIGATIONS});
    -            env.close();
    -            BEAST_EXPECT(referencedHoldingExists(env, keylet));
    -        }
    -
    -        // ---- IOU-backed vault ----------------------------------------
    -        {
    -            testcase("vault pseudo trust line: Clawback blocked by tecPSEUDO_ACCOUNT");
    -            Env env{*this, testableAmendments()};
    -            Account const issuer{"issuer"};
    -            Account const owner{"owner"};
    -            env.fund(XRP(10'000), issuer, owner);
    -            env(fset(issuer, asfAllowTrustLineClawback));
    -            env.close();
    -
    -            PrettyAsset const asset = issuer["IOU"];
    -            env.trust(asset(1'000'000), owner);
    -            env(pay(issuer, owner, asset(1'000)));
    -            env.close();
    -
    -            Vault const vault{env};
    -            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -            env(tx);
    -            env.close();
    -
    -            env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(500)}));
    -            env.close();
    -
    -            BEAST_EXPECT(referencedHoldingExists(env, keylet));
    -
    -            Account const pseudoAccount{"vault-pseudo", env.le(keylet)->at(sfAccount)};
    -            // Issuer attempts to claw back the FULL IOU balance (500)
    -            // directly from the vault pseudo. With the full amount, the
    -            // doApply path would drain the trust line to zero and (if
    -            // both reserve flags clear) trustDelete would erase it - if
    -            // doApply ever ran. The same SAV pseudo-account guard
    -            // refuses at preclaim with tecPSEUDO_ACCOUNT. The amount's
    -            // STAmount issuer field is the holder, per IOU clawback
    -            // convention.
    -            env(claw(issuer, pseudoAccount["IOU"](500)), Ter{tecPSEUDO_ACCOUNT});
    -            env.close();
    -            BEAST_EXPECT(referencedHoldingExists(env, keylet));
    -            // Sanity: pseudo's full balance is intact.
    -            BEAST_EXPECT(env.balance(pseudoAccount, asset).number() == 500);
    -        }
    -
    -        {
    -            testcase("vault pseudo trust line: TrustSet limit=0 from issuer preserves line");
    -            Env env{*this, testableAmendments()};
    -            Account const issuer{"issuer"};
    -            Account const owner{"owner"};
    -            env.fund(XRP(10'000), issuer, owner);
    -            env(fset(issuer, asfDefaultRipple));
    -            env.close();
    -
    -            PrettyAsset const asset = issuer["IOU"];
    -            env.trust(asset(1'000'000), owner);
    -            env(pay(issuer, owner, asset(1'000)));
    -            env.close();
    -
    -            Vault const vault{env};
    -            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -            env(tx);
    -            env.close();
    -
    -            env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(500)}));
    -            env.close();
    -
    -            BEAST_EXPECT(referencedHoldingExists(env, keylet));
    -
    -            // Issuer submits TrustSet with limit=0 against the vault
    -            // pseudo. The pseudo's side of the line still has the
    -            // original (non-zero) limit and a non-zero balance, so the
    -            // line is preserved - even though the issuer cleared its
    -            // own side. trustDelete only fires when both limits clear
    -            // and the balance is zero.
    -            Account const pseudoAccount{"vault-pseudo", env.le(keylet)->at(sfAccount)};
    -            env(trust(issuer, pseudoAccount["IOU"](0)));
    -            env.close();
    -            BEAST_EXPECT(referencedHoldingExists(env, keylet));
    -        }
    -
    -        // ---- Positive control: VaultDelete is the only legitimate path
    -        {
    -            testcase("vault pseudo holding: VaultDelete is the legitimate cleanup path");
    -            Env env{*this, testableAmendments()};
    -            Account const issuer{"issuer"};
    -            Account const owner{"owner"};
    -            env.fund(XRP(10'000), issuer, owner);
    -            env.close();
    -
    -            MPTTester mptt{env, issuer, kMptInitNoFund};
    -            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
    -            PrettyAsset const asset = mptt.issuanceID();
    -            mptt.authorize({.account = owner});
    -
    -            Vault const vault{env};
    -            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -            env(tx);
    -            env.close();
    -
    -            BEAST_EXPECT(referencedHoldingExists(env, keylet));
    -            auto const pseudoId = env.le(keylet)->at(sfAccount);
    -            auto const sharedMptId = env.le(keylet)->at(sfShareMPTID);
    -            auto const holdingKeylet = keylet::mptoken(mptt.issuanceID(), pseudoId);
    -
    -            // VaultDelete tears down the vault pseudo's holding, the
    -            // share issuance, and the pseudo-account itself. Invariant
    -            // permits this because the tx is ttVAULT_DELETE.
    -            env(vault.del({.owner = owner, .id = keylet.key}));
    -            env.close();
    -
    -            BEAST_EXPECT(env.le(keylet) == nullptr);
    -            BEAST_EXPECT(env.le(holdingKeylet) == nullptr);
    -            BEAST_EXPECT(env.le(keylet::mptokenIssuance(sharedMptId)) == nullptr);
    -        }
    -    }
    -
    -    // VaultDeposit::preclaim uses accountHolds(..., SpendableHandling::
    -    // shFULL_BALANCE), which for an IOU asset adds the counterparty's
    -    // LowLimit/HighLimit to the depositor's raw balance (TokenHelpers.cpp:
    -    // getTrustLineBalance with includeOppositeLimit=true). When the
    -    // depositor's raw balance < deposit amount but raw + opposite limit >=
    -    // amount, preclaim is satisfied. doApply then calls
    -    // directSendNoFeeIOU, which unconditionally subtracts saAmount from
    -    // saBalance — driving the trust line negative — and returns tesSUCCESS.
    -    // The post-send sanity check uses the default shSIMPLE_BALANCE (no
    -    // opposite-limit add), sees a negative balance, and returns tefINTERNAL.
    -    void
    -    testVaultDepositNegativeBalanceFromOppositeLimit()
    -    {
    -        auto runTest = [&](FeatureBitset f, TER expected) {
    -            using namespace test::jtx;
    -            using namespace std::literals;
    -
    -            Env env{*this, f};
    -            Account const gw{"gateway"};
    -            Account const owner{"owner"};
    -            Account const depositor{"depositor"};
    -
    -            env.fund(XRP(10000), gw, owner, depositor);
    -            env.close();
    -
    -            // Gateway with DefaultRipple so vault creation on its IOU works.
    -            env(fset(gw, asfDefaultRipple));
    -            env.close();
    -
    -            // Depositor opens a trust line to gateway and receives a small
    -            // balance.
    -            PrettyAsset const usd = gw["USD"];
    -            env.trust(usd(1000), depositor);
    -            env(pay(gw, depositor, usd(100)));  // raw trust-line balance: 100
    -            env.close();
    -
    -            // Key precondition: gateway sets a non-zero limit on the same
    -            // RippleState — the "opposite field" from depositor's perspective.
    -            // This is what inflates shFULL_BALANCE in preclaim above the raw
    -            // balance.
    -            env(trust(gw, depositor["USD"](1000)));
    -            env.close();
    -
    -            // Create the IOU vault.
    -            Vault const vault{env};
    -            auto [vaultTx, keylet] = vault.create({.owner = owner, .asset = usd});
    -            env(vaultTx);
    -            env.close();
    -
    -            // Submit a deposit of 500 USD:
    -            //   - raw balance:                100 USD
    -            //   - opposite limit (gw's side): 1000 USD
    -            //   - preclaim sees 100 + 1000 = 1100, passes (>= 500)
    -            //   - doApply transfers 500, depositor's trust-line balance
    -            //     becomes -400
    -            //   - sanity check at VaultDeposit.cpp:256 fires
    -            //   - tx returns tefINTERNAL (BUG — should be tesSUCCESS.
    -            auto depositTx =
    -                vault.deposit({.depositor = depositor, .id = keylet.key, .amount = usd(500)});
    -            env(depositTx, Ter(expected));
    -            env.close();
    -        };
    -
    -        {
    -            testcase(
    -                "IOU vault deposit exceeding depositor's balance but "
    -                "within counterparty's trust limit, pre-fixCleanup3_2_0 "
    -                "(tefINTERNAL)");
    -            runTest(test::jtx::testableAmendments() - fixCleanup3_2_0, tefINTERNAL);
    -        }
    -        {
    -            testcase(
    -                "IOU vault deposit exceeding depositor's balance but "
    -                "within counterparty's trust limit, post-fixCleanup3_2_0 "
    -                "(tesSUCCESS)");
    -            runTest(test::jtx::testableAmendments(), tesSUCCESS);
    -        }
    -    }
    -
    -    void
    -    testVaultDeleteMemoData()
    -    {
    -        using namespace test::jtx;
    -
    -        Env env{*this};
    -
    -        Account const owner{"owner"};
    -        env.fund(XRP(1'000'000), owner);
    -        env.close();
    -
    -        Vault const vault{env};
    -
    -        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
    -        // Transaction fails if the data field is provided
    -        {
    -            testcase("VaultDelete memo data featureLendingProtocolV1_1 disabled");
    -            env.disableFeature(featureLendingProtocolV1_1);
    -            delTx[sfMemoData] = strHex(std::string(kMaxDataPayloadLength, 'A'));
    -            env(delTx, Ter(temDISABLED));
    -            env.enableFeature(featureLendingProtocolV1_1);
    -            env.close();
    -        }
    -
    -        // Transaction fails if the data field is too large
    -        {
    -            testcase("VaultDelete memo data featureLendingProtocolV1_1 enabled data too large");
    -            delTx[sfMemoData] = strHex(std::string(kMaxDataPayloadLength + 1, 'A'));
    -            env(delTx, Ter(temMALFORMED));
    -            env.close();
    -        }
    -
    -        // Transaction fails if the data field is set, but is empty
    -        {
    -            testcase("VaultDelete memo data featureLendingProtocolV1_1 enabled data empty");
    -            delTx[sfMemoData] = strHex(std::string());
    -            env(delTx, Ter(temMALFORMED));
    -            env.close();
    -        }
    -
    -        {
    -            testcase("VaultDelete memo data featureLendingProtocolV1_1 enabled no vault");
    -            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});
    -            delTx[sfMemoData] = strHex(std::string(kMaxDataPayloadLength, 'A'));
    -            env(delTx, Ter(tecNO_ENTRY));
    -            env.close();
    -        }
    -
    -        {
    -            testcase("VaultDelete memo data featureLendingProtocolV1_1 enabled data valid");
    -            PrettyAsset const xrpAsset = xrpIssue();
    -            auto const [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset});
    -            env(tx, Ter(tesSUCCESS));
    -            env.close();
    -            // Recreate the transaction as the vault keylet changed
    -            auto delTx = vault.del({.owner = owner, .id = keylet.key});
    -            delTx[sfMemoData] = strHex(std::string(kMaxDataPayloadLength, 'A'));
    -            env(delTx, Ter(tesSUCCESS));
    -            env.close();
    -        }
    -    }
    -
    -    void
    -    testVaultCreateLEVersion()
    -    {
    -        using namespace test::jtx;
    -
    -        Account const owner{"owner"};
    -        PrettyAsset const xrpAsset = xrpIssue();
    -
    -        {
    -            testcase("VaultCreate LEVersion: featureLendingProtocolV1_1 disabled, field absent");
    -            Env env{*this};
    -            env.disableFeature(featureLendingProtocolV1_1);
    -            env.fund(XRP(1'000'000), owner);
    -            env.close();
    -
    -            Vault const vault{env};
    -            auto const [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset});
    -            env(tx, Ter(tesSUCCESS));
    -            env.close();
    -
    -            auto const sleVault = env.le(keylet);
    -            BEAST_EXPECT(sleVault);
    -            BEAST_EXPECT(!sleVault->isFieldPresent(sfLEVersion));
    -        }
    -
    -        {
    -            testcase(
    -                "VaultCreate LEVersion: featureLendingProtocolV1_1 enabled, LEVersion == "
    -                "VaultVersion::CashBasis");
    -            Env env{*this};
    -            env.fund(XRP(1'000'000), owner);
    -            env.close();
    -
    -            Vault const vault{env};
    -            auto const [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset});
    -            env(tx, Ter(tesSUCCESS));
    -            env.close();
    -
    -            auto const sleVault = env.le(keylet);
    -            BEAST_EXPECT(sleVault);
    -            BEAST_EXPECT(sleVault->isFieldPresent(sfLEVersion));
    -            BEAST_EXPECT(sleVault->at(sfLEVersion) == std::to_underlying(VaultVersion::CashBasis));
    -        }
    -
    -        {
    -            testcase("VaultCreate rejects LEVersion set in the transaction");
    -            Env env{*this};
    -            env.fund(XRP(1'000'000), owner);
    -            env.close();
    -
    -            Vault const vault{env};
    -            auto [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset});
    -            tx[sfLEVersion] = 2;
    -            env(tx, Ter(temMALFORMED));
    -            env.close();
    -
    -            BEAST_EXPECT(!env.le(keylet));
    -        }
    -
    -        {
    -            testcase("VaultSet rejects LEVersion set in the transaction");
    -            Env env{*this};
    -            env.fund(XRP(1'000'000), owner);
    -            env.close();
    -
    -            Vault const vault{env};
    -            auto const [createTx, keylet] = vault.create({.owner = owner, .asset = xrpAsset});
    -            env(createTx, Ter(tesSUCCESS));
    -            env.close();
    -
    -            auto setTx = vault.set({.owner = owner, .id = keylet.key});
    -            setTx[sfLEVersion] = 2;
    -            env(setTx, Ter(temMALFORMED));
    -            env.close();
    -        }
    -    }
    -
    -    void
    -    testVaultDepositFreezeIOU()
    -    {
    -        using namespace test::jtx;
    -        testcase("VaultDeposit IOU freeze checks");
    -
    -        Account const issuer{"issuer"};
    -        Account const owner{"owner"};
    -        Env env{*this};
    -        Vault vault{env};
    -
    -        env.fund(XRP(100'000), issuer, owner);
    -        env(fset(issuer, asfAllowTrustLineClawback));
    -        env.close();
    -        PrettyAsset const asset = issuer["IOU"];
    -        env.trust(asset(1'000'000), owner);
    -        env(pay(issuer, owner, asset(100'000)));
    -        env.close();
    -
    -        auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -        env(tx);
    -        env.close();
    -        auto const vaultAcct = Account("vault", env.le(keylet)->at(sfAccount));
    -
    -        // Initial deposit so the vault pseudo-account has a trustline
    -        env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)}));
    -        env.close();
    -
    -        auto runTests = [&]() {
    -            auto const fix330Enabled = env.current()->rules().enabled(fixCleanup3_3_0);
    -
    -            // Global freeze
    -            {
    -                testcase("VaultDeposit IOU global freeze");
    -                env(fset(issuer, asfGlobalFreeze));
    -                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
    -                    Ter(tecFROZEN));
    -                env(fclear(issuer, asfGlobalFreeze));
    -            }
    -
    -            // Depositor freeze
    -            {
    -                testcase("VaultDeposit IOU depositor freeze");
    -                env(trust(issuer, asset(0), owner, tfSetFreeze));
    -                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
    -                    Ter(tecFROZEN));
    -                env(trust(issuer, asset(0), owner, tfClearFreeze));
    -            }
    -
    -            // Depositor deep freeze
    -            {
    -                testcase("VaultDeposit IOU depositor deep freeze");
    -                env(trust(issuer, asset(0), owner, tfSetFreeze | tfSetDeepFreeze));
    -                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
    -                    Ter(tecFROZEN));
    -                env(trust(issuer, asset(0), owner, tfClearFreeze | tfClearDeepFreeze));
    -            }
    -
    -            // Vault-account freeze
    -            // Post-fix: checkDepositFreeze catches it → tecFROZEN
    -            // Pre-fix: not checked directly, but the transitive share
    -            //          check triggers → tecLOCKED
    -            {
    -                testcase("VaultDeposit IOU pseudo-account freeze");
    -                auto trustSet = [&]() {
    -                    json::Value jv;
    -                    jv[jss::Account] = issuer.human();
    -                    {
    -                        auto& ja = jv[jss::LimitAmount] =
    -                            asset(0).value().getJson(JsonOptions::Values::None);
    -                        ja[jss::issuer] = toBase58(vaultAcct.id());
    -                    }
    -                    jv[jss::TransactionType] = jss::TrustSet;
    -                    return jv;
    -                }();
    -
    -                trustSet[jss::Flags] = tfSetFreeze;
    -                env(trustSet);
    -                env.close();
    -
    -                TER const expected = fix330Enabled ? TER(tecFROZEN) : TER(tecLOCKED);
    -                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
    -                    Ter(expected));
    -
    -                trustSet[jss::Flags] = tfClearFreeze;
    -                env(trustSet);
    -                env.close();
    -            }
    -
    -            // Vault-account deep freeze
    -            {
    -                testcase("VaultDeposit IOU pseudo-account deep freeze");
    -                auto trustSet = [&]() {
    -                    json::Value jv;
    -                    jv[jss::Account] = issuer.human();
    -                    {
    -                        auto& ja = jv[jss::LimitAmount] =
    -                            asset(0).value().getJson(JsonOptions::Values::None);
    -                        ja[jss::issuer] = toBase58(vaultAcct.id());
    -                    }
    -                    jv[jss::TransactionType] = jss::TrustSet;
    -                    return jv;
    -                }();
    -
    -                trustSet[jss::Flags] = tfSetFreeze | tfSetDeepFreeze;
    -                env(trustSet);
    -                env.close();
    -
    -                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
    -                    Ter(fix330Enabled ? TER(tecFROZEN) : TER(tecLOCKED)));
    -
    -                trustSet[jss::Flags] = tfClearFreeze | tfClearDeepFreeze;
    -                env(trustSet);
    -                env.close();
    -            }
    -
    -            // Clawback works while frozen
    -            {
    -                testcase("VaultDeposit IOU freeze clawback unaffected");
    -                env(fset(issuer, asfGlobalFreeze));
    -                env(vault.clawback(
    -                    {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(1)}));
    -                env(fclear(issuer, asfGlobalFreeze));
    -                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}));
    -                env.close();
    -            }
    -        };
    -
    -        runTests();
    -        env.disableFeature(fixCleanup3_3_0);
    -        runTests();
    -        env.enableFeature(fixCleanup3_3_0);
    -    }
    -
    -    void
    -    testVaultDepositFreezeMPT()
    -    {
    -        using namespace test::jtx;
    -        testcase("VaultDeposit MPT lock checks");
    -
    -        Account const issuer{"issuer"};
    -        Account const owner{"owner"};
    -        Env env{*this};
    -        Vault vault{env};
    -
    -        env.fund(XRP(100'000), issuer, owner);
    -        env.close();
    -
    -        MPTTester mptt{env, issuer, kMptInitNoFund};
    -        mptt.create(
    -            {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock | tfMPTRequireAuth});
    -        PrettyAsset const mpt{mptt.issuanceID()};
    -
    -        mptt.authorize({.account = owner});
    -        mptt.authorize({.account = issuer, .holder = owner});
    -        env.close();
    -        env(pay(issuer, owner, mpt(100'000)));
    -        env.close();
    -
    -        auto [tx, keylet] = vault.create({.owner = owner, .asset = mpt});
    -        env(tx);
    -        env.close();
    -        auto const vaultAcctID = env.le(keylet)->at(sfAccount);
    -        Account const vaultAcct("vault", vaultAcctID);
    -
    -        env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(100)}));
    -        env.close();
    -
    -        // For MPT isDeepFrozen == isFrozen, so all locks block in
    -        // both pre- and post-fix.
    -        auto runTests = [&]() {
    -            // Global lock
    -            {
    -                testcase("VaultDeposit MPT global lock");
    -                mptt.set({.flags = tfMPTLock});
    -                env.close();
    -                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}),
    -                    Ter(tecLOCKED));
    -                mptt.set({.flags = tfMPTUnlock});
    -                env.close();
    -            }
    -
    -            // Depositor individual lock
    -            {
    -                testcase("VaultDeposit MPT depositor lock");
    -                mptt.set({.holder = owner, .flags = tfMPTLock});
    -                env.close();
    -                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}),
    -                    Ter(tecLOCKED));
    -                mptt.set({.holder = owner, .flags = tfMPTUnlock});
    -                env.close();
    -            }
    -
    -            // Vault pseudo-account individual lock
    -            {
    -                testcase("VaultDeposit MPT pseudo-account lock");
    -                mptt.set({.holder = vaultAcct, .flags = tfMPTLock});
    -                env.close();
    -                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}),
    -                    Ter(tecLOCKED));
    -                mptt.set({.holder = vaultAcct, .flags = tfMPTUnlock});
    -                env.close();
    -            }
    -
    -            // Clawback works while locked
    -            {
    -                testcase("VaultDeposit MPT lock clawback unaffected");
    -                mptt.set({.flags = tfMPTLock});
    -                env.close();
    -                env(vault.clawback(
    -                    {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = mpt(1)}));
    -                mptt.set({.flags = tfMPTUnlock});
    -                env.close();
    -                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}));
    -                env.close();
    -            }
    -        };
    -
    -        runTests();
    -        env.disableFeature(fixCleanup3_3_0);
    -        runTests();
    -        env.enableFeature(fixCleanup3_3_0);
    -    }
    -
    -    // Focused demonstration: a depositor under an individual IOU freeze
    -    // can still withdraw to themselves (self-withdrawal), but is blocked from
    -    // withdrawing to a third party.
    -    //
    -    // Pre-fixCleanup3_3_0: both the self-withdrawal AND the third-party
    -    // withdrawal were blocked because the old code checked checkFrozen on the
    -    // destination regardless of whether it was the submitter.
    -    // Post-fixCleanup3_3_0: checkWithdrawFreeze skips the submitter freeze
    -    // check when submitter == destination, so self-withdrawal succeeds.
    -    void
    -    testVaultSelfWithdrawWhileFrozen()
    -    {
    -        testcase("VaultWithdraw IOU self-withdrawal while individually frozen");
    -
    -        using namespace test::jtx;
    -
    -        Account const issuer{"issuer"};
    -        Account const owner{"owner"};
    -        Account const charlie{"charlie"};
    -        Env env{*this};
    -        Vault vault{env};
    -
    -        env.fund(XRP(100'000), issuer, owner, charlie);
    -        env(fset(issuer, asfAllowTrustLineClawback));
    -        env.close();
    -
    -        PrettyAsset const asset = issuer["IOU"];
    -        env.trust(asset(1'000'000), owner);
    -        env.trust(asset(1'000'000), charlie);
    -        env(pay(issuer, owner, asset(100'000)));
    -        env.close();
    -
    -        auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -        env(tx);
    -        env.close();
    -
    -        env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(10)}));
    -        env.close();
    -
    -        auto runTests = [&]() {
    -            auto const fix330Enabled = env.current()->rules().enabled(fixCleanup3_3_0);
    -
    -            // Set an individual freeze on the owner's IOU trustline.
    -            env(trust(issuer, asset(0), owner, tfSetFreeze));
    -            env.close();
    -
    -            // Self-withdrawal: submitter == destination, so the submitter
    -            // freeze check is skipped.
    -            // Post-fix: tesSUCCESS.  Pre-fix: tecFROZEN.
    -            env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
    -                Ter(fix330Enabled ? TER(tesSUCCESS) : TER(tecFROZEN)));
    -
    -            // Withdrawal to a third party is blocked: submitter != destination
    -            // so the submitter freeze check applies.
    -            {
    -                auto withdrawToCharlie =
    -                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)});
    -                withdrawToCharlie[sfDestination] = charlie.human();
    -                // Post-fix: tecFROZEN (checkIndividualFrozen on submitter).
    -                // Pre-fix: tecLOCKED (isFrozen on the vault share).
    -                env(withdrawToCharlie, Ter(fix330Enabled ? TER(tecFROZEN) : TER(tecLOCKED)));
    -            }
    -
    -            env(trust(issuer, asset(0), owner, tfClearFreeze));
    -            env.close();
    -        };
    -
    -        runTests();
    -        env.disableFeature(fixCleanup3_3_0);
    -        runTests();
    -        env.enableFeature(fixCleanup3_3_0);
    -    }
    -
    -    void
    -    testVaultWithdrawFreezeIOU()
    -    {
    -        using namespace test::jtx;
    -        testcase("VaultWithdraw IOU freeze checks");
    -
    -        Account const issuer{"issuer"};
    -        Account const owner{"owner"};
    -        Env env{*this};
    -        Vault const vault{env};
    -
    -        env.fund(XRP(100'000), issuer, owner);
    -        env(fset(issuer, asfAllowTrustLineClawback));
    -        env.close();
    -        PrettyAsset const asset = issuer["IOU"];
    -        env.trust(asset(1'000'000), owner);
    -        env(pay(issuer, owner, asset(100'000)));
    -        env.close();
    -
    -        auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    -        env(tx);
    -        env.close();
    -        auto const vaultAcct = Account("vault", env.le(keylet)->at(sfAccount));
    -
    -        env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)}));
    -        env.close();
    -
    -        Account const charlie{"charlie"};
    -        env.fund(XRP(10'000), charlie);
    -        env.trust(asset(1'000'000), charlie);
    -        env.close();
    -
    -        auto runTests = [&]() {
    -            auto const fix330Enabled = env.current()->rules().enabled(fixCleanup3_3_0);
    -            // Global freeze → self-withdraw
    -            {
    -                testcase("VaultWithdraw IOU global freeze");
    -                env(fset(issuer, asfGlobalFreeze));
    -                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
    -                    Ter(tecFROZEN));
    -                // Global freeze → withdraw to 3rd party
    -
    -                auto withdrawToCharlie =
    -                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)});
    -                withdrawToCharlie[sfDestination] = charlie.human();
    -                env(withdrawToCharlie, Ter(tecFROZEN));
    -
    -                env(fclear(issuer, asfGlobalFreeze));
    -            }
    -
    -            // Vault-account freeze
    -            {
    -                testcase("VaultWithdraw IOU pseudo-account freeze");
    -                auto trustSet = [&]() {
    -                    json::Value jv;
    -                    jv[jss::Account] = issuer.human();
    -                    {
    -                        auto& ja = jv[jss::LimitAmount] =
    -                            asset(0).value().getJson(JsonOptions::Values::None);
    -                        ja[jss::issuer] = toBase58(vaultAcct.id());
    -                    }
    -                    jv[jss::TransactionType] = jss::TrustSet;
    -                    return jv;
    -                }();
    -
    -                trustSet[jss::Flags] = tfSetFreeze;
    -                env(trustSet);
    -                env.close();
    -
    -                TER const terExpected = fix330Enabled ? TER(tecFROZEN) : TER(tecLOCKED);
    -
    -                // Self-withdraw
    -                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
    -                    Ter(terExpected));
    -                // Withdraw to 3rd party
    -
    -                auto withdrawToCharlie =
    -                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)});
    -                withdrawToCharlie[sfDestination] = charlie.human();
    -                env(withdrawToCharlie, Ter(terExpected));
    -
    -                trustSet[jss::Flags] = tfClearFreeze;
    -                env(trustSet);
    -                env.close();
    -            }
    -
    -            // Depositor freeze, self-withdraw
    -            {
    -                testcase("VaultWithdraw IOU self-withdraw freeze check");
    -                env(trust(issuer, asset(0), owner, tfSetFreeze));
    -
    -                // Post-fix: self-withdraw allowed (submitter==dst skip)
    -                // Pre-fix: isFrozen(depositor, iou) catches it
    -                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
    -                    Ter(fix330Enabled ? TER(tesSUCCESS) : TER(tecFROZEN)));
    -
    -                // Depositor freeze withdraw to 3rd party
    -                auto withdrawTo3rd =
    -                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)});
    -                withdrawTo3rd[sfDestination] = charlie.human();
    -
    -                // Post-fix: submitter freeze blocks withdraw to 3rd party
    -                // Pre-fix: submitter's IOU freeze not checked, but checkFrozen(depositor,
    -                // share) triggers tecLOCKED
    -                env(withdrawTo3rd, Ter(fix330Enabled ? TER(tecFROZEN) : TER(tecLOCKED)));
    -
    -                env(trust(issuer, asset(0), owner, tfClearFreeze));
    -                // Replenish what was withdrawn
    -                if (fix330Enabled)
    -                {
    -                    env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}));
    -                }
    -                env.close();
    -            }
    -
    -            // Depositor deep freeze → self-withdraw blocked
    -            {
    -                testcase("VaultWithdraw IOU depositor deep freeze");
    -                env(trust(issuer, asset(0), owner, tfSetFreeze | tfSetDeepFreeze));
    -
    -                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
    -                    Ter(tecFROZEN));
    -
    -                env(trust(issuer, asset(0), owner, tfClearFreeze | tfClearDeepFreeze));
    -            }
    -
    -            // Destination freeze → withdraw to 3rd party
    -            {
    -                testcase("VaultWithdraw IOU freeze withdraw to 3rd party");
    -
    -                env(trust(issuer, asset(0), charlie, tfSetFreeze));
    -
    -                // Self-withdraw unaffected by charlie's freeze
    -                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)}));
    -
    -                auto withdrawToCharlie =
    -                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)});
    -                withdrawToCharlie[sfDestination] = charlie.human();
    -
    -                // Post-fix: freeze on dst allowed
    -                // Pre-fix: checkFrozen(dst, iou) catches it
    -                env(withdrawToCharlie, Ter(fix330Enabled ? TER(tesSUCCESS) : TER(tecFROZEN)));
    -
    -                env(trust(issuer, asset(0), charlie, tfClearFreeze));
    -
    -                // Replenish: 1 for self-withdraw + 1 if charlie withdraw succeeded
    -                env(vault.deposit(
    -                    {.depositor = owner,
    -                     .id = keylet.key,
    -                     .amount = asset(fix330Enabled ? 2 : 1)}));
    -                env.close();
    -            }
    -
    -            // Destination deep freeze → withdraw to 3rd party blocked
    -            {
    -                testcase("VaultWithdraw IOU deep freeze withdraw to 3rd party");
    -
    -                env(trust(issuer, asset(0), charlie, tfSetFreeze | tfSetDeepFreeze));
    -
    -                auto withdrawToCharlie =
    -                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)});
    -                withdrawToCharlie[sfDestination] = charlie.human();
    -                env(withdrawToCharlie, Ter(tecFROZEN));
    -
    -                // Destination deep freeze → self-withdraw unaffected
    -                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)}));
    -
    -                env(trust(issuer, asset(0), charlie, tfClearFreeze | tfClearDeepFreeze));
    -                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}));
    -                env.close();
    -            }
    -
    -            // Clawback works while frozen
    -            {
    -                testcase("VaultWithdraw IOU freeze clawback unaffected");
    -                env(fset(issuer, asfGlobalFreeze));
    -
    -                env(vault.clawback(
    -                    {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(1)}));
    -
    -                env(fclear(issuer, asfGlobalFreeze));
    -                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}));
    -                env.close();
    -            }
    -        };
    -
    -        runTests();
    -        env.disableFeature(fixCleanup3_3_0);
    -        runTests();
    -        env.enableFeature(fixCleanup3_3_0);
    -    }
    -
    -    void
    -    testVaultWithdrawFreezeMPT()
    -    {
    -        using namespace test::jtx;
    -        testcase("VaultWithdraw MPT lock checks");
    -
    -        Account const issuer{"issuer"};
    -        Account const owner{"owner"};
    -        Env env{*this};
    -        Vault vault{env};
    -
    -        env.fund(XRP(100'000), issuer, owner);
    -        env.close();
    -
    -        MPTTester mptt{env, issuer, kMptInitNoFund};
    -        mptt.create(
    -            {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock | tfMPTRequireAuth});
    -        PrettyAsset const mpt{mptt.issuanceID()};
    -
    -        mptt.authorize({.account = owner});
    -        mptt.authorize({.account = issuer, .holder = owner});
    -        env.close();
    -        env(pay(issuer, owner, mpt(100'000)));
    -        env.close();
    -
    -        auto [tx, keylet] = vault.create({.owner = owner, .asset = mpt});
    -        env(tx);
    -        env.close();
    -        Account const vaultAcct("vault", env.le(keylet)->at(sfAccount));
    -
    -        env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(100)}));
    -        env.close();
    -
    -        Account const charlie{"charlie"};
    -        env.fund(XRP(10'000), charlie);
    -        env.close();
    -        mptt.authorize({.account = charlie});
    -        mptt.authorize({.account = issuer, .holder = charlie});
    -        env.close();
    -
    -        auto runTests = [&]() {
    -            auto const fix330Enabled = env.current()->rules().enabled(fixCleanup3_3_0);
    -
    -            // Global lock
    -            {
    -                testcase("VaultWithdraw MPT global lock");
    -                mptt.set({.flags = tfMPTLock});
    -                env.close();
    -                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)}),
    -                    Ter(tecLOCKED));
    -
    -                // Global lock → withdraw to issuer
    -                // Post-fix: bypasses freeze checks, but accountHolds
    -                //           on the pseudo returns 0 under global lock
    -                // Pre-fix: checkFrozen(dst=issuer) catches global lock
    -                {
    -                    auto withdrawToIssuer =
    -                        vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)});
    -                    withdrawToIssuer[sfDestination] = issuer.human();
    -                    env(withdrawToIssuer, Ter(fix330Enabled ? TER(tesSUCCESS) : TER(tecLOCKED)));
    -                }
    -                mptt.set({.flags = tfMPTUnlock});
    -                env.close();
    -                if (fix330Enabled)
    -                {
    -                    env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}));
    -                }
    -                env.close();
    -            }
    -
    -            // Vault pseudo-account individual lock
    -            {
    -                testcase("VaultWithdraw MPT pseudo-account lock");
    -                mptt.set({.holder = vaultAcct, .flags = tfMPTLock});
    -                env.close();
    -                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)}),
    -                    Ter(tecLOCKED));
    -                mptt.set({.holder = vaultAcct, .flags = tfMPTUnlock});
    -                env.close();
    -            }
    -
    -            // Depositor individual lock → self-withdraw blocked
    -            // (isDeepFrozen == isFrozen for MPT)
    -            {
    -                testcase("VaultWithdraw MPT depositor lock");
    -                mptt.set({.holder = owner, .flags = tfMPTLock});
    -                env.close();
    -                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)}),
    -                    Ter(tecLOCKED));
    -                // Depositor lock → withdraw to 3rd party also blocked
    -                {
    -                    auto withdrawToCharlie =
    -                        vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)});
    -                    withdrawToCharlie[sfDestination] = charlie.human();
    -                    env(withdrawToCharlie, Ter(tecLOCKED));
    -                }
    -
    -                // Depositor lock → withdraw to issuer
    -                // Post-fix: issuer bypass in checkWithdrawFreezes
    -                // Pre-fix: checkFrozen(depositor, share) blocks transitively
    -                {
    -                    auto withdrawToIssuer =
    -                        vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)});
    -                    withdrawToIssuer[sfDestination] = issuer.human();
    -                    env(withdrawToIssuer, Ter(fix330Enabled ? TER(tesSUCCESS) : TER(tecLOCKED)));
    -                }
    -                mptt.set({.holder = owner, .flags = tfMPTUnlock});
    -                env.close();
    -                if (fix330Enabled)
    -                {
    -                    env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}));
    -                }
    -                env.close();
    -            }
    -
    -            // 3rd party destination lock → withdraw to 3rd party blocked
    -            {
    -                testcase("VaultWithdraw MPT 3rd party destination lock");
    -                mptt.set({.holder = charlie, .flags = tfMPTLock});
    -                env.close();
    -                {
    -                    auto withdrawToCharlie =
    -                        vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)});
    -                    withdrawToCharlie[sfDestination] = charlie.human();
    -                    env(withdrawToCharlie, Ter{tecLOCKED});
    -                }
    -                // 3rd party lock → self-withdraw unaffected
    -                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)}));
    -                mptt.set({.holder = charlie, .flags = tfMPTUnlock});
    -                env.close();
    -                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}));
    -                env.close();
    -            }
    -
    -            // Clawback works while locked
    -            {
    -                testcase("VaultWithdraw MPT lock clawback unaffected");
    -                mptt.set({.flags = tfMPTLock});
    -                env.close();
    -                env(vault.clawback(
    -                    {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = mpt(1)}));
    -                mptt.set({.flags = tfMPTUnlock});
    -                env.close();
    -                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}));
    -                env.close();
    -            }
    -        };
    -
    -        runTests();
    -        env.disableFeature(fixCleanup3_3_0);
    -        runTests();
    -        env.enableFeature(fixCleanup3_3_0);
    -    }
    -
    -public:
    -    void
    -    run() override
    -    {
    -        testVaultWithdrawEqualityEnforced();
    -        testBugIssuerVaultDepositAtEdge();
    -        testBugMakeDeltaPosteriorScale();
    -        testBugMakeDeltaAnteriorScale();
    -        testVaultDepositCanonicalizeToZero();
    -        testVaultWithdrawCanonicalizeToZero();
    -        testVaultDepositNegativeBalanceFromOppositeLimit();
    -        testSequences();
    -        testPreflight();
    -        testCreateFailXRP();
    -        testCreateFailIOU();
    -        testCreateFailMPT();
    -        testVaultCreateClosedEnded();
    -        testVaultCreateSubscriptionDateBoundary();
    -        testVaultPhaseDerivation();
    -        testVaultPhaseDerivationOpenEnded();
    -        testVaultDepositClosedEnded();
    -        testVaultWithdrawClosedEnded();
    -        testVaultClosedEndedLifecycle();
    -        testVaultLoanLatePaymentAfterInvestment();
    -        testVaultClosedEndedMultipleLoans();
    -        testVaultClawbackClosedEndedPhases();
    -        testWithMPT();
    -        testWithIOU();
    -        testWithDomainCheck();
    -        testDomainLossAfterAcquisition();
    -        testDomainCheckBuyerSideOffer();
    -        testWithDomainChecXRP();
    -        testNonTransferableShares();
    -        testFailedPseudoAccount();
    -        testScaleIOU();
    -        testRPC();
    -        testRPCClosedEnded();
    -        testVaultClawbackBurnShares();
    -        testVaultClawbackAssets();
    -        testVaultEscrowedMPT();
    -        testAssetsMaximum();
    -        testVaultDeleteMemoData();
    -        testVaultCreateLEVersion();
    -        testBug6LimitBypassWithShares();
    -        testRemoveEmptyHoldingLockedAmount();
    -        testRemoveEmptyHoldingConfidentialBalances();
    -
    -        testWithdrawSoleShareholderFixedAssetExit(all_ - fixCleanup3_2_0);
    -        testWithdrawSoleShareholderFixedAssetExit(all_);
    -        testWithdrawSoleShareholderFullSharesRejected(all_ - fixCleanup3_2_0);
    -        testWithdrawSoleShareholderFullSharesRejected(all_);
    -        testWithdrawSoleShareholderCleanVaultUnaffected(all_ - fixCleanup3_2_0);
    -        testWithdrawSoleShareholderCleanVaultUnaffected(all_);
    -        testWithdrawSoleShareholderPartialFixedSharesUsesFullPrice();
    -        testWithdrawSoleShareholderLoanRepaymentExit();
    -
    -        testVaultDepositFreezeIOU();
    -        testVaultDepositFreezeMPT();
    -        testVaultWithdrawFreezeIOU();
    -        testVaultWithdrawFreezeMPT();
    -        testVaultSelfWithdrawWhileFrozen();
    -
    -        testReferenceHolding();
    -        testHoldingDeletionBlocked();
    -    }
    -};
    -
    -BEAST_DEFINE_TESTSUITE_PRIO(Vault, app, xrpl, 1);
    -
    -}  // namespace xrpl
    diff --git a/src/test/app/lending/LendingHelpers_test.cpp b/src/test/app/lending/LendingHelpers_test.cpp
    index 5d67cdc3c5..909b617980 100644
    --- a/src/test/app/lending/LendingHelpers_test.cpp
    +++ b/src/test/app/lending/LendingHelpers_test.cpp
    @@ -409,7 +409,7 @@ class LendingHelpers_test : public beast::unit_test::Suite
             Env const env{*this};
             auto const& rules = env.current()->rules();
     
    -        // Inputs from the bug reproduction in Loan_test.cpp:
    +        // Inputs from the near-zero-rate LoanPay bug reproduction:
             //   InterestRate = 1 TenthBips32 (0.001 % per year),
             //   PaymentInterval = 600 s, principal = 100, 3 payments.
             // periodicRate is ~1.9e-10.
    diff --git a/src/test/app/lending/LoanTestBase.h b/src/test/app/lending/LoanTestBase.h
    index 950b196043..b3669742fe 100644
    --- a/src/test/app/lending/LoanTestBase.h
    +++ b/src/test/app/lending/LoanTestBase.h
    @@ -67,6 +67,16 @@
     
     namespace xrpl::test {
     
    +/**
    + * Shared base for the Loan*_test family under src/test/app/lending/.
    + *
    + * Run all suites in this family with
    + *   xrpld -u Loan,LendingHelpers
    + * The "Loan" prefix is matched against every suite name via
    + * beast::unit_test::Selector::ModeT::Automatch; LendingHelpers is listed
    + * explicitly because it does not share the "Loan" prefix (and lives in a
    + * different module: app vs tx).
    + */
     class LoanTestBase : public beast::unit_test::Suite
     {
     protected:
    diff --git a/src/test/app/lending/Loan_test.cpp b/src/test/app/lending/Loan_test.cpp
    deleted file mode 100644
    index 717387665e..0000000000
    --- a/src/test/app/lending/Loan_test.cpp
    +++ /dev/null
    @@ -1,46 +0,0 @@
    -#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
    diff --git a/src/test/app/vault/VaultBugs_test.cpp b/src/test/app/vault/VaultBugs_test.cpp
    new file mode 100644
    index 0000000000..a7071f3767
    --- /dev/null
    +++ b/src/test/app/vault/VaultBugs_test.cpp
    @@ -0,0 +1,716 @@
    +#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 {
    +
    +class VaultBugs_test : public VaultTestBase
    +{
    +private:
    +    // Bug: the equality check (vault outflow == destination inflow) was
    +    // skipped whenever the destination delta rounded to zero at localMinScale,
    +    // including cases where the vault outflow rounded to a non-zero value and
    +    // a representable amount of value was genuinely destroyed.
    +    //
    +    // Scenario: Bob's IOU balance sits 5 units below the 10^16 STAmount
    +    // precision boundary (atEdge2 = 9,999,999,999,999,995).  A withdrawal of
    +    // 6 USD shifts his balance across that boundary: the exponent increments
    +    // (0 → 1), so his effective inflow in Number space is only +5 — 1 USD is
    +    // consumed by the precision-boundary rounding and cannot be credited.
    +    //
    +    // The destroyed amount (1 USD) is sub-ULP at destinationScale=1 (step=10),
    +    // so the check treats it as an unavoidable IOU-precision artefact and
    +    // lets the transaction succeed.
    +    //
    +    // Contrast: if 15 USD were destroyed at the same scale (destroyed ≥ step),
    +    // floor(15/10)=1 ≠ 0 and the invariant would fire — that discrepancy IS
    +    // representable and indicates a real accounting bug.
    +    //
    +    // Pre-fixCleanup3_2_0: the "must increase destination balance" check fires
    +    // because roundedDestinationDelta = 0 ≤ 0.
    +    void
    +    testVaultWithdrawEqualityEnforced()
    +    {
    +        using namespace test::jtx;
    +
    +        auto runScenario = [this](FeatureBitset features, TER expected) {
    +            std::string logs;
    +            Env env(*this, features, std::make_unique(&logs));
    +
    +            Account const issuer{"issuer"};
    +            Account const alice{"alice"};
    +            Account const bob{"bob"};
    +
    +            env.fund(XRP(100'000), issuer, alice, bob);
    +            env.close();
    +            env(fset(issuer, asfDefaultRipple));
    +            env.close();
    +
    +            PrettyAsset const usd{issuer["USD"]};
    +            STAmount const aliceLimit{usd.raw(), 2, 16};
    +            STAmount const bobLimit{usd.raw(), 2, 16};
    +            // Bob's balance sits 5 units below the 10^16 STAmount precision
    +            // boundary.  Receiving 6 USD shifts his exponent 0 → 1; the
    +            // STAmount records +5, not +6 (1 USD is lost to rounding).
    +            STAmount const atEdge2{usd.raw(), Number{9'999'999'999'999'995LL}};
    +
    +            env(trust(alice, aliceLimit));
    +            env(trust(bob, bobLimit));
    +            env.close();
    +
    +            env(pay(issuer, alice, usd(1'000)));
    +            env(pay(issuer, bob, atEdge2));
    +            env.close();
    +
    +            Vault const vault{env};
    +            auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = usd});
    +            vaultTx[sfScale] = 0;
    +            env(vaultTx);
    +            env.close();
    +
    +            env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = usd(1'000)}));
    +            env.close();
    +
    +            // Withdraw 6 USD to Bob: vault loses 6, Bob gains only 5.
    +            // Destroyed amount = 1 USD, which is sub-ULP at destinationScale=1.
    +            auto tx = vault.withdraw({.depositor = alice, .id = vaultKeylet.key, .amount = usd(6)});
    +            tx[sfDestination] = bob.human();
    +            env(tx, Ter(expected));
    +            env.close();
    +        };
    +
    +        {
    +            testcase(
    +                "bug: VaultWithdraw to destination at IOU precision boundary fires "
    +                "invariant (pre-fixCleanup3_2_0)");
    +            runScenario(testableAmendments() - fixCleanup3_2_0, tecINVARIANT_FAILED);
    +        }
    +        {
    +            testcase(
    +                "bug: VaultWithdraw to destination at IOU precision boundary succeeds "
    +                "when destroyed amount is sub-ULP (post-fixCleanup3_2_0)");
    +            runScenario(testableAmendments(), tesSUCCESS);
    +        }
    +    }
    +
    +    // VaultDeposit by issuer with the vault parked at the IOU 16-digit
    +    // edge (9.999e15). Issuer mints 2 more USD; the vault trust line
    +    // goes 9.999e15 → 10^16, gaining 1 unit instead of 2 (canonicalization).
    +    //
    +    // Pre-fixCleanup3_2_0: the proactive check is absent; the deposit
    +    // applies, then VaultInvariant's "deposit must increase vault
    +    // balance" assertion fires at finalize time on the rounded vault
    +    // delta of zero, returning tecINVARIANT_FAILED.
    +    // Post-amendment: reject deposit that is not representable at Vault scale.
    +    void
    +    testBugIssuerVaultDepositAtEdge()
    +    {
    +        using namespace test::jtx;
    +
    +        auto runScenario = [this](FeatureBitset features, TER expected) {
    +            std::string logs;
    +            Env env(*this, features, std::make_unique(&logs));
    +
    +            Account const issuer{"issuer"};
    +            Account const owner{"owner"};
    +
    +            env.fund(XRP(100'000), issuer, owner);
    +            env.close();
    +            env(fset(issuer, asfDefaultRipple));
    +            env.close();
    +
    +            PrettyAsset const usd{issuer["USD"]};
    +            STAmount const trustLimit{usd.raw(), 2, 16};
    +            STAmount const ownerFund{usd.raw(), Number{9'999'999'999'999'999LL}};
    +
    +            env(trust(owner, trustLimit));
    +            env.close();
    +            env(pay(issuer, owner, ownerFund));
    +            env.close();
    +
    +            Vault const vault{env};
    +            auto [vaultTx, vaultKeylet] = vault.create({.owner = owner, .asset = usd});
    +            vaultTx[sfScale] = 0;
    +            env(vaultTx);
    +            env.close();
    +            env(vault.deposit({.depositor = owner, .id = vaultKeylet.key, .amount = ownerFund}));
    +            env.close();
    +
    +            // Vault pseudo-account is now at 9.999e15. Issuer mints 2
    +            // more USD. Pre: tecINVARIANT_FAILED at finalize. Post:
    +            // tecPRECISION_LOSS proactively. Either way, no value moves.
    +            env(vault.deposit({.depositor = issuer, .id = vaultKeylet.key, .amount = usd(2)}),
    +                Ter(expected));
    +            env.close();
    +        };
    +
    +        {
    +            testcase(
    +                "bug: VaultDeposit by issuer at IOU edge fires "
    +                "tecINVARIANT_FAILED at finalize (pre-fixCleanup3_2_0)");
    +            runScenario(testableAmendments() - fixCleanup3_2_0, tecINVARIANT_FAILED);
    +        }
    +        {
    +            testcase(
    +                "bug: VaultDeposit by issuer at IOU edge rejects with "
    +                "tecPRECISION_LOSS proactively (post-fixCleanup3_2_0)");
    +            runScenario(testableAmendments(), tecPRECISION_LOSS);
    +        }
    +    }
    +
    +    // Bug: DeltaInfo::makeDelta uses max(scale(after), scale(before)) for
    +    // sfAssetsTotal/Available deltas.  This is symmetric to
    +    // testBugMakeDeltaAnteriorScale but in the opposite direction: a deposit
    +    // pushes assetsTotal from just below 1e16 (IOU exponent 0, ULP = 1) to just
    +    // above it (exponent 1, ULP = 10).  makeDelta picks the coarser *posterior*
    +    // scale 1.  The trust line balance rounds from atEdge + 2 = 10,000,000,000,000,001
    +    // → 1e16, so the pseudo-account delta is only +1 in IOU space.
    +    // roundToAsset(+1, scale=1) = 0 fires "deposit must increase vault balance"
    +    // even though the state change is consistent at every precision boundary.
    +    //
    +    // Fix (fixCleanup3_2_0): computeVaultMinScale uses the posterior Number-space
    +    // scale of sfAssetsTotal (which retains the full value 10,000,000,000,000,001,
    +    // exponent 0), giving minScale = 0.  roundToAsset(+1, scale=0) = 1 > 0 and
    +    // the invariant passes.  However the transactor's own precision guard fires
    +    // first (bob pays 2 USD, vault receives only 1 due to IOU rounding), so the
    +    // post-amendment result is tecPRECISION_LOSS rather than tesSUCCESS —
    +    // the depositor is protected from silently losing 1 USD to rounding.
    +    void
    +    testBugMakeDeltaPosteriorScale()
    +    {
    +        using namespace test::jtx;
    +
    +        auto runScenario = [this](FeatureBitset features, TER expected) {
    +            std::string logs;
    +            Env env(*this, features, std::make_unique(&logs));
    +
    +            Account const issuer{"issuer"};
    +            Account const alice{"alice"};
    +            Account const bob{"bob"};
    +
    +            env.fund(XRP(100'000), issuer, alice, bob);
    +            env.close();
    +            env(fset(issuer, asfDefaultRipple));
    +            env.close();
    +
    +            PrettyAsset const usd{issuer["USD"]};
    +            // atEdge is the largest IOU value with exponent 0 (ULP = 1).
    +            // A deposit of 2 USD brings assetsTotal to 10,000,000,000,000,001
    +            // in Number space, crossing the 1e16 boundary in IOU space.
    +            STAmount const atEdge{usd.raw(), Number{9'999'999'999'999'999LL}};
    +
    +            env(trust(alice, STAmount{usd.raw(), 2, 16}));
    +            env(trust(bob, usd(100)));
    +            env.close();
    +            env(pay(issuer, alice, atEdge));
    +            env(pay(issuer, bob, usd(2)));
    +            env.close();
    +
    +            Vault const vault{env};
    +            auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = usd});
    +            vaultTx[sfScale] = 0;
    +            env(vaultTx);
    +            env.close();
    +
    +            // sfAssetsTotal = sfAssetsAvailable = atEdge (exponent 0, ULP = 1)
    +            env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = atEdge}));
    +            env.close();
    +
    +            // Deposit 2 USD: +2 is sub-ULP at the posterior IOU scale (ULP = 10)
    +            // but exact at the Number scale retained by sfAssetsTotal.
    +            env(vault.deposit({.depositor = bob, .id = vaultKeylet.key, .amount = usd(2)}),
    +                Ter(expected));
    +            env.close();
    +        };
    +
    +        {
    +            testcase(
    +                "bug: VaultDeposit across IOU scale boundary fires invariant "
    +                "(pre-fixCleanup3_2_0)");
    +            runScenario(testableAmendments() - fixCleanup3_2_0, tecINVARIANT_FAILED);
    +        }
    +        {
    +            testcase(
    +                "bug: VaultDeposit across IOU scale boundary succeeds "
    +                "(post-fixCleanup3_2_0)");
    +            runScenario(testableAmendments(), tecPRECISION_LOSS);
    +        }
    +    }
    +
    +    // Bug: DeltaInfo::makeDelta uses max(scale(after), scale(before)) for the
    +    // sfAssetsTotal and sfAssetsAvailable deltas, and visitEntry applies the
    +    // same max() for the vault pseudo-account RippleState.  When
    +    // sfAssetsTotal sits exactly at 1e16 (IOU exponent 1, ULP = 10) and a
    +    // withdrawal of 5 USD brings it to 9.999...995e15 (IOU exponent 0,
    +    // ULP = 1), all three computations pick the anterior coarser scale 1.
    +    // roundToAsset(-5, scale=1) collapses to 0, so the invariant check
    +    // vaultPseudoDeltaAssets >= kZero fires even though the state change is
    +    // valid and fully consistent at IOU precision.
    +    //
    +    // Fix (fixCleanup3_2_0): finalize compares the vault pseudo-account and
    +    // sfAssetsTotal/Available deltas directly in Number space, bypassing
    +    // scale-coarsened rounding.
    +    void
    +    testBugMakeDeltaAnteriorScale()
    +    {
    +        using namespace test::jtx;
    +
    +        auto runScenario = [this](FeatureBitset features, TER expected) {
    +            std::string logs;
    +            Env env(*this, features, std::make_unique(&logs));
    +
    +            Account const issuer{"issuer"};
    +            Account const alice{"alice"};
    +
    +            env.fund(XRP(100'000), issuer, alice);
    +            env.close();
    +            env(fset(issuer, asfDefaultRipple));
    +            env.close();
    +
    +            PrettyAsset const usd{issuer["USD"]};
    +            // Trust limit of 2e16, fund exactly 1e16 so deposit lands at the
    +            // IOU scale-1 boundary (exponent 1, ULP = 10).
    +            STAmount const fundAndDeposit{usd.raw(), Number{1, 16}};
    +
    +            env(trust(alice, STAmount{usd.raw(), 2, 16}));
    +            env.close();
    +            env(pay(issuer, alice, fundAndDeposit));
    +            env.close();
    +
    +            Vault const vault{env};
    +            auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = usd});
    +            vaultTx[sfScale] = 0;
    +            env(vaultTx);
    +            env.close();
    +
    +            // sfAssetsTotal = sfAssetsAvailable = 1e16 (exponent 1, ULP = 10).
    +            env(vault.deposit(
    +                {.depositor = alice, .id = vaultKeylet.key, .amount = fundAndDeposit}));
    +            env.close();
    +
    +            // Withdraw 5 USD: -5 is sub-ULP at the anterior scale (ULP = 10)
    +            // but exact at the posterior scale (ULP = 1).  The state change is
    +            // consistent; only the invariant's scale selection is wrong.
    +            env(vault.withdraw({.depositor = alice, .id = vaultKeylet.key, .amount = usd(5)}),
    +                Ter(expected));
    +            env.close();
    +        };
    +
    +        {
    +            testcase(
    +                "bug: VaultWithdraw across IOU scale boundary fires invariant "
    +                "(pre-fixCleanup3_2_0)");
    +            runScenario(testableAmendments() - fixCleanup3_2_0, tecINVARIANT_FAILED);
    +        }
    +        {
    +            testcase(
    +                "bug: VaultWithdraw across IOU scale boundary succeeds "
    +                "(post-fixCleanup3_2_0)");
    +            runScenario(testableAmendments(), tesSUCCESS);
    +        }
    +    }
    +
    +    // Bug: when a depositor's IOU trustline balance is very large (e.g.
    +    // ~1e17), adding a small deposit (e.g. 1 USD) leaves sfAssetsTotal
    +    // unchanged at IOU precision because the increment is sub-ULP at the
    +    // vault's current asset scale.  The vault records the deposit, mints
    +    // shares, and decrements the depositor's trustline, but sfAssetsTotal
    +    // does not change — the conservation invariant fires because the rail
    +    // delta is zero.
    +    //
    +    // Two sub-cases are exercised:
    +    //   1. First-ever deposit into an empty vault: the depositor's own
    +    //      trustline has a large balance so 1 USD canonicalizes to zero
    +    //      when written back through the IOU rail.
    +    //   2. Subsequent deposit after the vault already holds a large
    +    //      sfAssetsTotal: a different depositor (bob, with a small balance)
    +    //      sends 1 USD, which again rounds to zero at the vault's coarse
    +    //      asset scale.
    +    //
    +    // Fix (fixCleanup3_2_0): the deposit transactor checks whether
    +    // roundToAsset(amount, vault_scale) == 0 and rejects early with
    +    // tecPRECISION_LOSS before any state is modified.
    +    void
    +    testVaultDepositCanonicalizeToZero()
    +    {
    +        using namespace test::jtx;
    +        auto runScenario = [this](FeatureBitset features, TER expected) {
    +            std::string logs;
    +            Env env(*this, features, std::make_unique(&logs));
    +
    +            Account const issuer{"issuer"};
    +            Account const alice{"alice"};
    +            Account const bob{"bob"};
    +
    +            env.fund(XRP(100'000), issuer, alice, bob);
    +            env.close();
    +
    +            env(fset(issuer, asfDefaultRipple));
    +            env.close();
    +
    +            PrettyAsset const usd{issuer["USD"]};
    +
    +            STAmount const trustLimit{usd.raw(), Number{99'999'999'999'999'999LL}};
    +            STAmount const aliceFund{usd.raw(), Number{99'999'999'999'999'999LL}};
    +
    +            env(trust(alice, trustLimit));
    +            env(trust(bob, trustLimit));
    +            env.close();
    +
    +            env(pay(issuer, alice, aliceFund));
    +            env(pay(issuer, bob, usd(1000)));
    +            env.close();
    +
    +            Vault const vault{env};
    +
    +            // Scale=0 so sfAssetsTotal stores whole USD
    +            auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = usd});
    +            vaultTx[sfScale] = 0;
    +            env(vaultTx);
    +            env.close();
    +
    +            // Alice's deposit canonicalizes to zero at her own trustline scale
    +            env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = usd(1)}),
    +                Ter(expected));
    +
    +            // Increase vault-scale
    +            env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = aliceFund}));
    +            env.close();
    +
    +            env(vault.deposit({.depositor = bob, .id = vaultKeylet.key, .amount = usd(1)}),
    +                Ter(expected));
    +            env.close();
    +        };
    +
    +        {
    +            testcase(
    +                "bug: VaultDeposit below Vault precision canonicalized to zero "
    +                "(pre-fixCleanup3_2_0)");
    +            runScenario(testableAmendments() - fixCleanup3_2_0, tecINVARIANT_FAILED);
    +        }
    +        {
    +            testcase(
    +                "bug: VaultDeposit below Vault precision canonicalized to zero "
    +                "(post-fixCleanup3_2_0)");
    +            runScenario(testableAmendments(), tecPRECISION_LOSS);
    +        }
    +    }
    +
    +    // Bug: ValidVault::visitEntry computes destinationDelta.scale as
    +    // max(before_exponent, after_exponent) for RippleState entries.  When a
    +    // withdrawal credits a destination whose IOU balance sits just below a
    +    // power-of-10 boundary (atEdge = 9'999'999'999'999'999), the post-credit
    +    // STAmount rounds up one exponent (exponent 0 → 1), making
    +    // destinationDelta.scale = 1.  The invariant then calls
    +    // roundToAsset(+2 USD, scale=1) = 0 and incorrectly fires
    +    // "withdrawal must increase destination balance".
    +    //
    +    // Fix (fixCleanup3_2_0): finalize compares destination delta directly in
    +    // Number space, bypassing scale-coarsened rounding.  The transaction
    +    // itself succeeds because the effective IOU credit is non-trivial at
    +    // Number precision even though the STAmount exponent shifted.
    +    void
    +    testVaultWithdrawCanonicalizeToZero()
    +    {
    +        using namespace test::jtx;
    +
    +        enum class DestKind : bool { ThirdParty = false, Self = true };
    +
    +        auto runScenario = [this](FeatureBitset features, DestKind destKind, TER expected) {
    +            std::string logs;
    +            Env env(*this, features, std::make_unique(&logs));
    +
    +            Account const issuer{"issuer"};
    +            Account const alice{"alice"};
    +            Account const bob{"bob"};
    +
    +            env.fund(XRP(100'000), issuer, alice, bob);
    +            env.close();
    +            env(fset(issuer, asfDefaultRipple));
    +            env.close();
    +
    +            PrettyAsset const usd{issuer["USD"]};
    +            STAmount const aliceLimit{usd.raw(), 2, 16};
    +            STAmount const bobLimit{usd.raw(), 2, 16};
    +            STAmount const atEdge{usd.raw(), Number{9'999'999'999'999'999LL}};
    +
    +            env(trust(alice, aliceLimit));
    +            if (destKind == DestKind::ThirdParty)
    +                env(trust(bob, bobLimit));
    +            env.close();
    +
    +            env(pay(issuer, alice, usd(1'000)));
    +            if (destKind == DestKind::ThirdParty)
    +                env(pay(issuer, bob, atEdge));
    +            env.close();
    +
    +            Vault const vault{env};
    +            auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = usd});
    +            vaultTx[sfScale] = 0;
    +            env(vaultTx);
    +            env.close();
    +
    +            env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = usd(1'000)}));
    +            env.close();
    +
    +            // For the self-destination case, push alice's own trust line to
    +            // the IOU edge so the next withdraw inflow crosses the boundary.
    +            if (destKind == DestKind::Self)
    +            {
    +                env(pay(issuer, alice, atEdge));
    +                env.close();
    +            }
    +
    +            auto tx = vault.withdraw({.depositor = alice, .id = vaultKeylet.key, .amount = usd(2)});
    +            if (destKind == DestKind::ThirdParty)
    +                tx[sfDestination] = bob.human();
    +            env(tx, Ter(expected));
    +            env.close();
    +        };
    +
    +        {
    +            testcase(
    +                "bug: VaultWithdraw to third-party at IOU edge fires invariant "
    +                "(pre-fixCleanup3_2_0)");
    +            runScenario(
    +                testableAmendments() - fixCleanup3_2_0, DestKind::ThirdParty, tecINVARIANT_FAILED);
    +        }
    +        {
    +            testcase(
    +                "bug: VaultWithdraw to third-party at IOU edge succeeds "
    +                "(post-fixCleanup3_2_0)");
    +            runScenario(testableAmendments(), DestKind::ThirdParty, tesSUCCESS);
    +        }
    +        {
    +            testcase(
    +                "bug: VaultWithdraw to self at IOU edge fires invariant "
    +                "(pre-fixCleanup3_2_0)");
    +            runScenario(
    +                testableAmendments() - fixCleanup3_2_0, DestKind::Self, tecINVARIANT_FAILED);
    +        }
    +        {
    +            testcase(
    +                "bug: VaultWithdraw to self at IOU edge succeeds "
    +                "(post-fixCleanup3_2_0)");
    +            runScenario(testableAmendments(), DestKind::Self, tesSUCCESS);
    +        }
    +    }
    +
    +    // VaultDeposit::preclaim uses accountHolds(..., SpendableHandling::
    +    // shFULL_BALANCE), which for an IOU asset adds the counterparty's
    +    // LowLimit/HighLimit to the depositor's raw balance (TokenHelpers.cpp:
    +    // getTrustLineBalance with includeOppositeLimit=true). When the
    +    // depositor's raw balance < deposit amount but raw + opposite limit >=
    +    // amount, preclaim is satisfied. doApply then calls
    +    // directSendNoFeeIOU, which unconditionally subtracts saAmount from
    +    // saBalance — driving the trust line negative — and returns tesSUCCESS.
    +    // The post-send sanity check uses the default shSIMPLE_BALANCE (no
    +    // opposite-limit add), sees a negative balance, and returns tefINTERNAL.
    +    void
    +    testVaultDepositNegativeBalanceFromOppositeLimit()
    +    {
    +        auto runTest = [&](FeatureBitset f, TER expected) {
    +            using namespace test::jtx;
    +            using namespace std::literals;
    +
    +            Env env{*this, f};
    +            Account const gw{"gateway"};
    +            Account const owner{"owner"};
    +            Account const depositor{"depositor"};
    +
    +            env.fund(XRP(10000), gw, owner, depositor);
    +            env.close();
    +
    +            // Gateway with DefaultRipple so vault creation on its IOU works.
    +            env(fset(gw, asfDefaultRipple));
    +            env.close();
    +
    +            // Depositor opens a trust line to gateway and receives a small
    +            // balance.
    +            PrettyAsset const usd = gw["USD"];
    +            env.trust(usd(1000), depositor);
    +            env(pay(gw, depositor, usd(100)));  // raw trust-line balance: 100
    +            env.close();
    +
    +            // Key precondition: gateway sets a non-zero limit on the same
    +            // RippleState — the "opposite field" from depositor's perspective.
    +            // This is what inflates shFULL_BALANCE in preclaim above the raw
    +            // balance.
    +            env(trust(gw, depositor["USD"](1000)));
    +            env.close();
    +
    +            // Create the IOU vault.
    +            Vault const vault{env};
    +            auto [vaultTx, keylet] = vault.create({.owner = owner, .asset = usd});
    +            env(vaultTx);
    +            env.close();
    +
    +            // Submit a deposit of 500 USD:
    +            //   - raw balance:                100 USD
    +            //   - opposite limit (gw's side): 1000 USD
    +            //   - preclaim sees 100 + 1000 = 1100, passes (>= 500)
    +            //   - doApply transfers 500, depositor's trust-line balance
    +            //     becomes -400
    +            //   - sanity check at VaultDeposit.cpp:256 fires
    +            //   - tx returns tefINTERNAL (BUG — should be tesSUCCESS.
    +            auto depositTx =
    +                vault.deposit({.depositor = depositor, .id = keylet.key, .amount = usd(500)});
    +            env(depositTx, Ter(expected));
    +            env.close();
    +        };
    +
    +        {
    +            testcase(
    +                "IOU vault deposit exceeding depositor's balance but "
    +                "within counterparty's trust limit, pre-fixCleanup3_2_0 "
    +                "(tefINTERNAL)");
    +            runTest(test::jtx::testableAmendments() - fixCleanup3_2_0, tefINTERNAL);
    +        }
    +        {
    +            testcase(
    +                "IOU vault deposit exceeding depositor's balance but "
    +                "within counterparty's trust limit, post-fixCleanup3_2_0 "
    +                "(tesSUCCESS)");
    +            runTest(test::jtx::testableAmendments(), tesSUCCESS);
    +        }
    +    }
    +
    +    // Reproduction: canWithdraw IOU limit check bypassed when
    +    // withdrawal amount is specified in shares (MPT) rather than in assets.
    +    void
    +    testBug6LimitBypassWithShares()
    +    {
    +        using namespace test::jtx;
    +        testcase("Bug6 - limit bypass with share-denominated withdrawal");
    +
    +        auto const allAmendments = testableAmendments() | featureSingleAssetVault;
    +
    +        for (auto const& features : {allAmendments, allAmendments - fixCleanup3_1_3})
    +        {
    +            bool const withFix = features[fixCleanup3_1_3];
    +
    +            Env env{*this, features};
    +            Account const owner{"owner"};
    +            Account const issuer{"issuer"};
    +            Account const depositor{"depositor"};
    +            Account const charlie{"charlie"};
    +            Vault const vault{env};
    +
    +            env.fund(XRP(1000), issuer, owner, depositor, charlie);
    +            env(fset(issuer, asfAllowTrustLineClawback));
    +            env.close();
    +
    +            PrettyAsset const asset = issuer["IOU"];
    +            env.trust(asset(1000), owner);
    +            env.trust(asset(1000), depositor);
    +            env(pay(issuer, owner, asset(200)));
    +            env(pay(issuer, depositor, asset(200)));
    +            env.close();
    +
    +            // Charlie gets a LOW trustline limit of 5
    +            env.trust(asset(5), charlie);
    +            env.close();
    +
    +            auto const [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +            env(tx);
    +            env.close();
    +
    +            auto const depositTx =
    +                vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)});
    +            env(depositTx);
    +            env.close();
    +
    +            // Get the share MPT info
    +            auto const vaultSle = env.le(keylet);
    +            if (!BEAST_EXPECT(vaultSle))
    +                return;
    +            auto const mptIssuanceID = vaultSle->at(sfShareMPTID);
    +            MPTIssue const shares(mptIssuanceID);
    +            PrettyAsset const share(shares);
    +
    +            // CONTROL: Withdraw 10 IOU (asset-denominated) to charlie.
    +            // Charlie's limit is 5, so this should be rejected with tecNO_LINE
    +            // regardless of the amendment.
    +            {
    +                auto withdrawTx =
    +                    vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(10)});
    +                withdrawTx[sfDestination] = charlie.human();
    +                env(withdrawTx, Ter{tecNO_LINE});
    +                env.close();
    +            }
    +            auto const charlieBalanceBefore = env.balance(charlie, asset.raw().get());
    +
    +            // Withdraw the equivalent amount in shares to charlie.
    +            // Post-fix: rejected (tecNO_LINE) because the share amount is
    +            //   converted to assets and the trustline limit is checked.
    +            // Pre-fix: succeeds (tesSUCCESS) because the limit check was
    +            //   skipped for share-denominated withdrawals.
    +            {
    +                auto withdrawTx = vault.withdraw(
    +                    {.depositor = depositor,
    +                     .id = keylet.key,
    +                     .amount = STAmount(share, 10'000'000)});
    +                withdrawTx[sfDestination] = charlie.human();
    +                env(withdrawTx, Ter{withFix ? TER{tecNO_LINE} : TER{tesSUCCESS}});
    +                env.close();
    +
    +                auto const charlieBalanceAfter = env.balance(charlie, asset.raw().get());
    +                if (withFix)
    +                {
    +                    // Post-fix: charlie's balance is unchanged — the withdrawal
    +                    // was correctly rejected despite being share-denominated.
    +                    BEAST_EXPECT(charlieBalanceAfter == charlieBalanceBefore);
    +                }
    +                else
    +                {
    +                    // Pre-fix: charlie received the assets, bypassing the
    +                    // trustline limit.
    +                    BEAST_EXPECT(charlieBalanceAfter > charlieBalanceBefore);
    +                }
    +            }
    +        }
    +    }
    +
    +public:
    +    void
    +    run() override
    +    {
    +        testVaultWithdrawEqualityEnforced();
    +        testBugIssuerVaultDepositAtEdge();
    +        testBugMakeDeltaPosteriorScale();
    +        testBugMakeDeltaAnteriorScale();
    +        testVaultDepositCanonicalizeToZero();
    +        testVaultWithdrawCanonicalizeToZero();
    +        testVaultDepositNegativeBalanceFromOppositeLimit();
    +        testBug6LimitBypassWithShares();
    +    }
    +};
    +
    +BEAST_DEFINE_TESTSUITE(VaultBugs, app, xrpl);
    +
    +}  // namespace xrpl
    diff --git a/src/test/app/vault/VaultClawback_test.cpp b/src/test/app/vault/VaultClawback_test.cpp
    new file mode 100644
    index 0000000000..2a9fe42b1c
    --- /dev/null
    +++ b/src/test/app/vault/VaultClawback_test.cpp
    @@ -0,0 +1,1122 @@
    +#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 {
    +
    +class VaultClawback_test : public VaultTestBase
    +{
    +private:
    +    void
    +    testVaultClawbackBurnShares()
    +    {
    +        using namespace test::jtx;
    +        using namespace loan_broker;
    +        using namespace loan;
    +        Env env(*this, beast::Severity::Warning);
    +
    +        auto const vaultAssetBalance = [&](Keylet const& vaultKeylet) {
    +            auto const sleVault = env.le(vaultKeylet);
    +            BEAST_EXPECT(sleVault != nullptr);
    +
    +            return std::make_pair(sleVault->at(sfAssetsAvailable), sleVault->at(sfAssetsTotal));
    +        };
    +
    +        auto const vaultShareBalance = [&](Keylet const& vaultKeylet) {
    +            auto const sleVault = env.le(vaultKeylet);
    +            BEAST_EXPECT(sleVault != nullptr);
    +
    +            auto const sleIssuance = env.le(keylet::mptokenIssuance(sleVault->at(sfShareMPTID)));
    +            BEAST_EXPECT(sleIssuance != nullptr);
    +
    +            return sleIssuance->at(sfOutstandingAmount);
    +        };
    +
    +        auto const setupVault = [&](PrettyAsset const& asset,
    +                                    Account const& owner,
    +                                    Account const& depositor) -> std::pair {
    +            Vault const vault{env};
    +
    +            auto const& [tx, vaultKeylet] = vault.create({.owner = owner, .asset = asset});
    +            env(tx, Ter(tesSUCCESS));
    +            env.close();
    +
    +            auto const& vaultSle = env.le(vaultKeylet);
    +            BEAST_EXPECT(vaultSle != nullptr);
    +
    +            Asset const share = vaultSle->at(sfShareMPTID);
    +
    +            env(vault.deposit(
    +                    {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}),
    +                Ter(tesSUCCESS));
    +            env.close();
    +
    +            auto const& [availablePreDefault, totalPreDefault] = vaultAssetBalance(vaultKeylet);
    +            BEAST_EXPECT(availablePreDefault == totalPreDefault);
    +            BEAST_EXPECT(availablePreDefault == asset(100).value());
    +
    +            // attempt to clawback shares while there are assets fails
    +            env(vault.clawback(
    +                    {.issuer = owner,
    +                     .id = vaultKeylet.key,
    +                     .holder = depositor,
    +                     .amount = share(0).value()}),
    +                Ter(tecNO_PERMISSION));
    +            env.close();
    +
    +            auto const& sharesAvailable = vaultShareBalance(vaultKeylet);
    +            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, SeqProxy::rawSequence(1));
    +
    +            // Create a simple Loan for the full amount of Vault assets
    +            env(set(depositor, brokerKeylet.key, asset(100).value()),
    +                loan::kInterestRate(TenthBips32(0)),
    +                kGracePeriod(60),
    +                kPaymentInterval(120),
    +                kPaymentTotal(10),
    +                Sig(sfCounterpartySignature, owner),
    +                Fee(env.current()->fees().base * 2),
    +                Ter(tesSUCCESS));
    +            env.close();
    +
    +            // attempt to clawback shares while there assetsAvailable == 0 and
    +            // assetsTotal > 0 fails
    +            env(vault.clawback(
    +                    {.issuer = owner,
    +                     .id = vaultKeylet.key,
    +                     .holder = depositor,
    +                     .amount = share(0).value()}),
    +                Ter(tecNO_PERMISSION));
    +            env.close();
    +
    +            env.close(std::chrono::seconds{120 + 60});
    +
    +            env(manage(owner, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS));
    +
    +            auto const& [availablePostDefault, totalPostDefault] = vaultAssetBalance(vaultKeylet);
    +
    +            BEAST_EXPECT(availablePostDefault == totalPostDefault);
    +            BEAST_EXPECT(availablePostDefault == asset(0).value());
    +            BEAST_EXPECT(vaultShareBalance(vaultKeylet) == sharesAvailable);
    +
    +            return std::make_pair(vault, vaultKeylet);
    +        };
    +
    +        auto const testCase = [&](PrettyAsset const& asset,
    +                                  std::string const& prefix,
    +                                  Account const& owner,
    +                                  Account const& depositor) {
    +            {
    +                testcase("VaultClawback (share) - " + prefix + " owner asset clawback fails");
    +                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor);
    +                // when asset is XRP or owner is not issuer clawback fail
    +                // when owner is issuer precision loss occurs as vault is
    +                // empty
    +                auto const expectedTer = [&]() {
    +                    if (asset.native())
    +                        return Ter(temMALFORMED);
    +                    if (asset.raw().getIssuer() != owner.id())
    +                        return Ter(tecNO_PERMISSION);
    +                    return Ter(tecPRECISION_LOSS);
    +                }();
    +                env(vault.clawback({
    +                        .issuer = owner,
    +                        .id = vaultKeylet.key,
    +                        .holder = depositor,
    +                        .amount = asset(100).value(),
    +                    }),
    +                    expectedTer);
    +                env.close();
    +            }
    +
    +            {
    +                testcase(
    +                    "VaultClawback (share) - " + prefix + " owner incomplete share clawback fails");
    +                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor);
    +                auto const& vaultSle = env.le(vaultKeylet);
    +                if (!BEAST_EXPECT(vaultSle))
    +                    return;
    +                Asset const share = vaultSle->at(sfShareMPTID);
    +                env(vault.clawback({
    +                        .issuer = owner,
    +                        .id = vaultKeylet.key,
    +                        .holder = depositor,
    +                        .amount = share(1).value(),
    +                    }),
    +                    Ter(tecLIMIT_EXCEEDED));
    +                env.close();
    +            }
    +
    +            {
    +                testcase(
    +                    "VaultClawback (share) - " + prefix +
    +                    " owner implicit complete share clawback");
    +                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor);
    +                env(vault.clawback({
    +                        .issuer = owner,
    +                        .id = vaultKeylet.key,
    +                        .holder = depositor,
    +                    }),
    +                    // when owner is issuer implicit clawback fails
    +                    asset.native() || asset.raw().getIssuer() != owner.id() ? Ter(tesSUCCESS)
    +                                                                            : Ter(tecWRONG_ASSET));
    +                env.close();
    +            }
    +
    +            {
    +                testcase(
    +                    "VaultClawback (share) - " + prefix +
    +                    " owner explicit complete share clawback succeeds");
    +                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor);
    +                auto const& vaultSle = env.le(vaultKeylet);
    +                if (!BEAST_EXPECT(vaultSle))
    +                    return;
    +                Asset const share = vaultSle->at(sfShareMPTID);
    +                env(vault.clawback({
    +                        .issuer = owner,
    +                        .id = vaultKeylet.key,
    +                        .holder = depositor,
    +                        .amount = share(vaultShareBalance(vaultKeylet)).value(),
    +                    }),
    +                    Ter(tesSUCCESS));
    +                env.close();
    +            }
    +            {
    +                testcase("VaultClawback (share) - " + prefix + " owner can clawback own shares");
    +                auto [vault, vaultKeylet] = setupVault(asset, owner, owner);
    +                auto const& vaultSle = env.le(vaultKeylet);
    +                if (!BEAST_EXPECT(vaultSle))
    +                    return;
    +                Asset const share = vaultSle->at(sfShareMPTID);
    +                env(vault.clawback({
    +                        .issuer = owner,
    +                        .id = vaultKeylet.key,
    +                        .holder = owner,
    +                        .amount = share(vaultShareBalance(vaultKeylet)).value(),
    +                    }),
    +                    Ter(tesSUCCESS));
    +                env.close();
    +            }
    +
    +            {
    +                testcase("VaultClawback (share) - " + prefix + " empty vault share clawback fails");
    +                auto [vault, vaultKeylet] = setupVault(asset, owner, owner);
    +                auto const& vaultSle = env.le(vaultKeylet);
    +                if (!BEAST_EXPECT(vaultSle))
    +                    return;
    +                Asset const share = vaultSle->at(sfShareMPTID);
    +                env(vault.clawback({
    +                        .issuer = owner,
    +                        .id = vaultKeylet.key,
    +                        .holder = owner,
    +                        .amount = share(vaultShareBalance(vaultKeylet)).value(),
    +                    }),
    +                    Ter(tesSUCCESS));
    +
    +                // Now the vault is empty, clawback again fails
    +                env(vault.clawback({
    +                        .issuer = owner,
    +                        .id = vaultKeylet.key,
    +                        .holder = owner,
    +                        .amount = share(vaultShareBalance(vaultKeylet)).value(),
    +                    }),
    +                    Ter(tecNO_PERMISSION));
    +                env.close();
    +            }
    +        };
    +
    +        Account const owner{"alice"};
    +        Account const depositor{"bob"};
    +        Account const issuer{"issuer"};
    +
    +        env.fund(XRP(10000), issuer, owner, depositor);
    +        env.close();
    +
    +        // Test XRP
    +        PrettyAsset const xrp = xrpIssue();
    +        testCase(xrp, "XRP", owner, depositor);
    +        testCase(xrp, "XRP (depositor is owner)", owner, owner);
    +
    +        // Test IOU
    +        PrettyAsset const iou = issuer["IOU"];
    +        env(fset(issuer, asfAllowTrustLineClawback));
    +        env.close();
    +
    +        env.trust(iou(1000), owner);
    +        env.trust(iou(1000), depositor);
    +        env(pay(issuer, owner, iou(100)));
    +        env(pay(issuer, depositor, iou(100)));
    +        env.close();
    +        testCase(iou, "IOU", owner, depositor);
    +        testCase(iou, "IOU (owner is issuer)", issuer, depositor);
    +
    +        // Test MPT
    +        MPTTester mptt{env, issuer, kMptInitNoFund};
    +        mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
    +        PrettyAsset const mpt = mptt.issuanceID();
    +        mptt.authorize({.account = owner});
    +        mptt.authorize({.account = depositor});
    +        env(pay(issuer, owner, mpt(1000)));
    +        env(pay(issuer, depositor, mpt(1000)));
    +        env.close();
    +        testCase(mpt, "MPT", owner, depositor);
    +        testCase(mpt, "MPT (owner is issuer)", issuer, depositor);
    +    }
    +
    +    void
    +    testVaultClawbackAssets()
    +    {
    +        using namespace test::jtx;
    +        using namespace loan_broker;
    +        using namespace loan;
    +        Env env(*this);
    +        env.enableFeature(fixCleanup3_1_3);
    +
    +        auto const setupVault = [&](PrettyAsset const& asset,
    +                                    Account const& owner,
    +                                    Account const& depositor,
    +                                    Account const& issuer) -> std::pair {
    +            Vault const vault{env};
    +
    +            auto const& [tx, vaultKeylet] = vault.create({.owner = owner, .asset = asset});
    +            env(tx, Ter(tesSUCCESS));
    +            env.close();
    +
    +            auto const& vaultSle = env.le(vaultKeylet);
    +            BEAST_EXPECT(vaultSle != nullptr);
    +            env.memoize(Account("vault", vaultSle->at(sfAccount)));
    +            env(vault.deposit(
    +                    {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}),
    +                Ter(tesSUCCESS));
    +            env.close();
    +
    +            return std::make_pair(vault, vaultKeylet);
    +        };
    +
    +        auto const testCase = [&](PrettyAsset const& asset,
    +                                  std::string const& prefix,
    +                                  Account const& owner,
    +                                  Account const& depositor,
    +                                  Account const& issuer) {
    +            if (asset.native())
    +            {
    +                testcase("VaultClawback (asset) - " + prefix + " issuer XRP clawback fails");
    +                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
    +                // If the asset is XRP, clawback with amount fails as malformed
    +                // when asset is specified.
    +                env(vault.clawback({
    +                        .issuer = issuer,
    +                        .id = vaultKeylet.key,
    +                        .holder = issuer,
    +                        .amount = asset(1).value(),
    +                    }),
    +                    Ter(temMALFORMED));
    +                // When asset is implicit, clawback fails as no permission.
    +                env(vault.clawback({
    +                        .issuer = issuer,
    +                        .id = vaultKeylet.key,
    +                        .holder = issuer,
    +                    }),
    +                    Ter(tecNO_PERMISSION));
    +                return;
    +            }
    +
    +            {
    +                testcase(
    +                    "VaultClawback (asset) - " + prefix + " clawback for different asset fails");
    +                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
    +
    +                Account const issuer2{"issuer2"};
    +                PrettyAsset const asset2 = issuer2["FOO"];
    +                env(vault.clawback({
    +                        .issuer = issuer,
    +                        .id = vaultKeylet.key,
    +                        .holder = depositor,
    +                        .amount = asset2(1).value(),
    +                    }),
    +                    Ter(tecWRONG_ASSET));
    +            }
    +
    +            {
    +                testcase(
    +                    "VaultClawback (asset) - " + prefix +
    +                    " ambiguous owner/issuer asset clawback fails");
    +                auto [vault, vaultKeylet] = setupVault(asset, issuer, depositor, issuer);
    +                env(vault.clawback({
    +                        .issuer = issuer,
    +                        .id = vaultKeylet.key,
    +                        .holder = issuer,
    +                    }),
    +                    Ter(tecWRONG_ASSET));
    +            }
    +
    +            {
    +                testcase("VaultClawback (asset) - " + prefix + " non-issuer asset clawback fails");
    +                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
    +
    +                env(vault.clawback({
    +                        .issuer = owner,
    +                        .id = vaultKeylet.key,
    +                        .holder = depositor,
    +                    }),
    +                    Ter(tecNO_PERMISSION));
    +
    +                env(vault.clawback({
    +                        .issuer = owner,
    +                        .id = vaultKeylet.key,
    +                        .holder = depositor,
    +                        .amount = asset(1).value(),
    +                    }),
    +                    Ter(tecNO_PERMISSION));
    +            }
    +
    +            {
    +                testcase("VaultClawback (asset) - " + prefix + " issuer clawback from self fails");
    +                auto [vault, vaultKeylet] = setupVault(asset, owner, issuer, issuer);
    +                env(vault.clawback({
    +                        .issuer = issuer,
    +                        .id = vaultKeylet.key,
    +                        .holder = issuer,
    +                    }),
    +                    Ter(tecNO_PERMISSION));
    +            }
    +
    +            {
    +                testcase("VaultClawback (asset) - " + prefix + " issuer share clawback fails");
    +                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
    +                auto const& vaultSle = env.le(vaultKeylet);
    +                if (!BEAST_EXPECT(vaultSle))
    +                    return;
    +                Asset const share = vaultSle->at(sfShareMPTID);
    +
    +                env(vault.clawback({
    +                        .issuer = issuer,
    +                        .id = vaultKeylet.key,
    +                        .holder = depositor,
    +                        .amount = share(1).value(),
    +                    }),
    +                    Ter(tecNO_PERMISSION));
    +            }
    +
    +            {
    +                testcase(
    +                    "VaultClawback (asset) - " + prefix +
    +                    " partial issuer asset clawback succeeds");
    +                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
    +
    +                env(vault.clawback({
    +                        .issuer = issuer,
    +                        .id = vaultKeylet.key,
    +                        .holder = depositor,
    +                        .amount = asset(1).value(),
    +                    }),
    +                    Ter(tesSUCCESS));
    +            }
    +
    +            {
    +                testcase(
    +                    "VaultClawback (asset) - " + prefix + " full issuer asset clawback succeeds");
    +                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
    +
    +                env(vault.clawback({
    +                        .issuer = issuer,
    +                        .id = vaultKeylet.key,
    +                        .holder = depositor,
    +                        .amount = asset(100).value(),
    +                    }),
    +                    Ter(tesSUCCESS));
    +            }
    +
    +            {
    +                testcase(
    +                    "VaultClawback (asset) - " + prefix +
    +                    " implicit full issuer asset clawback succeeds");
    +                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
    +
    +                env(vault.clawback({
    +                        .issuer = issuer,
    +                        .id = vaultKeylet.key,
    +                        .holder = depositor,
    +                    }),
    +                    Ter(tesSUCCESS));
    +            }
    +
    +            {
    +                testcase(
    +                    "VaultClawback (asset) - " + prefix +
    +                    " zero-amount clawback clamped with outstanding loan");
    +                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
    +
    +                auto const vaultSle = env.le(vaultKeylet);
    +                if (!BEAST_EXPECT(vaultSle))
    +                    return;
    +
    +                PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
    +
    +                // Create a loan broker backed by this vault
    +                auto const brokerKeylet =
    +                    keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
    +                env(set(owner, vaultKeylet.key));
    +                env.close();
    +
    +                // Depositor borrows 40 units, reducing assetsAvailable to 60
    +                // while assetsTotal stays at 100
    +                env(set(depositor, brokerKeylet.key, asset(40).value()),
    +                    loan::kInterestRate(TenthBips32(0)),
    +                    kGracePeriod(60),
    +                    kPaymentInterval(120),
    +                    kPaymentTotal(10),
    +                    Sig(sfCounterpartySignature, owner),
    +                    Fee(env.current()->fees().base * 2),
    +                    Ter(tesSUCCESS));
    +                env.close();
    +
    +                {
    +                    auto const sle = env.le(vaultKeylet);
    +                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(60).value());
    +                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(100).value());
    +                }
    +
    +                // Zero-amount clawback (= "clawback all") should succeed,
    +                // clamped to assetsAvailable (60) rather than the full
    +                // share value (100).
    +                env(vault.clawback({
    +                        .issuer = issuer,
    +                        .id = vaultKeylet.key,
    +                        .holder = depositor,
    +                    }),
    +                    Ter(tesSUCCESS));
    +                env.close();
    +
    +                // Only 60 assets clawed back; loan's 40 still outstanding
    +                {
    +                    auto const sle = env.le(vaultKeylet);
    +                    BEAST_EXPECT(sle != nullptr);
    +                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(0).value());
    +                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(40).value());
    +
    +                    // 60 of 100 shares destroyed (1:1 ratio), 40 remain
    +                    auto const sharesAfter = env.balance(depositor, shares);
    +                    BEAST_EXPECT(sharesAfter == shares(Number{4, sle->at(sfScale) + 1}));
    +                }
    +            }
    +
    +            {
    +                testcase(
    +                    "VaultClawback (asset) - " + prefix +
    +                    " non-zero clawback clamped with outstanding loan");
    +                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
    +
    +                auto const vaultSle = env.le(vaultKeylet);
    +                if (!BEAST_EXPECT(vaultSle))
    +                    return;
    +                PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
    +
    +                // Create a loan broker backed by this vault
    +                auto const brokerKeylet =
    +                    keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
    +                env(set(owner, vaultKeylet.key));
    +                env.close();
    +
    +                // Depositor borrows 40 units
    +                env(set(depositor, brokerKeylet.key, asset(40).value()),
    +                    loan::kInterestRate(TenthBips32(0)),
    +                    kGracePeriod(60),
    +                    kPaymentInterval(120),
    +                    kPaymentTotal(10),
    +                    Sig(sfCounterpartySignature, owner),
    +                    Fee(env.current()->fees().base * 2),
    +                    Ter(tesSUCCESS));
    +                env.close();
    +
    +                {
    +                    auto const sle = env.le(vaultKeylet);
    +                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(60).value());
    +                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(100).value());
    +                }
    +
    +                // Request 100 but only 60 available — clamped to 60
    +                env(vault.clawback({
    +                        .issuer = issuer,
    +                        .id = vaultKeylet.key,
    +                        .holder = depositor,
    +                        .amount = asset(100).value(),
    +                    }),
    +                    Ter(tesSUCCESS));
    +                env.close();
    +
    +                {
    +                    auto const sle = env.le(vaultKeylet);
    +                    BEAST_EXPECT(sle != nullptr);
    +                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(0).value());
    +                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(40).value());
    +
    +                    // 60 of 100 shares destroyed (1:1 ratio), 40 remain
    +                    auto const sharesAfter = env.balance(depositor, shares);
    +                    BEAST_EXPECT(sharesAfter == shares(Number{4, sle->at(sfScale) + 1}));
    +                }
    +            }
    +
    +            {
    +                testcase(
    +                    "VaultClawback (asset) - " + prefix +
    +                    " partial clawback below available with outstanding loan");
    +                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
    +
    +                auto const vaultSle = env.le(vaultKeylet);
    +                if (!BEAST_EXPECT(vaultSle))
    +                    return;
    +                PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
    +
    +                // Create a loan broker backed by this vault
    +                auto const brokerKeylet =
    +                    keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
    +                env(set(owner, vaultKeylet.key));
    +                env.close();
    +
    +                // Depositor borrows 40 units: assetsAvailable=60, assetsTotal=100
    +                env(set(depositor, brokerKeylet.key, asset(40).value()),
    +                    loan::kInterestRate(TenthBips32(0)),
    +                    kGracePeriod(60),
    +                    kPaymentInterval(120),
    +                    kPaymentTotal(10),
    +                    Sig(sfCounterpartySignature, owner),
    +                    Fee(env.current()->fees().base * 2),
    +                    Ter(tesSUCCESS));
    +                env.close();
    +
    +                {
    +                    auto const sle = env.le(vaultKeylet);
    +                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(60).value());
    +                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(100).value());
    +                }
    +
    +                // Clawback 30 — well under available (60), no clamping needed
    +                env(vault.clawback({
    +                        .issuer = issuer,
    +                        .id = vaultKeylet.key,
    +                        .holder = depositor,
    +                        .amount = asset(30).value(),
    +                    }),
    +                    Ter(tesSUCCESS));
    +                env.close();
    +
    +                {
    +                    auto const sle = env.le(vaultKeylet);
    +                    BEAST_EXPECT(sle != nullptr);
    +                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(30).value());
    +                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(70).value());
    +
    +                    // 30 of 100 shares destroyed (1:1 ratio), 70 remain
    +                    auto const sharesAfter = env.balance(depositor, shares);
    +                    BEAST_EXPECT(sharesAfter == shares(Number{7, sle->at(sfScale) + 1}));
    +                }
    +            }
    +
    +            {
    +                testcase(
    +                    "VaultClawback (asset) - " + prefix +
    +                    " clawback exactly equal to available with outstanding loan");
    +                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
    +
    +                auto const vaultSle = env.le(vaultKeylet);
    +                if (!BEAST_EXPECT(vaultSle))
    +                    return;
    +                PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
    +
    +                auto const brokerKeylet =
    +                    keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
    +                env(set(owner, vaultKeylet.key));
    +                env.close();
    +
    +                // Depositor borrows 40 units: assetsAvailable=60, assetsTotal=100
    +                env(set(depositor, brokerKeylet.key, asset(40).value()),
    +                    loan::kInterestRate(TenthBips32(0)),
    +                    kGracePeriod(60),
    +                    kPaymentInterval(120),
    +                    kPaymentTotal(10),
    +                    Sig(sfCounterpartySignature, owner),
    +                    Fee(env.current()->fees().base * 2),
    +                    Ter(tesSUCCESS));
    +                env.close();
    +
    +                // Clawback exactly 60 — at the boundary, no clamping needed
    +                env(vault.clawback({
    +                        .issuer = issuer,
    +                        .id = vaultKeylet.key,
    +                        .holder = depositor,
    +                        .amount = asset(60).value(),
    +                    }),
    +                    Ter(tesSUCCESS));
    +                env.close();
    +
    +                {
    +                    auto const sle = env.le(vaultKeylet);
    +                    BEAST_EXPECT(sle != nullptr);
    +                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(0).value());
    +                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(40).value());
    +
    +                    // 60 of 100 shares destroyed (1:1 ratio), 40 remain
    +                    auto const sharesAfter = env.balance(depositor, shares);
    +                    BEAST_EXPECT(sharesAfter == shares(Number{4, sle->at(sfScale) + 1}));
    +                }
    +            }
    +
    +            {
    +                testcase(
    +                    "VaultClawback (asset) - " + prefix +
    +                    " clawback with zero available (fully borrowed)");
    +                auto [vault, vaultKeylet] = setupVault(asset, owner, depositor, issuer);
    +
    +                auto const vaultSle = env.le(vaultKeylet);
    +                if (!BEAST_EXPECT(vaultSle))
    +                    return;
    +                PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
    +
    +                auto const brokerKeylet =
    +                    keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
    +                env(set(owner, vaultKeylet.key));
    +                env.close();
    +
    +                // Depositor borrows all 100 units: assetsAvailable=0, assetsTotal=100
    +                env(set(depositor, brokerKeylet.key, asset(100).value()),
    +                    loan::kInterestRate(TenthBips32(0)),
    +                    kGracePeriod(60),
    +                    kPaymentInterval(120),
    +                    kPaymentTotal(10),
    +                    Sig(sfCounterpartySignature, owner),
    +                    Fee(env.current()->fees().base * 2),
    +                    Ter(tesSUCCESS));
    +                env.close();
    +
    +                {
    +                    auto const sle = env.le(vaultKeylet);
    +                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(0).value());
    +                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(100).value());
    +                }
    +
    +                auto const sharesBefore = env.balance(depositor, shares);
    +
    +                // Zero-amount clawback — nothing available, clamped to 0,
    +                // resulting in zero shares destroyed → tecPRECISION_LOSS
    +                env(vault.clawback({
    +                        .issuer = issuer,
    +                        .id = vaultKeylet.key,
    +                        .holder = depositor,
    +                    }),
    +                    Ter(tecPRECISION_LOSS));
    +                env.close();
    +
    +                // Explicit amount clawback — also nothing available
    +                env(vault.clawback({
    +                        .issuer = issuer,
    +                        .id = vaultKeylet.key,
    +                        .holder = depositor,
    +                        .amount = asset(50).value(),
    +                    }),
    +                    Ter(tecPRECISION_LOSS));
    +                env.close();
    +
    +                {
    +                    // Nothing changed — vault and shares unchanged
    +                    auto const sle = env.le(vaultKeylet);
    +                    BEAST_EXPECT(sle != nullptr);
    +                    BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(0).value());
    +                    BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(100).value());
    +                    auto const sharesAfter = env.balance(depositor, shares);
    +                    BEAST_EXPECT(sharesAfter == sharesBefore);
    +                }
    +            }
    +        };
    +
    +        Account const owner{"alice"};
    +        Account const depositor{"bob"};
    +        Account const issuer{"issuer"};
    +
    +        env.fund(XRP(10000), issuer, owner, depositor);
    +        env.close();
    +
    +        // Test XRP
    +        PrettyAsset const xrp = xrpIssue();
    +        testCase(xrp, "XRP", owner, depositor, issuer);
    +
    +        // Test IOU
    +        PrettyAsset const iou = issuer["IOU"];
    +        env(fset(issuer, asfAllowTrustLineClawback));
    +        env.close();
    +        env.trust(iou(2000), owner);
    +        env.trust(iou(2000), depositor);
    +        env(pay(issuer, owner, iou(2000)));
    +        env(pay(issuer, depositor, iou(2000)));
    +        env.close();
    +        testCase(iou, "IOU", owner, depositor, issuer);
    +
    +        // Test MPT
    +        MPTTester mptt{env, issuer, kMptInitNoFund};
    +        mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
    +
    +        PrettyAsset const mpt = mptt.issuanceID();
    +        mptt.authorize({.account = owner});
    +        mptt.authorize({.account = depositor});
    +        env(pay(issuer, depositor, mpt(2000)));
    +        env.close();
    +        testCase(mpt, "MPT", owner, depositor, issuer);
    +
    +        // Test pre-fixCleanup3_1_3 legacy path: zero-amount clawback
    +        // returns early without clamping to assetsAvailable.
    +        {
    +            testcase(
    +                "VaultClawback (asset) - IOU pre-fixCleanup3_1_3"
    +                " zero-amount clawback unclamped with outstanding loan");
    +
    +            env.disableFeature(fixCleanup3_1_3);
    +
    +            auto [vault, vaultKeylet] = setupVault(iou, owner, depositor, issuer);
    +
    +            auto const vaultSle = env.le(vaultKeylet);
    +            BEAST_EXPECT(vaultSle != nullptr);
    +            if (!vaultSle)
    +                return;
    +
    +            PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
    +
    +            // Create a loan broker backed by this vault
    +            auto const brokerKeylet =
    +                keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
    +            env(set(owner, vaultKeylet.key));
    +            env.close();
    +
    +            // Depositor borrows 40 units, reducing assetsAvailable to 60
    +            // while assetsTotal stays at 100
    +            env(set(depositor, brokerKeylet.key, iou(40).value()),
    +                loan::kInterestRate(TenthBips32(0)),
    +                kGracePeriod(60),
    +                kPaymentInterval(120),
    +                kPaymentTotal(10),
    +                Sig(sfCounterpartySignature, owner),
    +                Fee(env.current()->fees().base * 2),
    +                Ter(tesSUCCESS));
    +            env.close();
    +
    +            {
    +                auto const sle = env.le(vaultKeylet);
    +                BEAST_EXPECT(sle->at(sfAssetsAvailable) == iou(60).value());
    +                BEAST_EXPECT(sle->at(sfAssetsTotal) == iou(100).value());
    +            }
    +
    +            auto const sharesBefore = env.balance(depositor, shares);
    +
    +            // Legacy: zero-amount clawback tries to recover the full
    +            // share value (100) without clamping to assetsAvailable (60).
    +            // This causes the vault balance to go negative, triggering
    +            // the sanity check in doApply → tefINTERNAL.
    +            env(vault.clawback({
    +                    .issuer = issuer,
    +                    .id = vaultKeylet.key,
    +                    .holder = depositor,
    +                }),
    +                Ter(tefINTERNAL));
    +            env.close();
    +
    +            {
    +                // Transaction rolled back — vault and shares unchanged
    +                auto const sle = env.le(vaultKeylet);
    +                BEAST_EXPECT(sle != nullptr);
    +                BEAST_EXPECT(sle->at(sfAssetsAvailable) == iou(60).value());
    +                BEAST_EXPECT(sle->at(sfAssetsTotal) == iou(100).value());
    +                auto const sharesAfter = env.balance(depositor, shares);
    +                BEAST_EXPECT(sharesAfter == sharesBefore);
    +            }
    +
    +            env.enableFeature(fixCleanup3_1_3);
    +        }
    +    }
    +
    +    void
    +    testVaultEscrowedMPT()
    +    {
    +        using namespace test::jtx;
    +        using namespace std::literals;
    +
    +        // Verify vault deposit/withdraw/clawback respect sfLockedAmount.
    +        // When MPT tokens are escrowed, sfMPTAmount is reduced and
    +        // sfLockedAmount is increased. Vault operations go through
    +        // accountSend/accountHolds which read sfMPTAmount, so escrowed
    +        // tokens are naturally excluded.
    +
    +        {
    +            testcase("Vault deposit fails when MPT asset is escrowed");
    +
    +            Env env{*this, testableAmendments()};
    +            auto const baseFee = env.current()->fees().base;
    +            Account const owner{"owner"};
    +            Account const depositor{"depositor"};
    +            Account const issuer{"issuer"};
    +            Account const bob{"bob"};
    +
    +            env.fund(XRP(10000), issuer, owner, depositor, bob);
    +            env.close();
    +
    +            MPTTester mptt{env, issuer, kMptInitNoFund};
    +            mptt.create(
    +                {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock | tfMPTCanEscrow});
    +            mptt.authorize({.account = owner});
    +            mptt.authorize({.account = depositor});
    +            mptt.authorize({.account = bob});
    +            PrettyAsset const asset = mptt.issuanceID();
    +            env(pay(issuer, depositor, asset(100)));
    +            env.close();
    +
    +            // Escrow 60 of 100 MPT tokens: sfMPTAmount drops to 40
    +            auto const escrowSeq = env.seq(depositor);
    +            env(escrow::create(depositor, bob, asset(60)),
    +                escrow::kCondition(escrow::kCb1),
    +                escrow::kFinishTime(env.now() + 1s),
    +                Fee(baseFee * 150),
    +                Ter(tesSUCCESS));
    +            env.close();
    +
    +            Vault const vault{env};
    +            auto [tx, vaultKeylet] = vault.create({.owner = owner, .asset = asset});
    +            env(tx, Ter(tesSUCCESS));
    +            env.close();
    +
    +            // Deposit 100 should fail — only 40 spendable
    +            env(vault.deposit(
    +                    {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}),
    +                Ter(tecINSUFFICIENT_FUNDS));
    +            env.close();
    +
    +            // Deposit 40 (the unlocked balance) should succeed
    +            env(vault.deposit({.depositor = depositor, .id = vaultKeylet.key, .amount = asset(40)}),
    +                Ter(tesSUCCESS));
    +            env.close();
    +
    +            {
    +                auto const sle = env.le(vaultKeylet);
    +                BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(40).value());
    +            }
    +
    +            // Clean up escrow
    +            env(escrow::finish(bob, depositor, escrowSeq),
    +                escrow::kCondition(escrow::kCb1),
    +                escrow::kFulfillment(escrow::kFb1),
    +                Fee(baseFee * 150),
    +                Ter(tesSUCCESS));
    +            env.close();
    +        }
    +
    +        {
    +            testcase("Vault withdraw respects escrowed shares");
    +
    +            Env env{*this, testableAmendments()};
    +            auto const baseFee = env.current()->fees().base;
    +            Account const owner{"owner"};
    +            Account const depositor{"depositor"};
    +            Account const issuer{"issuer"};
    +            Account const bob{"bob"};
    +
    +            env.fund(XRP(10000), issuer, owner, depositor, bob);
    +            env.close();
    +
    +            MPTTester mptt{env, issuer, kMptInitNoFund};
    +            mptt.create(
    +                {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock | tfMPTCanEscrow});
    +            mptt.authorize({.account = owner});
    +            mptt.authorize({.account = depositor});
    +            PrettyAsset const asset = mptt.issuanceID();
    +            env(pay(issuer, depositor, asset(100)));
    +            env.close();
    +
    +            Vault const vault{env};
    +            auto [tx, vaultKeylet] = vault.create({.owner = owner, .asset = asset});
    +            env(tx, Ter(tesSUCCESS));
    +            env.close();
    +
    +            // Deposit 100 → get shares
    +            env(vault.deposit(
    +                    {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}),
    +                Ter(tesSUCCESS));
    +            env.close();
    +
    +            auto const vaultSle = env.le(vaultKeylet);
    +            if (!BEAST_EXPECT(vaultSle))
    +                return;
    +            env.memoize(Account("vault", vaultSle->at(sfAccount)));
    +            PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
    +
    +            // Authorize bob for share MPT so he can receive escrowed shares
    +            auto const shareMPTID = vaultSle->at(sfShareMPTID);
    +            {
    +                json::Value jv;
    +                jv[jss::Account] = bob.human();
    +                jv[sfMPTokenIssuanceID] = to_string(shareMPTID);
    +                jv[jss::TransactionType] = jss::MPTokenAuthorize;
    +                env(jv, Ter(tesSUCCESS));
    +                env.close();
    +            }
    +
    +            // Escrow 60% of shares
    +            auto const escrowAmount = shares(Number{6, vaultSle->at(sfScale) + 1});
    +            env(escrow::create(depositor, bob, escrowAmount),
    +                escrow::kCondition(escrow::kCb1),
    +                escrow::kFinishTime(env.now() + 1s),
    +                Fee(baseFee * 150),
    +                Ter(tesSUCCESS));
    +            env.close();
    +
    +            // Withdraw all 100 should fail — only 40% of shares are unlocked
    +            env(vault.withdraw(
    +                    {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}),
    +                Ter(tecINSUFFICIENT_FUNDS));
    +            env.close();
    +
    +            // Withdraw 40 (matching unlocked shares) should succeed
    +            env(vault.withdraw(
    +                    {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(40)}),
    +                Ter(tesSUCCESS));
    +            env.close();
    +
    +            {
    +                auto const sle = env.le(vaultKeylet);
    +                BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(60).value());
    +            }
    +        }
    +
    +        {
    +            testcase("Vault clawback only recovers unlocked shares");
    +
    +            Env env{*this, testableAmendments() | fixCleanup3_1_3};
    +            auto const baseFee = env.current()->fees().base;
    +            Account const owner{"owner"};
    +            Account const depositor{"depositor"};
    +            Account const issuer{"issuer"};
    +            Account const bob{"bob"};
    +
    +            env.fund(XRP(10000), issuer, owner, depositor, bob);
    +            env.close();
    +
    +            MPTTester mptt{env, issuer, kMptInitNoFund};
    +            mptt.create(
    +                {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock | tfMPTCanEscrow});
    +            mptt.authorize({.account = owner});
    +            mptt.authorize({.account = depositor});
    +            PrettyAsset const asset = mptt.issuanceID();
    +            env(pay(issuer, depositor, asset(100)));
    +            env.close();
    +
    +            Vault const vault{env};
    +            auto [tx, vaultKeylet] = vault.create({.owner = owner, .asset = asset});
    +            env(tx, Ter(tesSUCCESS));
    +            env.close();
    +
    +            // Deposit 100 → get shares
    +            env(vault.deposit(
    +                    {.depositor = depositor, .id = vaultKeylet.key, .amount = asset(100)}),
    +                Ter(tesSUCCESS));
    +            env.close();
    +
    +            auto const vaultSle = env.le(vaultKeylet);
    +            if (!BEAST_EXPECT(vaultSle))
    +                return;
    +            env.memoize(Account("vault", vaultSle->at(sfAccount)));
    +            PrettyAsset const shares = MPTIssue(vaultSle->at(sfShareMPTID));
    +
    +            // Authorize bob for share MPT so he can receive escrowed shares
    +            auto const shareMPTID = vaultSle->at(sfShareMPTID);
    +            {
    +                json::Value jv;
    +                jv[jss::Account] = bob.human();
    +                jv[sfMPTokenIssuanceID] = to_string(shareMPTID);
    +                jv[jss::TransactionType] = jss::MPTokenAuthorize;
    +                env(jv, Ter(tesSUCCESS));
    +                env.close();
    +            }
    +
    +            // Escrow 60% of shares
    +            auto const escrowAmount = shares(Number{6, vaultSle->at(sfScale) + 1});
    +            env(escrow::create(depositor, bob, escrowAmount),
    +                escrow::kCondition(escrow::kCb1),
    +                escrow::kFinishTime(env.now() + 1s),
    +                Fee(baseFee * 150),
    +                Ter(tesSUCCESS));
    +            env.close();
    +
    +            // Zero-amount clawback ("all") — should only recover assets
    +            // corresponding to unlocked shares (40%)
    +            env(vault.clawback({
    +                    .issuer = issuer,
    +                    .id = vaultKeylet.key,
    +                    .holder = depositor,
    +                }),
    +                Ter(tesSUCCESS));
    +            env.close();
    +
    +            {
    +                auto const sle = env.le(vaultKeylet);
    +                BEAST_EXPECT(sle != nullptr);
    +                // Only 40 of 100 assets recovered (matching 40% unlocked shares)
    +                BEAST_EXPECT(sle->at(sfAssetsTotal) == asset(60).value());
    +                BEAST_EXPECT(sle->at(sfAssetsAvailable) == asset(60).value());
    +
    +                // Depositor's unlocked shares are now 0
    +                auto const sharesAfter = env.balance(depositor, shares);
    +                BEAST_EXPECT(sharesAfter == shares(0));
    +            }
    +        }
    +    }
    +
    +public:
    +    void
    +    run() override
    +    {
    +        testVaultClawbackBurnShares();
    +        testVaultClawbackAssets();
    +        testVaultEscrowedMPT();
    +    }
    +};
    +
    +BEAST_DEFINE_TESTSUITE_PRIO(VaultClawback, app, xrpl, 1);
    +
    +}  // namespace xrpl
    diff --git a/src/test/app/vault/VaultClosedEnded_test.cpp b/src/test/app/vault/VaultClosedEnded_test.cpp
    new file mode 100644
    index 0000000000..252a7f4990
    --- /dev/null
    +++ b/src/test/app/vault/VaultClosedEnded_test.cpp
    @@ -0,0 +1,1008 @@
    +#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 {
    +
    +class VaultClosedEnded_test : public VaultTestBase
    +{
    +private:
    +    // VaultCreate malformation and happy paths for closed-ended vaults, plus the
    +    // featureLendingProtocolV1_1 gate.
    +    void
    +    testVaultCreateClosedEnded()
    +    {
    +        testcase("closed-ended VaultCreate");
    +        using namespace test::jtx;
    +
    +        auto const withEnv = [this](FeatureBitset features, auto&& body) {
    +            Env env{*this, features};
    +            Account const owner{"owner"};
    +            env.fund(XRP(1000), owner);
    +            env.close();
    +            Vault vault{env};
    +            body(env, owner, vault);
    +        };
    +
    +        Asset const asset = xrpIssue();
    +        auto const minPeriod = kMinInvestmentPeriod;
    +        auto const maxPeriod = kMaxInvestmentPeriod;
    +        auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded);
    +
    +        // Gate: the three new fields require featureLendingProtocolV1_1.
    +        withEnv(
    +            testableAmendments() - featureLendingProtocolV1_1,
    +            [&](Env& env, Account const& owner, Vault& vault) {
    +                auto const sub = env.now().time_since_epoch().count() + 60;
    +                auto [tx, keylet] = vault.create(
    +                    {.owner = owner,
    +                     .asset = asset,
    +                     .vaultKind = closedEnded,
    +                     .subscriptionDate = sub,
    +                     .redemptionDate = sub + minPeriod});
    +                env(tx, Ter{temDISABLED});
    +            });
    +
    +        /*
    +         * Valid closed-ended creation with a comfortably interior gap (well above
    +         * MIN_INVESTMENT_PERIOD and well below MAX_INVESTMENT_PERIOD).
    +         */
    +        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
    +            auto const sub = env.now().time_since_epoch().count() + 60;
    +            auto const red = sub + 86400;
    +            auto [tx, keylet] = vault.create(
    +                {.owner = owner,
    +                 .asset = asset,
    +                 .vaultKind = closedEnded,
    +                 .subscriptionDate = sub,
    +                 .redemptionDate = red});
    +            env(tx);
    +            env.close();
    +            auto const sle = env.le(keylet);
    +            if (BEAST_EXPECT(sle))
    +            {
    +                BEAST_EXPECT(sle->at(sfVaultKind) == closedEnded);
    +                BEAST_EXPECT(sle->at(sfSubscriptionDate) == sub);
    +                BEAST_EXPECT(sle->at(sfRedemptionDate) == red);
    +            }
    +        });
    +
    +        // ClosedEnded missing one of SubscriptionDate / RedemptionDate => temMALFORMED.
    +        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
    +            auto const sub = env.now().time_since_epoch().count() + 60;
    +            auto [tx, keylet] = vault.create(
    +                {.owner = owner,
    +                 .asset = asset,
    +                 .vaultKind = closedEnded,
    +                 .redemptionDate = sub + minPeriod});
    +            env(tx, Ter{temMALFORMED});
    +        });
    +        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
    +            auto const sub = env.now().time_since_epoch().count() + 60;
    +            auto [tx, keylet] = vault.create(
    +                {.owner = owner,
    +                 .asset = asset,
    +                 .vaultKind = closedEnded,
    +                 .subscriptionDate = sub});
    +            env(tx, Ter{temMALFORMED});
    +        });
    +
    +        /*
    +         * SubscriptionDate not strictly after parent close time (preclaim, state-dependent -
    +         * returns tecEXPIRED). This is the only reachable path to tecEXPIRED in VaultCreate; see
    +         * the note below the next case. Note: there is no separate "expired RedemptionDate" test
    +         * case here. preflight enforces red >= sub + kMinInvestmentPeriod, so any past
    +         * RedemptionDate implies a strictly-earlier, equally-past SubscriptionDate; the
    +         * SubscriptionDate check above short-circuits first. The RedemptionDate arm of the
    +         * hasExpired check in VaultCreate::preclaim is defensive and unreachable as the sole cause
    +         * of tecEXPIRED.
    +         */
    +        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
    +            auto const nowSec = env.now().time_since_epoch().count();
    +            auto [tx, keylet] = vault.create(
    +                {.owner = owner,
    +                 .asset = asset,
    +                 .vaultKind = closedEnded,
    +                 .subscriptionDate = nowSec,
    +                 .redemptionDate = nowSec + minPeriod});
    +            env(tx, Ter{tecEXPIRED});
    +        });
    +
    +        /*
    +         * Gap smaller than MIN_INVESTMENT_PERIOD => temMALFORMED. Includes the SubscriptionDate >=
    +         * RedemptionDate degenerate cases: the red == sub boundary and the strictly-reversed red <
    +         * sub case, the latter yielding a negative signed int64 gap that is caught by the
    +         * sub-minimum branch of the gap check.
    +         */
    +        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
    +            auto const sub = env.now().time_since_epoch().count() + 60;
    +            auto [tx, keylet] = vault.create(
    +                {.owner = owner,
    +                 .asset = asset,
    +                 .vaultKind = closedEnded,
    +                 .subscriptionDate = sub,
    +                 .redemptionDate = sub + minPeriod - 1});
    +            env(tx, Ter{temMALFORMED});
    +        });
    +        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
    +            auto const sub = env.now().time_since_epoch().count() + 60;
    +            auto [tx, keylet] = vault.create(
    +                {.owner = owner,
    +                 .asset = asset,
    +                 .vaultKind = closedEnded,
    +                 .subscriptionDate = sub,
    +                 .redemptionDate = sub});
    +            env(tx, Ter{temMALFORMED});
    +        });
    +        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
    +            auto const sub = env.now().time_since_epoch().count() + 60;
    +            auto [tx, keylet] = vault.create(
    +                {.owner = owner,
    +                 .asset = asset,
    +                 .vaultKind = closedEnded,
    +                 .subscriptionDate = sub,
    +                 .redemptionDate = sub - 1});
    +            env(tx, Ter{temMALFORMED});
    +        });
    +
    +        // Gap equal to MAX_INVESTMENT_PERIOD => temMALFORMED (bound is half-open on the right).
    +        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
    +            auto const sub = env.now().time_since_epoch().count() + 60;
    +            auto [tx, keylet] = vault.create(
    +                {.owner = owner,
    +                 .asset = asset,
    +                 .vaultKind = closedEnded,
    +                 .subscriptionDate = sub,
    +                 .redemptionDate = sub + maxPeriod});
    +            env(tx, Ter{temMALFORMED});
    +        });
    +
    +        // Gap strictly greater than MAX_INVESTMENT_PERIOD => temMALFORMED. Same code path as
    +        // gap == MAX_INVESTMENT_PERIOD above, but covers the "gap >= MAX" bullet fully.
    +        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
    +            auto const sub = env.now().time_since_epoch().count() + 60;
    +            auto [tx, keylet] = vault.create(
    +                {.owner = owner,
    +                 .asset = asset,
    +                 .vaultKind = closedEnded,
    +                 .subscriptionDate = sub,
    +                 .redemptionDate = sub + maxPeriod + 1});
    +            env(tx, Ter{temMALFORMED});
    +        });
    +
    +        // Happy path: gap exactly equal to MIN_INVESTMENT_PERIOD is accepted (lower bound is
    +        // inclusive).
    +        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
    +            auto const sub = env.now().time_since_epoch().count() + 60;
    +            auto const red = sub + minPeriod;
    +            auto [tx, keylet] = vault.create(
    +                {.owner = owner,
    +                 .asset = asset,
    +                 .vaultKind = closedEnded,
    +                 .subscriptionDate = sub,
    +                 .redemptionDate = red});
    +            env(tx);
    +            env.close();
    +            auto const sle = env.le(keylet);
    +            if (BEAST_EXPECT(sle))
    +            {
    +                BEAST_EXPECT(sle->at(sfRedemptionDate) == red);
    +            }
    +        });
    +
    +        // Happy path: gap one second less than MAX_INVESTMENT_PERIOD is
    +        // accepted (upper bound is exclusive).
    +        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
    +            auto const sub = env.now().time_since_epoch().count() + 60;
    +            auto const red = sub + maxPeriod - 1;
    +            auto [tx, keylet] = vault.create(
    +                {.owner = owner,
    +                 .asset = asset,
    +                 .vaultKind = closedEnded,
    +                 .subscriptionDate = sub,
    +                 .redemptionDate = red});
    +            env(tx);
    +            env.close();
    +            auto const sle = env.le(keylet);
    +            if (BEAST_EXPECT(sle))
    +            {
    +                BEAST_EXPECT(sle->at(sfRedemptionDate) == red);
    +            }
    +        });
    +
    +        // OpenEnded (absent/0) with SubscriptionDate or RedemptionDate present
    +        // => temMALFORMED.
    +        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
    +            auto const sub = env.now().time_since_epoch().count() + 60;
    +            auto [tx, keylet] =
    +                vault.create({.owner = owner, .asset = asset, .subscriptionDate = sub});
    +            env(tx, Ter{temMALFORMED});
    +        });
    +        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
    +            auto const sub = env.now().time_since_epoch().count() + 60;
    +            auto [tx, keylet] =
    +                vault.create({.owner = owner, .asset = asset, .redemptionDate = sub + minPeriod});
    +            env(tx, Ter{temMALFORMED});
    +        });
    +
    +        // Unrecognised VaultKind => temMALFORMED.
    +        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
    +            auto [tx, keylet] = vault.create(
    +                {.owner = owner,
    +                 .asset = asset,
    +                 .vaultKind = static_cast(closedEnded + 1)});
    +            env(tx, Ter{temMALFORMED});
    +        });
    +
    +        // Happy path: open-ended vault (no new fields present) is unaffected.
    +        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
    +            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +            env(tx);
    +            env.close();
    +            auto const sle = env.le(keylet);
    +            if (BEAST_EXPECT(sle))
    +            {
    +                BEAST_EXPECT(!sle->isFieldPresent(sfVaultKind));
    +                BEAST_EXPECT(!sle->isFieldPresent(sfSubscriptionDate));
    +                BEAST_EXPECT(!sle->isFieldPresent(sfRedemptionDate));
    +            }
    +        });
    +
    +        // Happy path: explicit `VaultKind = 0` (OpenEnded) behaves the same
    +        // as absent. Per spec, absent and OpenEnded are equivalent.
    +        withEnv(testableAmendments(), [&](Env& env, Account const& owner, Vault& vault) {
    +            auto [tx, keylet] = vault.create(
    +                {.owner = owner,
    +                 .asset = asset,
    +                 .vaultKind = std::to_underlying(VaultKind::OpenEnded)});
    +            env(tx);
    +            env.close();
    +            auto const sle = env.le(keylet);
    +            if (BEAST_EXPECT(sle))
    +            {
    +                // OpenEnded is sfVaultKind's default; SoeDefault fields
    +                // aren't serialized when they hold the default value.
    +                BEAST_EXPECT(!sle->isFieldPresent(sfVaultKind));
    +                BEAST_EXPECT(!sle->isFieldPresent(sfSubscriptionDate));
    +                BEAST_EXPECT(!sle->isFieldPresent(sfRedemptionDate));
    +            }
    +        });
    +    }
    +
    +    // SubscriptionDate boundary cases at the top of the UINT32 range.
    +    // (1) The largest legal sub picks red = UINT32_MAX exactly, which hits
    +    // the inclusive lower bound of the kMinInvestmentPeriod gap check.
    +    // (2) sub = UINT32_MAX must be rejected: sub + kMinInvestmentPeriod is
    +    // unrepresentable as the tx's UINT32 sfRedemptionDate, so no red value
    +    // can satisfy the gap check.
    +    void
    +    testVaultCreateSubscriptionDateBoundary()
    +    {
    +        testcase("closed-ended VaultCreate SubscriptionDate near UINT32_MAX");
    +        using namespace test::jtx;
    +
    +        auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded);
    +        Asset const asset = xrpIssue();
    +
    +        {
    +            Env env{*this, testableAmendments()};
    +            Account const owner{"owner"};
    +            env.fund(XRP(1000), owner);
    +            env.close();
    +
    +            Vault const vault{env};
    +            auto const sub = std::numeric_limits::max() - kMinInvestmentPeriod;
    +            auto const red = std::numeric_limits::max();
    +            auto [tx, keylet] = vault.create(
    +                {.owner = owner,
    +                 .asset = asset,
    +                 .vaultKind = closedEnded,
    +                 .subscriptionDate = sub,
    +                 .redemptionDate = red});
    +            env(tx);
    +            env.close();
    +            auto const sle = env.le(keylet);
    +            if (BEAST_EXPECT(sle))
    +            {
    +                BEAST_EXPECT(sle->at(sfSubscriptionDate) == sub);
    +                BEAST_EXPECT(sle->at(sfRedemptionDate) == red);
    +            }
    +        }
    +
    +        // sub = UINT32_MAX: no legal red exists because sub + kMinInvestmentPeriod
    +        // wraps in a UINT32. Every candidate red must fall to temMALFORMED via
    +        // the gap check in preflight.
    +        auto const rejectAtMax = [&, this](std::uint32_t red) {
    +            Env env{*this, testableAmendments()};
    +            Account const owner{"owner"};
    +            env.fund(XRP(1000), owner);
    +            env.close();
    +
    +            Vault const vault{env};
    +            auto [tx, keylet] = vault.create(
    +                {.owner = owner,
    +                 .asset = asset,
    +                 .vaultKind = closedEnded,
    +                 .subscriptionDate = std::numeric_limits::max(),
    +                 .redemptionDate = red});
    +            env(tx, Ter{temMALFORMED});
    +        };
    +        rejectAtMax(std::numeric_limits::max());
    +        rejectAtMax(0u);
    +        rejectAtMax(kMinInvestmentPeriod - 1u);
    +    }
    +
    +    // Phase derivation across the SubscriptionDate / RedemptionDate boundaries, including the now
    +    // == SubscriptionDate case (which must still resolve to Subscription).
    +    void
    +    testVaultPhaseDerivation()
    +    {
    +        testcase("closed-ended phase derivation");
    +        using namespace test::jtx;
    +
    +        Env env{*this, testableAmendments()};
    +        Account const owner{"owner"};
    +        Account const depositor{"depositor"};
    +        env.fund(XRP(1000), owner, depositor);
    +        env.close();
    +
    +        Asset const asset = xrpIssue();
    +        auto const [vault, keylet, sub, red] =
    +            makeClosedEndedVault(env, owner, asset, 60u, kMinInvestmentPeriod);
    +
    +        // Pre-seed shares during Subscription so the depositor has capital to
    +        // withdraw at the Redemption boundary below.
    +        env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = XRP(10).value()}));
    +        env.close();
    +
    +        auto const deposit =
    +            [&](TER expected, std::source_location const& loc = std::source_location::current()) {
    +                env(
    +                    WithSourceLocation{
    +                        vault.deposit(
    +                            {.depositor = depositor, .id = keylet.key, .amount = XRP(1).value()}),
    +                        loc},
    +                    Ter{expected});
    +            };
    +        auto const withdraw =
    +            [&](TER expected, std::source_location const& loc = std::source_location::current()) {
    +                env(
    +                    WithSourceLocation{
    +                        vault.withdraw(
    +                            {.depositor = depositor, .id = keylet.key, .amount = XRP(1).value()}),
    +                        loc},
    +                    Ter{expected});
    +            };
    +
    +        auto const runTest = [&](TER expectedDeposit,
    +                                 TER expectedWithdraw,
    +                                 std::source_location const& loc =
    +                                     std::source_location::current()) {
    +            deposit(expectedDeposit, loc);
    +            withdraw(expectedWithdraw, loc);
    +        };
    +
    +        // Assert both deposit and withdraw return codes at each point so the
    +        // active phase is uniquely identified:
    +        //   Subscription: deposit tesSUCCESS, withdraw tesSUCCESS
    +        //   Investment:   deposit tecEXPIRED, withdraw tecTOO_SOON
    +        //   Redemption:   deposit tecEXPIRED, withdraw tesSUCCESS
    +
    +        // Ledger time comfortably before SubscriptionDate: Subscription.
    +        runTest(tesSUCCESS, tesSUCCESS);
    +
    +        // Boundary: parent close time exactly at SubscriptionDate must still
    +        // be Subscription.
    +        closeToTime(env, tp{d{sub}});
    +        runTest(tesSUCCESS, tesSUCCESS);
    +
    +        // One second past SubscriptionDate: Investment.
    +        closeToTime(env, tp{d{sub}} + getLedgerTimeResolution(env));
    +        runTest(tecEXPIRED, tecTOO_SOON);
    +
    +        // Any point strictly before RedemptionDate remains Investment.
    +        closeToTime(env, tp{d{red}} - getLedgerTimeResolution(env));
    +        runTest(tecEXPIRED, tecTOO_SOON);
    +
    +        // Boundary: parent close time == RedemptionDate is Redemption (per
    +        // spec table: now >= RedemptionDate). Deposits are rejected but
    +        // withdrawals succeed.
    +        closeToTime(env, tp{d{red}});
    +        runTest(tecEXPIRED, tesSUCCESS);
    +        env.close();
    +    }
    +
    +    // Open-ended vaults are always in VaultPhase::NoPhase, regardless of the ledger clock or any
    +    // dates present on the vault.
    +    void
    +    testVaultPhaseDerivationOpenEnded()
    +    {
    +        testcase("open-ended phase derivation");
    +        using namespace test::jtx;
    +
    +        Env env{*this, testableAmendments()};
    +        Account const owner{"owner"};
    +        env.fund(XRP(1000), owner);
    +        env.close();
    +
    +        Asset const asset = xrpIssue();
    +        Vault const vault{env};
    +        auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +        env(tx);
    +        env.close();
    +
    +        auto const checkPhaseAt = [&](NetClock::time_point at) {
    +            closeToTime(env, at);
    +            auto const sle = env.le(keylet);
    +            if (!BEAST_EXPECT(sle))
    +                return;
    +            BEAST_EXPECT(getVaultPhase(*env.current(), sle) == VaultPhase::NoPhase);
    +        };
    +
    +        // Advance the clock through a wide range of ledger times: an open-ended vault's phase
    +        // must be NoPhase at every one of them, because the derivation short-circuits on
    +        // VaultKind::OpenEnded before it looks at any dates.
    +        auto const ledgerTime = tp{d{30}} + env.closed()->header().closeTimeResolution;
    +        checkPhaseAt(ledgerTime);
    +        checkPhaseAt(ledgerTime + std::chrono::seconds{kMinInvestmentPeriod});
    +        checkPhaseAt(
    +            ledgerTime + std::chrono::seconds{kMaxInvestmentPeriod} -
    +            env.closed()->header().closeTimeResolution);
    +    }
    +
    +    // VaultDeposit is allowed only during Subscription (or NoPhase). Rejected during Investment and
    +    // Redemption.
    +    void
    +    testVaultDepositClosedEnded()
    +    {
    +        testcase("closed-ended VaultDeposit phase gating");
    +        using namespace test::jtx;
    +
    +        Env env{*this, testableAmendments()};
    +        Account const owner{"owner"};
    +        Account const depositor{"depositor"};
    +        env.fund(XRP(1000), owner, depositor);
    +        env.close();
    +
    +        Asset const asset = xrpIssue();
    +        auto const [vault, keylet, sub, red] =
    +            makeClosedEndedVault(env, owner, asset, 60u, kMinInvestmentPeriod);
    +
    +        auto const deposit =
    +            [&](TER expected, std::source_location const& loc = std::source_location::current()) {
    +                env(
    +                    WithSourceLocation{
    +                        vault.deposit(
    +                            {.depositor = depositor, .id = keylet.key, .amount = XRP(1).value()}),
    +                        loc},
    +                    Ter{expected});
    +                env.close();
    +            };
    +
    +        // Subscription: allowed.
    +        deposit(tesSUCCESS);
    +
    +        // Investment: rejected.
    +        env.close(tp{d{sub + 1}});
    +        deposit(tecEXPIRED);
    +
    +        // Redemption: rejected.
    +        env.close(tp{d{red}});
    +        deposit(tecEXPIRED);
    +    }
    +
    +    // VaultWithdraw is allowed in Subscription and Redemption; rejected in Investment. The
    +    // AssetsAvailable cap continues to apply and is exercised in Redemption against a vault with
    +    // capital deployed as an outstanding loan.
    +    void
    +    testVaultWithdrawClosedEnded()
    +    {
    +        testcase("closed-ended VaultWithdraw phase gating");
    +        using namespace test::jtx;
    +        using namespace loan_broker;
    +        using namespace loan;
    +
    +        Env env{*this, testableAmendments()};
    +        Account const owner{"owner"};
    +        Account const depositor{"depositor"};
    +        Account const borrower{"borrower"};
    +        env.fund(XRP(10'000), owner, depositor, borrower);
    +        env.close();
    +
    +        Asset const asset = xrpIssue();
    +        // Widen the Investment window so a single-payment loan (min payment
    +        // interval kMinPaymentInterval = 60s) fits before RedemptionDate.
    +        auto const [vault, keylet, sub, red] =
    +            makeClosedEndedVault(env, owner, asset, 60u, kMinInvestmentPeriod + 3600u);
    +
    +        // Deposit XRP(100) in Subscription so the depositor's shares are
    +        // worth XRP(100). The vault holds XRP(100) with
    +        // AssetsAvailable == AssetsTotal.
    +        env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = XRP(100).value()}));
    +        env.close();
    +
    +        // Create a loan broker backed by this vault. LoanBrokerSet has no
    +        // phase gate, so this is fine to do in Subscription.
    +        auto const brokerKeylet =
    +            keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
    +        env(loan_broker::set(owner, keylet.key));
    +        env.close();
    +
    +        auto const withdraw = [&](STAmount const& amount,
    +                                  TER expected,
    +                                  std::source_location const& loc =
    +                                      std::source_location::current()) {
    +            env(
    +                WithSourceLocation{
    +                    vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = amount}),
    +                    loc},
    +                Ter{expected});
    +            env.close();
    +        };
    +
    +        // Subscription: allowed (LP cancel).
    +        withdraw(XRP(1).value(), tesSUCCESS);
    +
    +        // Investment: rejected.
    +        closeToTime(env, tp{d{sub}} + getLedgerTimeResolution(env));
    +        withdraw(XRP(1).value(), tecTOO_SOON);
    +
    +        // Deploy capital: borrower takes a loan of XRP(60) against the
    +        // vault, dropping AssetsAvailable to ~XRP(39) while AssetsTotal
    +        // remains ~XRP(99).
    +        env(loan::set(borrower, brokerKeylet.key, XRP(60).value()),
    +            loan::kInterestRate(TenthBips32(0)),
    +            kGracePeriod(60),
    +            kPaymentInterval(60),
    +            kPaymentTotal(1),
    +            Sig(sfCounterpartySignature, owner),
    +            Fee(env.current()->fees().base * 2));
    +        env.close();
    +
    +        // Redemption: withdrawals are allowed but subject to the AssetsAvailable cap. A small
    +        // withdrawal within AssetsAvailable succeeds. A withdrawal within the depositor's share
    +        // value but exceeding the vault's liquid balance fails with tecINSUFFICIENT_FUNDS from the
    +        // vault-shortage guard (not the insufficient-shares guard).
    +        closeToTime(env, tp{d{red}});
    +        withdraw(XRP(10).value(), tesSUCCESS);
    +        withdraw(XRP(80).value(), tecINSUFFICIENT_FUNDS);
    +    }
    +
    +    // End-to-end lifecycle of a closed-ended vault (Subscription → Investment → Redemption) with
    +    // multiple depositors and a real loan originated through the Investment leg. Exercises every
    +    // phase transition and verifies the expected deposit, withdrawal, and lending behaviour in each
    +    // phase.
    +    void
    +    testVaultClosedEndedLifecycle()
    +    {
    +        testcase("closed-ended vault lifecycle (subscribe → invest → redeem)");
    +        using namespace test::jtx;
    +        using namespace loan_broker;
    +        using namespace loan;
    +
    +        Env env{*this, testableAmendments()};
    +        Account const owner{"owner"};
    +        Account const alice{"alice"};
    +        Account const bob{"bob"};
    +        Account const borrower{"borrower"};
    +        env.fund(XRP(10'000), owner, alice, bob, borrower);
    +        env.close();
    +
    +        auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded);
    +        Asset const asset = xrpIssue();
    +        // Widen the Investment window so a single-payment loan (min payment interval
    +        // kMinPaymentInterval = 60s) fits before RedemptionDate with headroom.
    +        auto const [vault, keylet, sub, red] =
    +            makeClosedEndedVault(env, owner, asset, 300u, kMinInvestmentPeriod + 3600u);
    +
    +        auto const sleCreate = env.le(keylet);
    +        BEAST_EXPECT(sleCreate);
    +        MPTIssue const shares{sleCreate->at(sfShareMPTID)};
    +
    +        auto const balancesEq = [&](STAmount const& available, STAmount const& total) {
    +            auto const sle = env.le(keylet);
    +            BEAST_EXPECT(sle->at(sfAssetsAvailable) == available);
    +            BEAST_EXPECT(sle->at(sfAssetsTotal) == total);
    +        };
    +        auto const availableEq = [&](STAmount const& expected) { balancesEq(expected, expected); };
    +
    +        // env.balance(account, mptIssue) name-resolves the issuer via Env::lookup, but the share
    +        // issuer is the vault's pseudo-account and is never registered with the jtx Env. Read the
    +        // MPToken SLE directly to avoid the lookup.
    +        auto const sharesEq = [&](Account const& holder, std::uint64_t expected) {
    +            auto const sle = env.le(keylet::mptoken(shares.getMptID(), holder.id()));
    +            std::uint64_t const actual = sle ? sle->getFieldU64(sfMPTAmount) : 0u;
    +            BEAST_EXPECT(actual == expected);
    +        };
    +
    +        // ---- Subscription phase ----
    +        // A legitimate VaultSet succeeds (positive control for 3.7).
    +        {
    +            auto tx = vault.set({.owner = owner, .id = keylet.key});
    +            tx[sfData] = "AA";
    +            env(tx);
    +            env.close();
    +        }
    +
    +        // alice deposits 100 XRP.
    +        env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()}));
    +        env.close();
    +        sharesEq(alice, 100'000'000);
    +        availableEq(XRP(100).value());
    +
    +        // bob deposits 200 XRP.
    +        env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = XRP(200).value()}));
    +        env.close();
    +        sharesEq(bob, 200'000'000);
    +        availableEq(XRP(300).value());
    +
    +        // alice cancels 25 XRP (LP cancel is permitted in Subscription).
    +        env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(25).value()}));
    +        env.close();
    +        sharesEq(alice, 75'000'000);
    +        availableEq(XRP(275).value());
    +
    +        // Create a loan broker backed by this vault. LoanBrokerSet has no phase gate, so it is
    +        // fine to do in Subscription.
    +        auto const brokerKeylet =
    +            keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
    +        env(loan_broker::set(owner, keylet.key));
    +        env.close();
    +
    +        // ---- Investment phase (now == sub + 1) ----
    +        env.close(tp{d{sub + 1}});
    +
    +        // Deposits into a closed-ended vault past SubscriptionDate return tecEXPIRED.
    +        env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(10).value()}),
    +            Ter{tecEXPIRED});
    +        env.close();
    +        // Withdrawals from a closed-ended vault during the Investment phase return tecTOO_SOON.
    +        env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(10).value()}),
    +            Ter{tecTOO_SOON});
    +        env.close();
    +
    +        // A real loan is originated during Investment (permitted only in this phase). Zero-interest
    +        // one-payment schedule keeps AssetsTotal unchanged (both accrual and cash-basis
    +        // accounting recognise no interest at origination); AssetsAvailable drops by the loan
    +        // principal.
    +        env(loan::set(borrower, brokerKeylet.key, XRP(60).value()),
    +            loan::kInterestRate(TenthBips32(0)),
    +            kGracePeriod(60),
    +            kPaymentInterval(60),
    +            kPaymentTotal(1),
    +            Sig(sfCounterpartySignature, owner),
    +            Fee(env.current()->fees().base * 2));
    +        env.close();
    +        auto const sleBroker = env.le(keylet::loanBroker(brokerKeylet.key));
    +        BEAST_EXPECT(sleBroker);
    +        auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1u));
    +        BEAST_EXPECT(env.le(loanKeylet));
    +        balancesEq(XRP(215).value(), XRP(275).value());
    +
    +        // Non-immutable VaultSet still works in Investment (positive control).
    +        {
    +            auto tx = vault.set({.owner = owner, .id = keylet.key});
    +            tx[sfData] = "BB";
    +            env(tx);
    +            env.close();
    +        }
    +
    +        // Depositor share balances unchanged by the loan origination; only AssetsAvailable moved.
    +        sharesEq(alice, 75'000'000);
    +        sharesEq(bob, 200'000'000);
    +
    +        // ---- Redemption phase (now == red) ----
    +        env.close(tp{d{red}});
    +
    +        // Deposits into a closed-ended vault past SubscriptionDate return tecEXPIRED, in both
    +        // Investment and Redemption.
    +        env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(10).value()}),
    +            Ter{tecEXPIRED});
    +        env.close();
    +
    +        // alice redeems her remaining 75 XRP (fits within AssetsAvailable = 215).
    +        env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(75).value()}));
    +        env.close();
    +        sharesEq(alice, 0);
    +        balancesEq(XRP(140).value(), XRP(200).value());
    +
    +        // bob has 200 XRP-worth of shares but only 140 XRP is available (the remaining 60 XRP
    +        // sits in the outstanding loan). A full 200 XRP withdrawal fails against the
    +        // AssetsAvailable cap; bob redeems 140 XRP instead and is left holding 60M shares backed
    +        // by the loan receivable — the realistic outcome when capital is still deployed at
    +        // Redemption.
    +        env(vault.withdraw({.depositor = bob, .id = keylet.key, .amount = XRP(200).value()}),
    +            Ter{tecINSUFFICIENT_FUNDS});
    +        env.close();
    +        env(vault.withdraw({.depositor = bob, .id = keylet.key, .amount = XRP(140).value()}));
    +        env.close();
    +        sharesEq(bob, 60'000'000);
    +        balancesEq(XRP(0).value(), XRP(60).value());
    +
    +        // Defensive spot-check that the three immutable fields have not changed across the entire
    +        // lifecycle. Direct immutability coverage lives with the invariant tests.
    +        auto const sleFinal = env.le(keylet);
    +        if (BEAST_EXPECT(sleFinal))
    +        {
    +            BEAST_EXPECT(sleFinal->at(sfVaultKind) == closedEnded);
    +            BEAST_EXPECT(sleFinal->at(sfSubscriptionDate) == sub);
    +            BEAST_EXPECT(sleFinal->at(sfRedemptionDate) == red);
    +        }
    +    }
    +
    +    // A loan whose payment is made after the Investment phase has ended
    +    // (well past its next-due-date and grace period, into Redemption) must
    +    // still be repayable. The vault phase must not gate LoanPay.
    +    void
    +    testVaultLoanLatePaymentAfterInvestment()
    +    {
    +        testcase("closed-ended vault: late loan payment during Redemption succeeds");
    +        using namespace test::jtx;
    +        using namespace loan_broker;
    +        using namespace loan;
    +
    +        Env env{*this, testableAmendments()};
    +        Account const owner{"owner"};
    +        Account const alice{"alice"};
    +        Account const borrower{"borrower"};
    +        env.fund(XRP(10'000), owner, alice, borrower);
    +        env.close();
    +
    +        Asset const asset = xrpIssue();
    +        auto const [vault, keylet, sub, red] =
    +            makeClosedEndedVault(env, owner, asset, 300u, kMinInvestmentPeriod + 3600u);
    +
    +        env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()}));
    +        env.close();
    +
    +        auto const brokerKeylet =
    +            keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
    +        env(loan_broker::set(owner, keylet.key));
    +        env.close();
    +
    +        // Investment phase: originate a zero-interest, single-payment loan
    +        // with a 300s payment interval and 60s grace. The payment is due
    +        // shortly after origination and well before RedemptionDate.
    +        env.close(tp{d{sub + 1}});
    +        env(loan::set(borrower, brokerKeylet.key, XRP(60).value()),
    +            loan::kInterestRate(TenthBips32(0)),
    +            kGracePeriod(60),
    +            kPaymentInterval(300),
    +            kPaymentTotal(1),
    +            Sig(sfCounterpartySignature, owner),
    +            Fee(env.current()->fees().base * 2));
    +        env.close();
    +        auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1u));
    +        BEAST_EXPECT(env.le(loanKeylet));
    +
    +        // Advance to Redemption. The payment is now past its due date and
    +        // grace, and the vault is no longer in Investment.
    +        closeToTime(env, tp{d{red}});
    +
    +        env(loan::pay(borrower, loanKeylet.key, XRP(60).value(), tfLoanLatePayment));
    +        env.close();
    +
    +        // Loan principal returned to the vault; assetsAvailable == assetsTotal.
    +        auto const sleAfter = env.le(keylet);
    +        if (BEAST_EXPECT(sleAfter))
    +        {
    +            BEAST_EXPECT(sleAfter->at(sfAssetsAvailable) == sleAfter->at(sfAssetsTotal));
    +            BEAST_EXPECT(sleAfter->at(sfAssetsAvailable) == XRP(100).value());
    +        }
    +
    +        env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()}));
    +        env.close();
    +    }
    +
    +    // Two concurrent loans against the same closed-ended vault in Investment
    +    // must coexist: both loan SLEs are created, AssetsAvailable reflects the
    +    // sum of the two outstanding principals, and each can be repaid
    +    // independently.
    +    void
    +    testVaultClosedEndedMultipleLoans()
    +    {
    +        testcase("closed-ended vault: multiple concurrent loans in Investment");
    +        using namespace test::jtx;
    +        using namespace loan_broker;
    +        using namespace loan;
    +
    +        Env env{*this, testableAmendments()};
    +        Account const owner{"owner"};
    +        Account const alice{"alice"};
    +        Account const bob{"bob"};
    +        Account const borrower1{"borrower1"};
    +        Account const borrower2{"borrower2"};
    +        env.fund(XRP(10'000), owner, alice, bob, borrower1, borrower2);
    +        env.close();
    +
    +        Asset const asset = xrpIssue();
    +        auto const [vault, keylet, sub, red] =
    +            makeClosedEndedVault(env, owner, asset, 300u, kMinInvestmentPeriod + 3600u);
    +
    +        env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()}));
    +        env.close();
    +        env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = XRP(100).value()}));
    +        env.close();
    +
    +        auto const brokerKeylet =
    +            keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
    +        env(loan_broker::set(owner, keylet.key));
    +        env.close();
    +
    +        env.close(tp{d{sub + 1}});
    +
    +        auto const originate = [&](Account const& b, STAmount const& principal) {
    +            env(loan::set(b, brokerKeylet.key, principal),
    +                loan::kInterestRate(TenthBips32(0)),
    +                kGracePeriod(60),
    +                kPaymentInterval(300),
    +                kPaymentTotal(1),
    +                Sig(sfCounterpartySignature, owner),
    +                Fee(env.current()->fees().base * 2));
    +            env.close();
    +        };
    +        originate(borrower1, XRP(50).value());
    +        originate(borrower2, XRP(70).value());
    +
    +        auto const loan1 = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1u));
    +        auto const loan2 = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(2u));
    +        BEAST_EXPECT(env.le(loan1));
    +        BEAST_EXPECT(env.le(loan2));
    +
    +        // Zero-interest at origination: AssetsTotal unchanged, AssetsAvailable
    +        // drops by the sum of the two loan principals.
    +        {
    +            auto const sle = env.le(keylet);
    +            if (BEAST_EXPECT(sle))
    +            {
    +                BEAST_EXPECT(sle->at(sfAssetsTotal) == XRP(200).value());
    +                BEAST_EXPECT(sle->at(sfAssetsAvailable) == XRP(80).value());
    +            }
    +        }
    +
    +        // Repay the first loan; the second remains outstanding.
    +        env(loan::pay(borrower1, loan1.key, XRP(50).value()));
    +        env.close();
    +        {
    +            auto const sle = env.le(keylet);
    +            if (BEAST_EXPECT(sle))
    +            {
    +                BEAST_EXPECT(sle->at(sfAssetsTotal) == XRP(200).value());
    +                BEAST_EXPECT(sle->at(sfAssetsAvailable) == XRP(130).value());
    +            }
    +        }
    +
    +        // Repay the second loan; vault is fully liquid again.
    +        env(loan::pay(borrower2, loan2.key, XRP(70).value()));
    +        env.close();
    +        {
    +            auto const sle = env.le(keylet);
    +            if (BEAST_EXPECT(sle))
    +            {
    +                BEAST_EXPECT(sle->at(sfAssetsAvailable) == sle->at(sfAssetsTotal));
    +                BEAST_EXPECT(sle->at(sfAssetsAvailable) == XRP(200).value());
    +            }
    +        }
    +
    +        // Redemption: both depositors withdraw in full.
    +        env.close(tp{d{red}});
    +        env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = XRP(100).value()}));
    +        env.close();
    +        env(vault.withdraw({.depositor = bob, .id = keylet.key, .amount = XRP(100).value()}));
    +        env.close();
    +    }
    +
    +    // VaultClawback has no phase gate: an issuer must be able to reclaim
    +    // asset from a depositor in Subscription, Investment and Redemption
    +    // alike. Uses an IOU with asfAllowTrustLineClawback so the issuer path
    +    // is exercised (XRP clawback with an explicit amount is temMALFORMED).
    +    void
    +    testVaultClawbackClosedEndedPhases()
    +    {
    +        testcase("closed-ended vault: VaultClawback succeeds in each phase");
    +        using namespace test::jtx;
    +
    +        Env env{*this, testableAmendments()};
    +        Account const issuer{"issuer"};
    +        Account const owner{"owner"};
    +        Account const alice{"alice"};
    +        env.fund(XRP(10'000), issuer, owner, alice);
    +        env.close();
    +
    +        env(fset(issuer, asfAllowTrustLineClawback));
    +        env.close();
    +
    +        PrettyAsset const iou = issuer["IOU"];
    +        env.trust(iou(10'000), alice);
    +        env(pay(issuer, alice, iou(1'000)));
    +        env.close();
    +
    +        auto const [vault, keylet, sub, red] =
    +            makeClosedEndedVault(env, owner, iou, 300u, kMinInvestmentPeriod + 3600u);
    +
    +        env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = iou(300).value()}));
    +        env.close();
    +
    +        auto const totalsEq = [&](STAmount const& expected) {
    +            auto const sle = env.le(keylet);
    +            if (BEAST_EXPECT(sle))
    +                BEAST_EXPECT(sle->at(sfAssetsTotal) == expected);
    +        };
    +
    +        // Subscription phase clawback.
    +        env(vault.clawback(
    +            {.issuer = issuer, .id = keylet.key, .holder = alice, .amount = iou(10).value()}));
    +        env.close();
    +        totalsEq(iou(290).value());
    +
    +        // Investment phase clawback.
    +        env.close(tp{d{sub + 1}});
    +        env(vault.clawback(
    +            {.issuer = issuer, .id = keylet.key, .holder = alice, .amount = iou(10).value()}));
    +        env.close();
    +        totalsEq(iou(280).value());
    +
    +        // Redemption phase clawback.
    +        env.close(tp{d{red}});
    +        env(vault.clawback(
    +            {.issuer = issuer, .id = keylet.key, .holder = alice, .amount = iou(10).value()}));
    +        env.close();
    +        totalsEq(iou(270).value());
    +    }
    +
    +public:
    +    void
    +    run() override
    +    {
    +        testVaultCreateClosedEnded();
    +        testVaultCreateSubscriptionDateBoundary();
    +        testVaultPhaseDerivation();
    +        testVaultPhaseDerivationOpenEnded();
    +        testVaultDepositClosedEnded();
    +        testVaultWithdrawClosedEnded();
    +        testVaultClosedEndedLifecycle();
    +        testVaultLoanLatePaymentAfterInvestment();
    +        testVaultClosedEndedMultipleLoans();
    +        testVaultClawbackClosedEndedPhases();
    +    }
    +};
    +
    +BEAST_DEFINE_TESTSUITE_PRIO(VaultClosedEnded, app, xrpl, 1);
    +
    +}  // namespace xrpl
    diff --git a/src/test/app/vault/VaultDomain_test.cpp b/src/test/app/vault/VaultDomain_test.cpp
    new file mode 100644
    index 0000000000..db8943921b
    --- /dev/null
    +++ b/src/test/app/vault/VaultDomain_test.cpp
    @@ -0,0 +1,586 @@
    +#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 {
    +
    +class VaultDomain_test : public VaultTestBase
    +{
    +private:
    +    void
    +    testWithDomainCheck()
    +    {
    +        using namespace test::jtx;
    +
    +        testcase("private vault");
    +
    +        Env env{*this, testableAmendments()};
    +        Account const issuer{"issuer"};
    +        Account const owner{"owner"};
    +        Account const depositor{"depositor"};
    +        Account const charlie{"charlie"};
    +        Account const pdOwner{"pdOwner"};
    +        Account const credIssuer1{"credIssuer1"};
    +        Account const credIssuer2{"credIssuer2"};
    +        std::string const credType = "credential";
    +        Vault const vault{env};
    +        env.fund(XRP(1000), issuer, owner, depositor, charlie, pdOwner, credIssuer1, credIssuer2);
    +        env.close();
    +        env(fset(issuer, asfAllowTrustLineClawback));
    +        env.close();
    +        env.require(Flags(issuer, asfAllowTrustLineClawback));
    +
    +        PrettyAsset const asset = issuer["IOU"];
    +        env.trust(asset(1000), owner);
    +        env(pay(issuer, owner, asset(500)));
    +        env.trust(asset(1000), depositor);
    +        env(pay(issuer, depositor, asset(500)));
    +        env.trust(asset(1000), charlie);
    +        env(pay(issuer, charlie, asset(5)));
    +        env.close();
    +
    +        auto [tx, keylet] = vault.create({.owner = owner, .asset = asset, .flags = tfVaultPrivate});
    +        env(tx);
    +        env.close();
    +        BEAST_EXPECT(env.le(keylet));
    +
    +        {
    +            testcase("private vault owner can deposit");
    +            auto tx = vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(50)});
    +            env(tx);
    +        }
    +
    +        {
    +            testcase("private vault depositor not authorized yet");
    +            auto tx =
    +                vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
    +            env(tx, Ter{tecNO_AUTH});
    +        }
    +
    +        {
    +            testcase("private vault cannot set non-existing domain");
    +            auto tx = vault.set({.owner = owner, .id = keylet.key});
    +            tx[sfDomainID] = to_string(BaseUInt<256>(42ul));
    +            env(tx, Ter{tecOBJECT_NOT_FOUND});
    +        }
    +
    +        {
    +            testcase("private vault set domainId");
    +
    +            {
    +                pdomain::Credentials const credentials1{
    +                    {.issuer = credIssuer1, .credType = credType}};
    +
    +                env(pdomain::setTx(pdOwner, credentials1));
    +                auto const domainId1 = [&]() {
    +                    auto tx = env.tx()->getJson(JsonOptions::Values::None);
    +                    return pdomain::getNewDomain(env.meta());
    +                }();
    +
    +                auto tx = vault.set({.owner = owner, .id = keylet.key});
    +                tx[sfDomainID] = to_string(domainId1);
    +                env(tx);
    +                env.close();
    +
    +                // Update domain second time, should be harmless
    +                env(tx);
    +                env.close();
    +            }
    +
    +            {
    +                pdomain::Credentials const credentials{
    +                    {.issuer = credIssuer1, .credType = credType},
    +                    {.issuer = credIssuer2, .credType = credType}};
    +
    +                env(pdomain::setTx(pdOwner, credentials));
    +                auto const domainId = [&]() {
    +                    auto tx = env.tx()->getJson(JsonOptions::Values::None);
    +                    return pdomain::getNewDomain(env.meta());
    +                }();
    +
    +                auto tx = vault.set({.owner = owner, .id = keylet.key});
    +                tx[sfDomainID] = to_string(domainId);
    +                env(tx);
    +                env.close();
    +
    +                // Should be idempotent
    +                tx = vault.set({.owner = owner, .id = keylet.key});
    +                tx[sfDomainID] = to_string(domainId);
    +                env(tx);
    +                env.close();
    +            }
    +        }
    +
    +        {
    +            testcase("private vault depositor still not authorized");
    +            auto tx =
    +                vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
    +            env(tx, Ter{tecNO_AUTH});
    +            env.close();
    +        }
    +
    +        auto const credKeylet = credentials::keylet(depositor, credIssuer1, credType);
    +        {
    +            testcase("private vault depositor now authorized");
    +            env(credentials::create(depositor, credIssuer1, credType));
    +            env(credentials::accept(depositor, credIssuer1, credType));
    +            env(credentials::create(charlie, credIssuer1, credType));
    +            // charlie's credential not accepted
    +            env.close();
    +            auto credSle = env.le(credKeylet);
    +            BEAST_EXPECT(credSle != nullptr);
    +
    +            auto tx =
    +                vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
    +            env(tx);
    +            env.close();
    +
    +            tx = vault.deposit({.depositor = charlie, .id = keylet.key, .amount = asset(50)});
    +            env(tx, Ter{tecNO_AUTH});
    +            env.close();
    +        }
    +
    +        {
    +            testcase("private vault depositor lost authorization");
    +            env(credentials::deleteCred(credIssuer1, depositor, credIssuer1, credType));
    +            env(credentials::deleteCred(credIssuer1, charlie, credIssuer1, credType));
    +            env.close();
    +            auto credSle = env.le(credKeylet);
    +            BEAST_EXPECT(credSle == nullptr);
    +
    +            auto tx =
    +                vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
    +            env(tx, Ter{tecNO_AUTH});
    +            env.close();
    +        }
    +
    +        auto const shares = [&env, keylet = keylet, this]() -> Asset {
    +            auto const vault = env.le(keylet);
    +            BEAST_EXPECT(vault != nullptr);
    +            return MPTIssue(vault->at(sfShareMPTID));
    +        }();
    +
    +        {
    +            testcase("private vault expired authorization");
    +            uint32_t const closeTime =
    +                env.current()->header().parentCloseTime.time_since_epoch().count();
    +            {
    +                auto tx0 = credentials::create(depositor, credIssuer2, credType);
    +                tx0[sfExpiration] = closeTime + 20;
    +                env(tx0);
    +                tx0 = credentials::create(charlie, credIssuer2, credType);
    +                tx0[sfExpiration] = closeTime + 20;
    +                env(tx0);
    +                env.close();
    +
    +                env(credentials::accept(depositor, credIssuer2, credType));
    +                env(credentials::accept(charlie, credIssuer2, credType));
    +                env.close();
    +            }
    +
    +            {
    +                auto tx1 =
    +                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
    +                env(tx1);
    +                env.close();
    +
    +                auto const tokenKeylet =
    +                    keylet::mptoken(shares.get().getMptID(), depositor.id());
    +                BEAST_EXPECT(env.le(tokenKeylet) != nullptr);
    +            }
    +
    +            {
    +                // time advance
    +                env.close();
    +                env.close();
    +                env.close();
    +
    +                auto const credsKeylet = credentials::keylet(depositor, credIssuer2, credType);
    +                BEAST_EXPECT(env.le(credsKeylet) != nullptr);
    +
    +                auto tx2 =
    +                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1)});
    +                env(tx2, Ter{tecEXPIRED});
    +                env.close();
    +
    +                BEAST_EXPECT(env.le(credsKeylet) == nullptr);
    +            }
    +
    +            {
    +                auto const credsKeylet = credentials::keylet(charlie, credIssuer2, credType);
    +                BEAST_EXPECT(env.le(credsKeylet) != nullptr);
    +                auto const tokenKeylet =
    +                    keylet::mptoken(shares.get().getMptID(), charlie.id());
    +                BEAST_EXPECT(env.le(tokenKeylet) == nullptr);
    +
    +                auto tx3 =
    +                    vault.deposit({.depositor = charlie, .id = keylet.key, .amount = asset(2)});
    +                env(tx3, Ter{tecEXPIRED});
    +
    +                env.close();
    +                BEAST_EXPECT(env.le(credsKeylet) == nullptr);
    +                BEAST_EXPECT(env.le(tokenKeylet) == nullptr);
    +            }
    +        }
    +
    +        {
    +            testcase("private vault reset domainId");
    +            auto tx = vault.set({.owner = owner, .id = keylet.key});
    +            tx[sfDomainID] = "0";
    +            env(tx);
    +            env.close();
    +
    +            tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
    +            env(tx, Ter{tecNO_AUTH});
    +            env.close();
    +
    +            tx = vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
    +            env(tx);
    +            env.close();
    +
    +            tx = vault.clawback(
    +                {.issuer = issuer, .id = keylet.key, .holder = depositor, .amount = asset(0)});
    +            env(tx);
    +
    +            tx = vault.clawback(
    +                {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(0)});
    +            env(tx);
    +            env.close();
    +
    +            tx = vault.del({
    +                .owner = owner,
    +                .id = keylet.key,
    +            });
    +            env(tx);
    +        }
    +    }
    +
    +    void
    +    testDomainLossAfterAcquisition()
    +    {
    +        using namespace test::jtx;
    +
    +        testcase("private vault share transfer after depositor loses domain");
    +
    +        // The "Private Vault - Access Control Rules" spec requires that a holder who
    +        // loses Layer 2 (Permissioned Domain membership) after acquiring shares be
    +        // blocked from sending them onward, by P2P transfer or DEX offer, the same
    +        // way a brand-new never-authorized holder is blocked. Only withdrawal to
    +        // self is meant to stay open.
    +        //
    +        // For a domain-gated share MPToken, requireAuth()'s escape hatch for
    +        // holders who already have an MPToken (MPTokenHelpers.cpp) only applies to
    +        // the classic explicit-issuer-authorization flag, which
    +        // enforceMPTokenAuthorization documents as "meaningless" for
    +        // domain-authorized holders and never sets. So a stale MPToken does not
    +        // carry authorization forward once the account's domain credential is
    +        // gone, and both actions below are correctly blocked.
    +
    +        Env env{*this, testableAmendments()};
    +        Account const issuer{"issuer"};
    +        Account const owner{"owner"};
    +        Account const depositor{"depositor"};
    +        Account const bob{"bob"};
    +        Account const pdOwner{"pdOwner"};
    +        Account const credIssuer{"credIssuer"};
    +        std::string const credType = "credential";
    +        Vault const vault{env};
    +        env.fund(XRP(1000), issuer, owner, depositor, bob, pdOwner, credIssuer);
    +        env.close();
    +
    +        PrettyAsset const asset = issuer["IOU"];
    +        env.trust(asset(1000), owner);
    +        env(pay(issuer, owner, asset(500)));
    +        env.trust(asset(1000), depositor);
    +        env(pay(issuer, depositor, asset(500)));
    +        env.trust(asset(1000), bob);
    +        env(pay(issuer, bob, asset(500)));
    +        env.close();
    +
    +        // Transferable shares (no tfVaultShareNonTransferable): sections 3.3/3.4 of
    +        // the spec (DEX trading / P2P transfer) only apply to transferable shares.
    +        auto [tx, keylet] = vault.create({.owner = owner, .asset = asset, .flags = tfVaultPrivate});
    +        env(tx);
    +        env.close();
    +
    +        pdomain::Credentials const credentials{{.issuer = credIssuer, .credType = credType}};
    +        env(pdomain::setTx(pdOwner, credentials));
    +        auto const domainId = [&]() {
    +            auto tx = env.tx()->getJson(JsonOptions::Values::None);
    +            return pdomain::getNewDomain(env.meta());
    +        }();
    +        {
    +            auto domainTx = vault.set({.owner = owner, .id = keylet.key});
    +            domainTx[sfDomainID] = to_string(domainId);
    +            env(domainTx);
    +            env.close();
    +        }
    +
    +        // Both depositor and bob acquire domain membership and deposit, so each
    +        // ends up with an authorized share MPToken.
    +        env(credentials::create(depositor, credIssuer, credType));
    +        env(credentials::accept(depositor, credIssuer, credType));
    +        env(credentials::create(bob, credIssuer, credType));
    +        env(credentials::accept(bob, credIssuer, credType));
    +        env.close();
    +
    +        env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)}));
    +        env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = asset(100)}));
    +        env.close();
    +
    +        auto const shares = [&env, keylet = keylet, this]() -> PrettyAsset {
    +            auto const sle = env.le(keylet);
    +            BEAST_EXPECT(sle != nullptr);
    +            return MPTIssue(sle->at(sfShareMPTID));
    +        }();
    +
    +        // Depositor loses Layer 2: their Permissioned Domain credential is revoked.
    +        auto const credKeylet = credentials::keylet(depositor, credIssuer, credType);
    +        env(credentials::deleteCred(credIssuer, depositor, credIssuer, credType));
    +        env.close();
    +        BEAST_EXPECT(env.le(credKeylet) == nullptr);
    +
    +        // Sanity check, mirrors testWithDomainCheck's "not authorized yet" case: a
    +        // brand-new depositor with no MPToken yet is still correctly blocked. The
    +        // gap below is specific to holders who already hold shares.
    +        {
    +            Account const charlie{"charlie"};
    +            env.fund(XRP(1000), charlie);
    +            env.close();
    +            auto depTx =
    +                vault.deposit({.depositor = charlie, .id = keylet.key, .amount = asset(1)});
    +            env(depTx, Ter{tecNO_AUTH});
    +        }
    +
    +        // P2P transfer: spec section 3.4 requires this blocked once Layer 2 is
    +        // lost, and it is.
    +        env(pay(depositor, bob, shares(1)), Ter{tecNO_AUTH});
    +        env.close();
    +
    +        // DEX/CLOB: spec section 3.3 requires the seller leg blocked the same way.
    +        // The offer can't even be created: preclaim treats the seller as
    +        // unfunded once their share balance reads as zero for auth purposes.
    +        env(offer(depositor, XRP(1), shares(1)), Ter{tecUNFUNDED_OFFER});
    +        env.close();
    +        BEAST_EXPECT(expectOffers(env, depositor, 0));
    +    }
    +
    +    void
    +    testDomainCheckBuyerSideOffer()
    +    {
    +        using namespace test::jtx;
    +
    +        testcase("private vault share purchase via DEX requires buyer domain membership");
    +
    +        // The "Private Vault - Access Control Rules" spec requires the buyer leg
    +        // of a DEX trade in private-vault shares to hold Layer 1 and Layer 2 as
    +        // well, not just the seller.
    +
    +        Env env{*this, testableAmendments()};
    +        Account const issuer{"issuer"};
    +        Account const owner{"owner"};
    +        Account const bob{"bob"};
    +        Account const charlie{"charlie"};
    +        Account const pdOwner{"pdOwner"};
    +        Account const credIssuer{"credIssuer"};
    +        std::string const credType = "credential";
    +        Vault const vault{env};
    +        env.fund(XRP(1000), issuer, owner, bob, charlie, pdOwner, credIssuer);
    +        env.close();
    +
    +        PrettyAsset const asset = issuer["IOU"];
    +        env.trust(asset(1000), owner);
    +        env(pay(issuer, owner, asset(500)));
    +        env.trust(asset(1000), bob);
    +        env(pay(issuer, bob, asset(500)));
    +        env.close();
    +
    +        auto [tx, keylet] = vault.create({.owner = owner, .asset = asset, .flags = tfVaultPrivate});
    +        env(tx);
    +        env.close();
    +
    +        pdomain::Credentials const credentials{{.issuer = credIssuer, .credType = credType}};
    +        env(pdomain::setTx(pdOwner, credentials));
    +        auto const domainId = [&]() {
    +            auto tx = env.tx()->getJson(JsonOptions::Values::None);
    +            return pdomain::getNewDomain(env.meta());
    +        }();
    +        {
    +            auto domainTx = vault.set({.owner = owner, .id = keylet.key});
    +            domainTx[sfDomainID] = to_string(domainId);
    +            env(domainTx);
    +            env.close();
    +        }
    +
    +        // Only bob joins the domain and deposits; charlie never does.
    +        env(credentials::create(bob, credIssuer, credType));
    +        env(credentials::accept(bob, credIssuer, credType));
    +        env.close();
    +        env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = asset(100)}));
    +        env.close();
    +
    +        auto const shares = [&env, keylet = keylet, this]() -> PrettyAsset {
    +            auto const sle = env.le(keylet);
    +            BEAST_EXPECT(sle != nullptr);
    +            return MPTIssue(sle->at(sfShareMPTID));
    +        }();
    +
    +        // Bob (domain member, holds shares) rests a sell offer.
    +        env(offer(bob, XRP(1), shares(1)));
    +        env.close();
    +        BEAST_EXPECT(expectOffers(env, bob, 1));
    +
    +        // Charlie never held the domain credential. Buying shares via a
    +        // crossing offer must be blocked the same way a direct MPTokenAuthorize
    +        // + pay attempt already is (see testWithDomainChecXRP's "cannot pay
    +        // shares to 3rd party"): checkAcceptAsset() rejects the offer outright
    +        // in preclaim, before any funding check is even reached.
    +        env(offer(charlie, shares(1), XRP(1)), Ter{tecNO_AUTH});
    +        env.close();
    +        BEAST_EXPECT(expectOffers(env, bob, 1));
    +        BEAST_EXPECT(expectOffers(env, charlie, 0));
    +    }
    +
    +    void
    +    testWithDomainChecXRP()
    +    {
    +        using namespace test::jtx;
    +
    +        testcase("private XRP vault");
    +
    +        Env env{*this, testableAmendments()};
    +        Account const owner{"owner"};
    +        Account const depositor{"depositor"};
    +        Account const alice{"charlie"};
    +        std::string const credType = "credential";
    +        Vault const vault{env};
    +        env.fund(XRP(100000), owner, depositor, alice);
    +        env.close();
    +
    +        PrettyAsset const asset = xrpIssue();
    +        auto [tx, keylet] = vault.create({.owner = owner, .asset = asset, .flags = tfVaultPrivate});
    +        env(tx);
    +        env.close();
    +
    +        auto const [vaultAccount, issuanceId] =
    +            [&env, keylet = keylet, this]() -> std::tuple {
    +            auto const vault = env.le(keylet);
    +            BEAST_EXPECT(vault != nullptr);
    +            return {vault->at(sfAccount), vault->at(sfShareMPTID)};
    +        }();
    +        BEAST_EXPECT(env.le(keylet::account(vaultAccount)));
    +        BEAST_EXPECT(env.le(keylet::mptokenIssuance(issuanceId)));
    +        PrettyAsset const shares{issuanceId};
    +
    +        {
    +            testcase("private XRP vault owner can deposit");
    +            auto tx = vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(50)});
    +            env(tx);
    +            env.close();
    +        }
    +
    +        {
    +            testcase("private XRP vault cannot pay shares to depositor yet");
    +            env(pay(owner, depositor, shares(1)), Ter{tecNO_AUTH});
    +        }
    +
    +        {
    +            testcase("private XRP vault depositor not authorized yet");
    +            auto tx =
    +                vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
    +            env(tx, Ter{tecNO_AUTH});
    +        }
    +
    +        {
    +            testcase("private XRP vault set DomainID");
    +            pdomain::Credentials const credentials{{.issuer = owner, .credType = credType}};
    +
    +            env(pdomain::setTx(owner, credentials));
    +            auto const domainId = [&]() {
    +                auto tx = env.tx()->getJson(JsonOptions::Values::None);
    +                return pdomain::getNewDomain(env.meta());
    +            }();
    +
    +            auto tx = vault.set({.owner = owner, .id = keylet.key});
    +            tx[sfDomainID] = to_string(domainId);
    +            env(tx);
    +            env.close();
    +        }
    +
    +        auto const credKeylet = credentials::keylet(depositor, owner, credType);
    +        {
    +            testcase("private XRP vault depositor now authorized");
    +            env(credentials::create(depositor, owner, credType));
    +            env(credentials::accept(depositor, owner, credType));
    +            env.close();
    +
    +            BEAST_EXPECT(env.le(credKeylet));
    +            auto tx =
    +                vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
    +            env(tx);
    +            env.close();
    +        }
    +
    +        {
    +            testcase("private XRP vault can pay shares to depositor");
    +            env(pay(owner, depositor, shares(1)));
    +        }
    +
    +        {
    +            testcase("private XRP vault cannot pay shares to 3rd party");
    +            json::Value jv;
    +            jv[sfAccount] = alice.human();
    +            jv[sfTransactionType] = jss::MPTokenAuthorize;
    +            jv[sfMPTokenIssuanceID] = to_string(issuanceId);
    +            env(jv);
    +            env.close();
    +
    +            env(pay(owner, alice, shares(1)), Ter{tecNO_AUTH});
    +        }
    +    }
    +
    +public:
    +    void
    +    run() override
    +    {
    +        testWithDomainCheck();
    +        testDomainLossAfterAcquisition();
    +        testDomainCheckBuyerSideOffer();
    +        testWithDomainChecXRP();
    +    }
    +};
    +
    +BEAST_DEFINE_TESTSUITE(VaultDomain, app, xrpl);
    +
    +}  // namespace xrpl
    diff --git a/src/test/app/vault/VaultFreeze_test.cpp b/src/test/app/vault/VaultFreeze_test.cpp
    new file mode 100644
    index 0000000000..120aabc8f6
    --- /dev/null
    +++ b/src/test/app/vault/VaultFreeze_test.cpp
    @@ -0,0 +1,691 @@
    +#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 {
    +
    +class VaultFreeze_test : public VaultTestBase
    +{
    +private:
    +    void
    +    testVaultDepositFreezeIOU()
    +    {
    +        using namespace test::jtx;
    +        testcase("VaultDeposit IOU freeze checks");
    +
    +        Account const issuer{"issuer"};
    +        Account const owner{"owner"};
    +        Env env{*this};
    +        Vault vault{env};
    +
    +        env.fund(XRP(100'000), issuer, owner);
    +        env(fset(issuer, asfAllowTrustLineClawback));
    +        env.close();
    +        PrettyAsset const asset = issuer["IOU"];
    +        env.trust(asset(1'000'000), owner);
    +        env(pay(issuer, owner, asset(100'000)));
    +        env.close();
    +
    +        auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +        env(tx);
    +        env.close();
    +        auto const vaultAcct = Account("vault", env.le(keylet)->at(sfAccount));
    +
    +        // Initial deposit so the vault pseudo-account has a trustline
    +        env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)}));
    +        env.close();
    +
    +        auto runTests = [&]() {
    +            auto const fix330Enabled = env.current()->rules().enabled(fixCleanup3_3_0);
    +
    +            // Global freeze
    +            {
    +                testcase("VaultDeposit IOU global freeze");
    +                env(fset(issuer, asfGlobalFreeze));
    +                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
    +                    Ter(tecFROZEN));
    +                env(fclear(issuer, asfGlobalFreeze));
    +            }
    +
    +            // Depositor freeze
    +            {
    +                testcase("VaultDeposit IOU depositor freeze");
    +                env(trust(issuer, asset(0), owner, tfSetFreeze));
    +                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
    +                    Ter(tecFROZEN));
    +                env(trust(issuer, asset(0), owner, tfClearFreeze));
    +            }
    +
    +            // Depositor deep freeze
    +            {
    +                testcase("VaultDeposit IOU depositor deep freeze");
    +                env(trust(issuer, asset(0), owner, tfSetFreeze | tfSetDeepFreeze));
    +                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
    +                    Ter(tecFROZEN));
    +                env(trust(issuer, asset(0), owner, tfClearFreeze | tfClearDeepFreeze));
    +            }
    +
    +            // Vault-account freeze
    +            // Post-fix: checkDepositFreeze catches it → tecFROZEN
    +            // Pre-fix: not checked directly, but the transitive share
    +            //          check triggers → tecLOCKED
    +            {
    +                testcase("VaultDeposit IOU pseudo-account freeze");
    +                auto trustSet = [&]() {
    +                    json::Value jv;
    +                    jv[jss::Account] = issuer.human();
    +                    {
    +                        auto& ja = jv[jss::LimitAmount] =
    +                            asset(0).value().getJson(JsonOptions::Values::None);
    +                        ja[jss::issuer] = toBase58(vaultAcct.id());
    +                    }
    +                    jv[jss::TransactionType] = jss::TrustSet;
    +                    return jv;
    +                }();
    +
    +                trustSet[jss::Flags] = tfSetFreeze;
    +                env(trustSet);
    +                env.close();
    +
    +                TER const expected = fix330Enabled ? TER(tecFROZEN) : TER(tecLOCKED);
    +                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
    +                    Ter(expected));
    +
    +                trustSet[jss::Flags] = tfClearFreeze;
    +                env(trustSet);
    +                env.close();
    +            }
    +
    +            // Vault-account deep freeze
    +            {
    +                testcase("VaultDeposit IOU pseudo-account deep freeze");
    +                auto trustSet = [&]() {
    +                    json::Value jv;
    +                    jv[jss::Account] = issuer.human();
    +                    {
    +                        auto& ja = jv[jss::LimitAmount] =
    +                            asset(0).value().getJson(JsonOptions::Values::None);
    +                        ja[jss::issuer] = toBase58(vaultAcct.id());
    +                    }
    +                    jv[jss::TransactionType] = jss::TrustSet;
    +                    return jv;
    +                }();
    +
    +                trustSet[jss::Flags] = tfSetFreeze | tfSetDeepFreeze;
    +                env(trustSet);
    +                env.close();
    +
    +                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
    +                    Ter(fix330Enabled ? TER(tecFROZEN) : TER(tecLOCKED)));
    +
    +                trustSet[jss::Flags] = tfClearFreeze | tfClearDeepFreeze;
    +                env(trustSet);
    +                env.close();
    +            }
    +
    +            // Clawback works while frozen
    +            {
    +                testcase("VaultDeposit IOU freeze clawback unaffected");
    +                env(fset(issuer, asfGlobalFreeze));
    +                env(vault.clawback(
    +                    {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(1)}));
    +                env(fclear(issuer, asfGlobalFreeze));
    +                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}));
    +                env.close();
    +            }
    +        };
    +
    +        runTests();
    +        env.disableFeature(fixCleanup3_3_0);
    +        runTests();
    +        env.enableFeature(fixCleanup3_3_0);
    +    }
    +
    +    void
    +    testVaultDepositFreezeMPT()
    +    {
    +        using namespace test::jtx;
    +        testcase("VaultDeposit MPT lock checks");
    +
    +        Account const issuer{"issuer"};
    +        Account const owner{"owner"};
    +        Env env{*this};
    +        Vault vault{env};
    +
    +        env.fund(XRP(100'000), issuer, owner);
    +        env.close();
    +
    +        MPTTester mptt{env, issuer, kMptInitNoFund};
    +        mptt.create(
    +            {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock | tfMPTRequireAuth});
    +        PrettyAsset const mpt{mptt.issuanceID()};
    +
    +        mptt.authorize({.account = owner});
    +        mptt.authorize({.account = issuer, .holder = owner});
    +        env.close();
    +        env(pay(issuer, owner, mpt(100'000)));
    +        env.close();
    +
    +        auto [tx, keylet] = vault.create({.owner = owner, .asset = mpt});
    +        env(tx);
    +        env.close();
    +        auto const vaultAcctID = env.le(keylet)->at(sfAccount);
    +        Account const vaultAcct("vault", vaultAcctID);
    +
    +        env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(100)}));
    +        env.close();
    +
    +        // For MPT isDeepFrozen == isFrozen, so all locks block in
    +        // both pre- and post-fix.
    +        auto runTests = [&]() {
    +            // Global lock
    +            {
    +                testcase("VaultDeposit MPT global lock");
    +                mptt.set({.flags = tfMPTLock});
    +                env.close();
    +                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}),
    +                    Ter(tecLOCKED));
    +                mptt.set({.flags = tfMPTUnlock});
    +                env.close();
    +            }
    +
    +            // Depositor individual lock
    +            {
    +                testcase("VaultDeposit MPT depositor lock");
    +                mptt.set({.holder = owner, .flags = tfMPTLock});
    +                env.close();
    +                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}),
    +                    Ter(tecLOCKED));
    +                mptt.set({.holder = owner, .flags = tfMPTUnlock});
    +                env.close();
    +            }
    +
    +            // Vault pseudo-account individual lock
    +            {
    +                testcase("VaultDeposit MPT pseudo-account lock");
    +                mptt.set({.holder = vaultAcct, .flags = tfMPTLock});
    +                env.close();
    +                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}),
    +                    Ter(tecLOCKED));
    +                mptt.set({.holder = vaultAcct, .flags = tfMPTUnlock});
    +                env.close();
    +            }
    +
    +            // Clawback works while locked
    +            {
    +                testcase("VaultDeposit MPT lock clawback unaffected");
    +                mptt.set({.flags = tfMPTLock});
    +                env.close();
    +                env(vault.clawback(
    +                    {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = mpt(1)}));
    +                mptt.set({.flags = tfMPTUnlock});
    +                env.close();
    +                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}));
    +                env.close();
    +            }
    +        };
    +
    +        runTests();
    +        env.disableFeature(fixCleanup3_3_0);
    +        runTests();
    +        env.enableFeature(fixCleanup3_3_0);
    +    }
    +
    +    void
    +    testVaultWithdrawFreezeIOU()
    +    {
    +        using namespace test::jtx;
    +        testcase("VaultWithdraw IOU freeze checks");
    +
    +        Account const issuer{"issuer"};
    +        Account const owner{"owner"};
    +        Env env{*this};
    +        Vault const vault{env};
    +
    +        env.fund(XRP(100'000), issuer, owner);
    +        env(fset(issuer, asfAllowTrustLineClawback));
    +        env.close();
    +        PrettyAsset const asset = issuer["IOU"];
    +        env.trust(asset(1'000'000), owner);
    +        env(pay(issuer, owner, asset(100'000)));
    +        env.close();
    +
    +        auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +        env(tx);
    +        env.close();
    +        auto const vaultAcct = Account("vault", env.le(keylet)->at(sfAccount));
    +
    +        env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)}));
    +        env.close();
    +
    +        Account const charlie{"charlie"};
    +        env.fund(XRP(10'000), charlie);
    +        env.trust(asset(1'000'000), charlie);
    +        env.close();
    +
    +        auto runTests = [&]() {
    +            auto const fix330Enabled = env.current()->rules().enabled(fixCleanup3_3_0);
    +            // Global freeze → self-withdraw
    +            {
    +                testcase("VaultWithdraw IOU global freeze");
    +                env(fset(issuer, asfGlobalFreeze));
    +                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
    +                    Ter(tecFROZEN));
    +                // Global freeze → withdraw to 3rd party
    +
    +                auto withdrawToCharlie =
    +                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)});
    +                withdrawToCharlie[sfDestination] = charlie.human();
    +                env(withdrawToCharlie, Ter(tecFROZEN));
    +
    +                env(fclear(issuer, asfGlobalFreeze));
    +            }
    +
    +            // Vault-account freeze
    +            {
    +                testcase("VaultWithdraw IOU pseudo-account freeze");
    +                auto trustSet = [&]() {
    +                    json::Value jv;
    +                    jv[jss::Account] = issuer.human();
    +                    {
    +                        auto& ja = jv[jss::LimitAmount] =
    +                            asset(0).value().getJson(JsonOptions::Values::None);
    +                        ja[jss::issuer] = toBase58(vaultAcct.id());
    +                    }
    +                    jv[jss::TransactionType] = jss::TrustSet;
    +                    return jv;
    +                }();
    +
    +                trustSet[jss::Flags] = tfSetFreeze;
    +                env(trustSet);
    +                env.close();
    +
    +                TER const terExpected = fix330Enabled ? TER(tecFROZEN) : TER(tecLOCKED);
    +
    +                // Self-withdraw
    +                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
    +                    Ter(terExpected));
    +                // Withdraw to 3rd party
    +
    +                auto withdrawToCharlie =
    +                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)});
    +                withdrawToCharlie[sfDestination] = charlie.human();
    +                env(withdrawToCharlie, Ter(terExpected));
    +
    +                trustSet[jss::Flags] = tfClearFreeze;
    +                env(trustSet);
    +                env.close();
    +            }
    +
    +            // Depositor freeze, self-withdraw
    +            {
    +                testcase("VaultWithdraw IOU self-withdraw freeze check");
    +                env(trust(issuer, asset(0), owner, tfSetFreeze));
    +
    +                // Post-fix: self-withdraw allowed (submitter==dst skip)
    +                // Pre-fix: isFrozen(depositor, iou) catches it
    +                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
    +                    Ter(fix330Enabled ? TER(tesSUCCESS) : TER(tecFROZEN)));
    +
    +                // Depositor freeze withdraw to 3rd party
    +                auto withdrawTo3rd =
    +                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)});
    +                withdrawTo3rd[sfDestination] = charlie.human();
    +
    +                // Post-fix: submitter freeze blocks withdraw to 3rd party
    +                // Pre-fix: submitter's IOU freeze not checked, but checkFrozen(depositor,
    +                // share) triggers tecLOCKED
    +                env(withdrawTo3rd, Ter(fix330Enabled ? TER(tecFROZEN) : TER(tecLOCKED)));
    +
    +                env(trust(issuer, asset(0), owner, tfClearFreeze));
    +                // Replenish what was withdrawn
    +                if (fix330Enabled)
    +                {
    +                    env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}));
    +                }
    +                env.close();
    +            }
    +
    +            // Depositor deep freeze → self-withdraw blocked
    +            {
    +                testcase("VaultWithdraw IOU depositor deep freeze");
    +                env(trust(issuer, asset(0), owner, tfSetFreeze | tfSetDeepFreeze));
    +
    +                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
    +                    Ter(tecFROZEN));
    +
    +                env(trust(issuer, asset(0), owner, tfClearFreeze | tfClearDeepFreeze));
    +            }
    +
    +            // Destination freeze → withdraw to 3rd party
    +            {
    +                testcase("VaultWithdraw IOU freeze withdraw to 3rd party");
    +
    +                env(trust(issuer, asset(0), charlie, tfSetFreeze));
    +
    +                // Self-withdraw unaffected by charlie's freeze
    +                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)}));
    +
    +                auto withdrawToCharlie =
    +                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)});
    +                withdrawToCharlie[sfDestination] = charlie.human();
    +
    +                // Post-fix: freeze on dst allowed
    +                // Pre-fix: checkFrozen(dst, iou) catches it
    +                env(withdrawToCharlie, Ter(fix330Enabled ? TER(tesSUCCESS) : TER(tecFROZEN)));
    +
    +                env(trust(issuer, asset(0), charlie, tfClearFreeze));
    +
    +                // Replenish: 1 for self-withdraw + 1 if charlie withdraw succeeded
    +                env(vault.deposit(
    +                    {.depositor = owner,
    +                     .id = keylet.key,
    +                     .amount = asset(fix330Enabled ? 2 : 1)}));
    +                env.close();
    +            }
    +
    +            // Destination deep freeze → withdraw to 3rd party blocked
    +            {
    +                testcase("VaultWithdraw IOU deep freeze withdraw to 3rd party");
    +
    +                env(trust(issuer, asset(0), charlie, tfSetFreeze | tfSetDeepFreeze));
    +
    +                auto withdrawToCharlie =
    +                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)});
    +                withdrawToCharlie[sfDestination] = charlie.human();
    +                env(withdrawToCharlie, Ter(tecFROZEN));
    +
    +                // Destination deep freeze → self-withdraw unaffected
    +                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)}));
    +
    +                env(trust(issuer, asset(0), charlie, tfClearFreeze | tfClearDeepFreeze));
    +                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}));
    +                env.close();
    +            }
    +
    +            // Clawback works while frozen
    +            {
    +                testcase("VaultWithdraw IOU freeze clawback unaffected");
    +                env(fset(issuer, asfGlobalFreeze));
    +
    +                env(vault.clawback(
    +                    {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(1)}));
    +
    +                env(fclear(issuer, asfGlobalFreeze));
    +                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(1)}));
    +                env.close();
    +            }
    +        };
    +
    +        runTests();
    +        env.disableFeature(fixCleanup3_3_0);
    +        runTests();
    +        env.enableFeature(fixCleanup3_3_0);
    +    }
    +
    +    void
    +    testVaultWithdrawFreezeMPT()
    +    {
    +        using namespace test::jtx;
    +        testcase("VaultWithdraw MPT lock checks");
    +
    +        Account const issuer{"issuer"};
    +        Account const owner{"owner"};
    +        Env env{*this};
    +        Vault vault{env};
    +
    +        env.fund(XRP(100'000), issuer, owner);
    +        env.close();
    +
    +        MPTTester mptt{env, issuer, kMptInitNoFund};
    +        mptt.create(
    +            {.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock | tfMPTRequireAuth});
    +        PrettyAsset const mpt{mptt.issuanceID()};
    +
    +        mptt.authorize({.account = owner});
    +        mptt.authorize({.account = issuer, .holder = owner});
    +        env.close();
    +        env(pay(issuer, owner, mpt(100'000)));
    +        env.close();
    +
    +        auto [tx, keylet] = vault.create({.owner = owner, .asset = mpt});
    +        env(tx);
    +        env.close();
    +        Account const vaultAcct("vault", env.le(keylet)->at(sfAccount));
    +
    +        env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(100)}));
    +        env.close();
    +
    +        Account const charlie{"charlie"};
    +        env.fund(XRP(10'000), charlie);
    +        env.close();
    +        mptt.authorize({.account = charlie});
    +        mptt.authorize({.account = issuer, .holder = charlie});
    +        env.close();
    +
    +        auto runTests = [&]() {
    +            auto const fix330Enabled = env.current()->rules().enabled(fixCleanup3_3_0);
    +
    +            // Global lock
    +            {
    +                testcase("VaultWithdraw MPT global lock");
    +                mptt.set({.flags = tfMPTLock});
    +                env.close();
    +                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)}),
    +                    Ter(tecLOCKED));
    +
    +                // Global lock → withdraw to issuer
    +                // Post-fix: bypasses freeze checks, but accountHolds
    +                //           on the pseudo returns 0 under global lock
    +                // Pre-fix: checkFrozen(dst=issuer) catches global lock
    +                {
    +                    auto withdrawToIssuer =
    +                        vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)});
    +                    withdrawToIssuer[sfDestination] = issuer.human();
    +                    env(withdrawToIssuer, Ter(fix330Enabled ? TER(tesSUCCESS) : TER(tecLOCKED)));
    +                }
    +                mptt.set({.flags = tfMPTUnlock});
    +                env.close();
    +                if (fix330Enabled)
    +                {
    +                    env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}));
    +                }
    +                env.close();
    +            }
    +
    +            // Vault pseudo-account individual lock
    +            {
    +                testcase("VaultWithdraw MPT pseudo-account lock");
    +                mptt.set({.holder = vaultAcct, .flags = tfMPTLock});
    +                env.close();
    +                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)}),
    +                    Ter(tecLOCKED));
    +                mptt.set({.holder = vaultAcct, .flags = tfMPTUnlock});
    +                env.close();
    +            }
    +
    +            // Depositor individual lock → self-withdraw blocked
    +            // (isDeepFrozen == isFrozen for MPT)
    +            {
    +                testcase("VaultWithdraw MPT depositor lock");
    +                mptt.set({.holder = owner, .flags = tfMPTLock});
    +                env.close();
    +                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)}),
    +                    Ter(tecLOCKED));
    +                // Depositor lock → withdraw to 3rd party also blocked
    +                {
    +                    auto withdrawToCharlie =
    +                        vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)});
    +                    withdrawToCharlie[sfDestination] = charlie.human();
    +                    env(withdrawToCharlie, Ter(tecLOCKED));
    +                }
    +
    +                // Depositor lock → withdraw to issuer
    +                // Post-fix: issuer bypass in checkWithdrawFreezes
    +                // Pre-fix: checkFrozen(depositor, share) blocks transitively
    +                {
    +                    auto withdrawToIssuer =
    +                        vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)});
    +                    withdrawToIssuer[sfDestination] = issuer.human();
    +                    env(withdrawToIssuer, Ter(fix330Enabled ? TER(tesSUCCESS) : TER(tecLOCKED)));
    +                }
    +                mptt.set({.holder = owner, .flags = tfMPTUnlock});
    +                env.close();
    +                if (fix330Enabled)
    +                {
    +                    env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}));
    +                }
    +                env.close();
    +            }
    +
    +            // 3rd party destination lock → withdraw to 3rd party blocked
    +            {
    +                testcase("VaultWithdraw MPT 3rd party destination lock");
    +                mptt.set({.holder = charlie, .flags = tfMPTLock});
    +                env.close();
    +                {
    +                    auto withdrawToCharlie =
    +                        vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)});
    +                    withdrawToCharlie[sfDestination] = charlie.human();
    +                    env(withdrawToCharlie, Ter{tecLOCKED});
    +                }
    +                // 3rd party lock → self-withdraw unaffected
    +                env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = mpt(1)}));
    +                mptt.set({.holder = charlie, .flags = tfMPTUnlock});
    +                env.close();
    +                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}));
    +                env.close();
    +            }
    +
    +            // Clawback works while locked
    +            {
    +                testcase("VaultWithdraw MPT lock clawback unaffected");
    +                mptt.set({.flags = tfMPTLock});
    +                env.close();
    +                env(vault.clawback(
    +                    {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = mpt(1)}));
    +                mptt.set({.flags = tfMPTUnlock});
    +                env.close();
    +                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = mpt(1)}));
    +                env.close();
    +            }
    +        };
    +
    +        runTests();
    +        env.disableFeature(fixCleanup3_3_0);
    +        runTests();
    +        env.enableFeature(fixCleanup3_3_0);
    +    }
    +
    +    // Focused demonstration: a depositor under an individual IOU freeze
    +    // can still withdraw to themselves (self-withdrawal), but is blocked from
    +    // withdrawing to a third party.
    +    //
    +    // Pre-fixCleanup3_3_0: both the self-withdrawal AND the third-party
    +    // withdrawal were blocked because the old code checked checkFrozen on the
    +    // destination regardless of whether it was the submitter.
    +    // Post-fixCleanup3_3_0: checkWithdrawFreeze skips the submitter freeze
    +    // check when submitter == destination, so self-withdrawal succeeds.
    +    void
    +    testVaultSelfWithdrawWhileFrozen()
    +    {
    +        testcase("VaultWithdraw IOU self-withdrawal while individually frozen");
    +
    +        using namespace test::jtx;
    +
    +        Account const issuer{"issuer"};
    +        Account const owner{"owner"};
    +        Account const charlie{"charlie"};
    +        Env env{*this};
    +        Vault vault{env};
    +
    +        env.fund(XRP(100'000), issuer, owner, charlie);
    +        env(fset(issuer, asfAllowTrustLineClawback));
    +        env.close();
    +
    +        PrettyAsset const asset = issuer["IOU"];
    +        env.trust(asset(1'000'000), owner);
    +        env.trust(asset(1'000'000), charlie);
    +        env(pay(issuer, owner, asset(100'000)));
    +        env.close();
    +
    +        auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +        env(tx);
    +        env.close();
    +
    +        env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(10)}));
    +        env.close();
    +
    +        auto runTests = [&]() {
    +            auto const fix330Enabled = env.current()->rules().enabled(fixCleanup3_3_0);
    +
    +            // Set an individual freeze on the owner's IOU trustline.
    +            env(trust(issuer, asset(0), owner, tfSetFreeze));
    +            env.close();
    +
    +            // Self-withdrawal: submitter == destination, so the submitter
    +            // freeze check is skipped.
    +            // Post-fix: tesSUCCESS.  Pre-fix: tecFROZEN.
    +            env(vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)}),
    +                Ter(fix330Enabled ? TER(tesSUCCESS) : TER(tecFROZEN)));
    +
    +            // Withdrawal to a third party is blocked: submitter != destination
    +            // so the submitter freeze check applies.
    +            {
    +                auto withdrawToCharlie =
    +                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(1)});
    +                withdrawToCharlie[sfDestination] = charlie.human();
    +                // Post-fix: tecFROZEN (checkIndividualFrozen on submitter).
    +                // Pre-fix: tecLOCKED (isFrozen on the vault share).
    +                env(withdrawToCharlie, Ter(fix330Enabled ? TER(tecFROZEN) : TER(tecLOCKED)));
    +            }
    +
    +            env(trust(issuer, asset(0), owner, tfClearFreeze));
    +            env.close();
    +        };
    +
    +        runTests();
    +        env.disableFeature(fixCleanup3_3_0);
    +        runTests();
    +        env.enableFeature(fixCleanup3_3_0);
    +    }
    +
    +public:
    +    void
    +    run() override
    +    {
    +        testVaultDepositFreezeIOU();
    +        testVaultDepositFreezeMPT();
    +        testVaultWithdrawFreezeIOU();
    +        testVaultWithdrawFreezeMPT();
    +        testVaultSelfWithdrawWhileFrozen();
    +    }
    +};
    +
    +BEAST_DEFINE_TESTSUITE(VaultFreeze, app, xrpl);
    +
    +}  // namespace xrpl
    diff --git a/src/test/app/vault/VaultLifecycle_test.cpp b/src/test/app/vault/VaultLifecycle_test.cpp
    new file mode 100644
    index 0000000000..ce91ca857a
    --- /dev/null
    +++ b/src/test/app/vault/VaultLifecycle_test.cpp
    @@ -0,0 +1,1776 @@
    +#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 {
    +
    +class VaultLifecycle_test : public VaultTestBase
    +{
    +private:
    +    void
    +    testSequences()
    +    {
    +        using namespace test::jtx;
    +        Account const issuer{"issuer"};
    +        Account const owner{"owner"};
    +        Account const depositor{"depositor"};
    +        Account const charlie{"charlie"};  // authorized 3rd party
    +        Account const dave{"dave"};
    +
    +        auto const testSequence = [&, this](
    +                                      std::string const& prefix,
    +                                      Env& env,
    +                                      Vault& vault,
    +                                      PrettyAsset const& asset) {
    +            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +            tx[sfData] = "AFEED00E";
    +            tx[sfAssetsMaximum] = asset(100).number();
    +            env(tx);
    +            env.close();
    +            BEAST_EXPECT(env.le(keylet));
    +            std::uint64_t const scale = asset.raw().holds() ? 1 : 1e6;
    +
    +            auto const [share, vaultAccount] =
    +                [&env, keylet = keylet, asset, this]() -> std::tuple {
    +                auto const vault = env.le(keylet);
    +                BEAST_EXPECT(vault != nullptr);
    +                if (!asset.integral())
    +                {
    +                    BEAST_EXPECT(vault->at(sfScale) == 6);
    +                }
    +                else
    +                {
    +                    BEAST_EXPECT(vault->at(sfScale) == 0);
    +                }
    +                auto const shares = env.le(keylet::mptokenIssuance(vault->at(sfShareMPTID)));
    +                BEAST_EXPECT(shares != nullptr);
    +                if (!asset.integral())
    +                {
    +                    BEAST_EXPECT(shares->at(sfAssetScale) == 6);
    +                }
    +                else
    +                {
    +                    BEAST_EXPECT(shares->at(sfAssetScale) == 0);
    +                }
    +                return {MPTIssue(vault->at(sfShareMPTID)), Account("vault", vault->at(sfAccount))};
    +            }();
    +            auto const shares = share.raw().get();
    +            env.memoize(vaultAccount);
    +
    +            // Several 3rd party accounts which cannot receive funds
    +            Account const alice{"alice"};
    +            Account const erin{"erin"};  // not authorized by issuer
    +            env.fund(XRP(1000), alice, erin);
    +            env(fset(alice, asfDepositAuth));
    +            env.close();
    +
    +            {
    +                testcase(prefix + " fail to deposit more than assets held");
    +                auto tx = vault.deposit(
    +                    {.depositor = depositor, .id = keylet.key, .amount = asset(10000)});
    +                env(tx, Ter(tecINSUFFICIENT_FUNDS));
    +                env.close();
    +            }
    +
    +            {
    +                testcase(prefix + " deposit non-zero amount");
    +                auto tx =
    +                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
    +                env(tx);
    +                env.close();
    +                BEAST_EXPECT(env.balance(depositor, shares) == share(50 * scale));
    +            }
    +
    +            {
    +                testcase(prefix + " deposit non-zero amount again");
    +                auto tx =
    +                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
    +                env(tx);
    +                env.close();
    +                BEAST_EXPECT(env.balance(depositor, shares) == share(100 * scale));
    +            }
    +
    +            {
    +                testcase(prefix + " fail to delete non-empty vault");
    +                auto tx = vault.del({.owner = owner, .id = keylet.key});
    +                env(tx, Ter(tecHAS_OBLIGATIONS));
    +                env.close();
    +            }
    +
    +            {
    +                testcase(prefix + " fail to update because wrong owner");
    +                auto tx = vault.set({.owner = issuer, .id = keylet.key});
    +                tx[sfAssetsMaximum] = asset(50).number();
    +                env(tx, Ter(tecNO_PERMISSION));
    +                env.close();
    +            }
    +
    +            {
    +                testcase(prefix + " fail to set maximum lower than current amount");
    +                auto tx = vault.set({.owner = owner, .id = keylet.key});
    +                tx[sfAssetsMaximum] = asset(50).number();
    +                env(tx, Ter(tecLIMIT_EXCEEDED));
    +                env.close();
    +            }
    +
    +            {
    +                testcase(prefix + " set maximum higher than current amount");
    +                auto tx = vault.set({.owner = owner, .id = keylet.key});
    +                tx[sfAssetsMaximum] = asset(150).number();
    +                env(tx);
    +                env.close();
    +            }
    +
    +            {
    +                testcase(prefix + " set maximum is idempotent, set it again");
    +                auto tx = vault.set({.owner = owner, .id = keylet.key});
    +                tx[sfAssetsMaximum] = asset(150).number();
    +                env(tx);
    +                env.close();
    +            }
    +
    +            {
    +                testcase(prefix + " set data");
    +                auto tx = vault.set({.owner = owner, .id = keylet.key});
    +                tx[sfData] = "0";
    +                env(tx);
    +                env.close();
    +            }
    +
    +            {
    +                testcase(prefix + " fail to set domain on public vault");
    +                auto tx = vault.set({.owner = owner, .id = keylet.key});
    +                tx[sfDomainID] = to_string(BaseUInt<256>(42ul));
    +                env(tx, Ter{tecNO_PERMISSION});
    +                env.close();
    +            }
    +
    +            {
    +                testcase(prefix + " fail to deposit more than maximum");
    +                auto tx =
    +                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)});
    +                env(tx, Ter(tecLIMIT_EXCEEDED));
    +                env.close();
    +            }
    +
    +            {
    +                testcase(prefix + " reset maximum to zero i.e. not enforced");
    +                auto tx = vault.set({.owner = owner, .id = keylet.key});
    +                tx[sfAssetsMaximum] = asset(0).number();
    +                env(tx);
    +                env.close();
    +            }
    +
    +            {
    +                testcase(prefix + " fail to withdraw more than assets held");
    +                auto tx = vault.withdraw(
    +                    {.depositor = depositor, .id = keylet.key, .amount = asset(1000)});
    +                env(tx, Ter(tecINSUFFICIENT_FUNDS));
    +                env.close();
    +            }
    +
    +            {
    +                testcase(prefix + " deposit some more");
    +                auto tx =
    +                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)});
    +                env(tx);
    +                env.close();
    +                BEAST_EXPECT(env.balance(depositor, shares) == share(200 * scale));
    +            }
    +
    +            {
    +                testcase(prefix + " clawback some");
    +                auto code = asset.raw().native() ? Ter(temMALFORMED) : Ter(tesSUCCESS);
    +                auto tx = vault.clawback(
    +                    {.issuer = issuer, .id = keylet.key, .holder = depositor, .amount = asset(10)});
    +                env(tx, code);
    +                env.close();
    +                if (!asset.raw().native())
    +                {
    +                    BEAST_EXPECT(env.balance(depositor, shares) == share(190 * scale));
    +                }
    +            }
    +
    +            {
    +                testcase(prefix + " clawback all");
    +                auto code = asset.raw().native() ? Ter(tecNO_PERMISSION) : Ter(tesSUCCESS);
    +                auto tx = vault.clawback({.issuer = issuer, .id = keylet.key, .holder = depositor});
    +                env(tx, code);
    +                env.close();
    +                if (!asset.raw().native())
    +                {
    +                    BEAST_EXPECT(env.balance(depositor, shares) == share(0));
    +
    +                    {
    +                        auto tx = vault.clawback(
    +                            {.issuer = issuer,
    +                             .id = keylet.key,
    +                             .holder = depositor,
    +                             .amount = asset(10)});
    +                        env(tx, Ter{tecPRECISION_LOSS});
    +                        env.close();
    +                    }
    +
    +                    {
    +                        auto tx = vault.withdraw(
    +                            {.depositor = depositor, .id = keylet.key, .amount = asset(10)});
    +                        env(tx, Ter{tecPRECISION_LOSS});
    +                        env.close();
    +                    }
    +                }
    +            }
    +
    +            if (!asset.raw().native())
    +            {
    +                testcase(prefix + " deposit again");
    +                auto tx =
    +                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(200)});
    +                env(tx);
    +                env.close();
    +                BEAST_EXPECT(env.balance(depositor, shares) == share(200 * scale));
    +            }
    +            else
    +            {
    +                testcase(prefix + " deposit/withdrawal same or less than fee");
    +                auto const amount = env.current()->fees().base;
    +
    +                auto tx =
    +                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = amount});
    +                env(tx);
    +                env.close();
    +
    +                tx = vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = amount});
    +                env(tx);
    +                env.close();
    +
    +                tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = amount});
    +                env(tx);
    +                env.close();
    +
    +                // Withdraw to 3rd party
    +                tx = vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = amount});
    +                tx[sfDestination] = charlie.human();
    +                env(tx);
    +                env.close();
    +
    +                tx =
    +                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = amount - 1});
    +                env(tx);
    +                env.close();
    +
    +                tx = vault.withdraw(
    +                    {.depositor = depositor, .id = keylet.key, .amount = amount - 1});
    +                env(tx);
    +                env.close();
    +            }
    +
    +            {
    +                testcase(prefix + " fail to withdraw to 3rd party lsfDepositAuth");
    +                auto tx = vault.withdraw(
    +                    {.depositor = depositor, .id = keylet.key, .amount = asset(100)});
    +                tx[sfDestination] = alice.human();
    +                env(tx, Ter{tecNO_PERMISSION});
    +                env.close();
    +            }
    +
    +            {
    +                testcase(prefix + " fail to withdraw to zero destination");
    +                auto tx = vault.withdraw(
    +                    {.depositor = depositor, .id = keylet.key, .amount = asset(1000)});
    +                tx[sfDestination] = "0";
    +                env(tx, Ter(temMALFORMED));
    +                env.close();
    +            }
    +
    +            if (!asset.raw().native())
    +            {
    +                testcase(prefix + " fail to withdraw to 3rd party no authorization");
    +                auto tx = vault.withdraw(
    +                    {.depositor = depositor, .id = keylet.key, .amount = asset(100)});
    +                tx[sfDestination] = erin.human();
    +                env(tx, Ter{asset.raw().holds() ? tecNO_LINE : tecNO_AUTH});
    +                env.close();
    +            }
    +
    +            {
    +                testcase(prefix + " fail to withdraw to 3rd party lsfRequireDestTag");
    +                auto tx = vault.withdraw(
    +                    {.depositor = depositor, .id = keylet.key, .amount = asset(100)});
    +                tx[sfDestination] = dave.human();
    +                env(tx, Ter{tecDST_TAG_NEEDED});
    +                env.close();
    +            }
    +
    +            {
    +                testcase(prefix + " withdraw to 3rd party lsfRequireDestTag");
    +                auto tx =
    +                    vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
    +                tx[sfDestination] = dave.human();
    +                tx[sfDestinationTag] = "0";
    +                env(tx);
    +                env.close();
    +            }
    +
    +            {
    +                testcase(prefix + " deposit again");
    +                auto tx = vault.deposit({.depositor = dave, .id = keylet.key, .amount = asset(50)});
    +                env(tx);
    +                env.close();
    +            }
    +
    +            {
    +                testcase(prefix + " fail to withdraw lsfRequireDestTag");
    +                auto tx =
    +                    vault.withdraw({.depositor = dave, .id = keylet.key, .amount = asset(50)});
    +                env(tx, Ter{tecDST_TAG_NEEDED});
    +                env.close();
    +            }
    +
    +            {
    +                testcase(prefix + " withdraw with tag");
    +                auto tx =
    +                    vault.withdraw({.depositor = dave, .id = keylet.key, .amount = asset(50)});
    +                tx[sfDestinationTag] = "0";
    +                env(tx);
    +                env.close();
    +            }
    +
    +            {
    +                testcase(prefix + " withdraw to authorized 3rd party");
    +                auto tx =
    +                    vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
    +                tx[sfDestination] = charlie.human();
    +                env(tx);
    +                env.close();
    +                BEAST_EXPECT(env.balance(depositor, shares) == share(100 * scale));
    +            }
    +
    +            {
    +                testcase(prefix + " withdraw to issuer");
    +                auto tx =
    +                    vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
    +                tx[sfDestination] = issuer.human();
    +                env(tx);
    +                env.close();
    +                BEAST_EXPECT(env.balance(depositor, shares) == share(50 * scale));
    +            }
    +
    +            if (!asset.raw().native())
    +            {
    +                testcase(prefix + " issuer deposits");
    +                auto tx =
    +                    vault.deposit({.depositor = issuer, .id = keylet.key, .amount = asset(10)});
    +                env(tx);
    +                env.close();
    +                BEAST_EXPECT(env.balance(issuer, shares) == share(10 * scale));
    +
    +                testcase(prefix + " issuer withdraws");
    +                tx = vault.withdraw(
    +                    {.depositor = issuer, .id = keylet.key, .amount = share(10 * scale)});
    +                env(tx);
    +                env.close();
    +                BEAST_EXPECT(env.balance(issuer, shares) == share(0 * scale));
    +            }
    +
    +            {
    +                testcase(prefix + " withdraw remaining assets");
    +                auto tx =
    +                    vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(50)});
    +                env(tx);
    +                env.close();
    +                BEAST_EXPECT(env.balance(depositor, shares) == share(0));
    +
    +                if (!asset.raw().native())
    +                {
    +                    auto tx = vault.clawback(
    +                        {.issuer = issuer,
    +                         .id = keylet.key,
    +                         .holder = depositor,
    +                         .amount = asset(0)});
    +                    env(tx, Ter{tecPRECISION_LOSS});
    +                    env.close();
    +                }
    +
    +                {
    +                    auto tx = vault.withdraw(
    +                        {.depositor = depositor, .id = keylet.key, .amount = share(10)});
    +                    env(tx, Ter{tecINSUFFICIENT_FUNDS});
    +                    env.close();
    +                }
    +            }
    +
    +            if (!asset.integral())
    +            {
    +                testcase(prefix + " temporary authorization for 3rd party");
    +                env(trust(erin, asset(1000)));
    +                env(trust(issuer, asset(0), erin, tfSetfAuth));
    +                env(pay(issuer, erin, asset(10)));
    +
    +                // Erin deposits all in vault, then sends shares to depositor
    +                auto tx = vault.deposit({.depositor = erin, .id = keylet.key, .amount = asset(10)});
    +                env(tx);
    +                env.close();
    +                {
    +                    auto tx = pay(erin, depositor, share(10 * scale));
    +
    +                    // depositor no longer has MPToken for shares
    +                    env(tx, Ter{tecNO_AUTH});
    +                    env.close();
    +
    +                    // depositor will gain MPToken for shares again
    +                    env(vault.deposit(
    +                        {.depositor = depositor, .id = keylet.key, .amount = asset(1)}));
    +                    env.close();
    +
    +                    env(tx);
    +                    env.close();
    +                }
    +
    +                testcase(prefix + " withdraw to authorized 3rd party");
    +                // Depositor withdraws assets, destined to Erin
    +                tx =
    +                    vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(10)});
    +                tx[sfDestination] = erin.human();
    +                env(tx);
    +                env.close();
    +
    +                // Erin returns assets to issuer
    +                env(pay(erin, issuer, asset(10)));
    +                env.close();
    +
    +                testcase(prefix + " fail to pay to unauthorized 3rd party");
    +                env(trust(erin, asset(0)));
    +                env.close();
    +
    +                // Erin has MPToken but is no longer authorized to hold assets
    +                env(pay(depositor, erin, share(1)), Ter{tecNO_LINE});
    +                env.close();
    +
    +                // Depositor withdraws remaining single asset
    +                tx = vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(1)});
    +                env(tx);
    +                env.close();
    +            }
    +
    +            {
    +                testcase(prefix + " fail to delete because wrong owner");
    +                auto tx = vault.del({.owner = issuer, .id = keylet.key});
    +                env(tx, Ter(tecNO_PERMISSION));
    +                env.close();
    +            }
    +
    +            {
    +                testcase(prefix + " delete empty vault");
    +                auto tx = vault.del({.owner = owner, .id = keylet.key});
    +                env(tx);
    +                env.close();
    +                BEAST_EXPECT(!env.le(keylet));
    +            }
    +        };
    +
    +        auto testCases = [&, this](
    +                             std::string prefix, std::function setup) {
    +            Env env{*this, testableAmendments()};
    +
    +            Vault vault{env};
    +            env.fund(XRP(1000), issuer, owner, depositor, charlie, dave);
    +            env.close();
    +            env(fset(issuer, asfAllowTrustLineClawback));
    +            env(fset(issuer, asfRequireAuth));
    +            env(fset(dave, asfRequireDest));
    +            env.close();
    +            env.require(Flags(issuer, asfAllowTrustLineClawback));
    +            env.require(Flags(issuer, asfRequireAuth));
    +
    +            PrettyAsset const asset = setup(env);
    +            testSequence(prefix, env, vault, asset);
    +        };
    +
    +        testCases("XRP", [&](Env& env) -> PrettyAsset { return {xrpIssue(), 1'000'000}; });
    +
    +        testCases("IOU", [&](Env& env) -> Asset {
    +            PrettyAsset const asset = issuer["IOU"];
    +            env(trust(owner, asset(1000)));
    +            env(trust(depositor, asset(1000)));
    +            env(trust(charlie, asset(1000)));
    +            env(trust(dave, asset(1000)));
    +            env(trust(issuer, asset(0), owner, tfSetfAuth));
    +            env(trust(issuer, asset(0), depositor, tfSetfAuth));
    +            env(trust(issuer, asset(0), charlie, tfSetfAuth));
    +            env(trust(issuer, asset(0), dave, tfSetfAuth));
    +            env(pay(issuer, depositor, asset(1000)));
    +            env.close();
    +            return asset;
    +        });
    +
    +        testCases("MPT", [&](Env& env) -> Asset {
    +            MPTTester mptt{env, issuer, kMptInitNoFund};
    +            mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
    +            PrettyAsset const asset = mptt.issuanceID();
    +            mptt.authorize({.account = depositor});
    +            mptt.authorize({.account = charlie});
    +            mptt.authorize({.account = dave});
    +            env(pay(issuer, depositor, asset(1000)));
    +            env.close();
    +            return asset;
    +        });
    +    }
    +
    +    void
    +    testWithMPT()
    +    {
    +        using namespace test::jtx;
    +
    +        struct CaseArgs
    +        {
    +            bool enableClawback = true;
    +            bool requireAuth = true;
    +            int initialXRP = 1000;
    +            FeatureBitset features = testableAmendments();
    +        };
    +
    +        auto testCase = [this](
    +                            std::function test,
    +                            CaseArgs args = {}) {
    +            Env env{*this, args.features};
    +            Account const issuer{"issuer"};
    +            Account const owner{"owner"};
    +            Account const depositor{"depositor"};
    +            env.fund(XRP(args.initialXRP), issuer, owner, depositor);
    +            env.close();
    +            Vault vault{env};
    +
    +            MPTTester mptt{env, issuer, kMptInitNoFund};
    +            auto const kNone = LedgerSpecificFlags(0);
    +            mptt.create(
    +                {.flags = tfMPTCanTransfer | tfMPTCanLock |
    +                     (args.enableClawback ? tfMPTCanClawback : kNone) |
    +                     (args.requireAuth ? tfMPTRequireAuth : kNone)});
    +            PrettyAsset const asset = mptt.issuanceID();
    +            mptt.authorize({.account = owner});
    +            mptt.authorize({.account = depositor});
    +            if (args.requireAuth)
    +            {
    +                mptt.authorize({.account = issuer, .holder = owner});
    +                mptt.authorize({.account = issuer, .holder = depositor});
    +            }
    +
    +            env(pay(issuer, depositor, asset(1000)));
    +            env.close();
    +
    +            test(env, issuer, owner, depositor, asset, vault, mptt);
    +        };
    +
    +        testCase([this](
    +                     Env& env,
    +                     Account const& issuer,
    +                     Account const& owner,
    +                     Account const& depositor,
    +                     PrettyAsset const& asset,
    +                     Vault& vault,
    +                     MPTTester& mptt) {
    +            testcase("MPT nothing to clawback from");
    +            auto tx = vault.clawback(
    +                {.issuer = issuer,
    +                 .id = keylet::skip().key,
    +                 .holder = depositor,
    +                 .amount = asset(10)});
    +            env(tx, Ter(tecNO_ENTRY));
    +        });
    +
    +        testCase([this](
    +                     Env& env,
    +                     Account const& issuer,
    +                     Account const& owner,
    +                     Account const& depositor,
    +                     Asset const& asset,
    +                     Vault& vault,
    +                     MPTTester& mptt) {
    +            testcase("MPT global lock blocks create");
    +            mptt.set({.account = issuer, .flags = tfMPTLock});
    +            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +            env(tx, Ter(tecLOCKED));
    +        });
    +
    +        testCase([this](
    +                     Env& env,
    +                     Account const& issuer,
    +                     Account const& owner,
    +                     Account const& depositor,
    +                     PrettyAsset const& asset,
    +                     Vault& vault,
    +                     MPTTester& mptt) {
    +            testcase("MPT only issuer can clawback");
    +
    +            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +            env(tx);
    +            env.close();
    +
    +            tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)});
    +            env(tx);
    +            env.close();
    +
    +            {
    +                auto tx = vault.clawback({
    +                    .issuer = depositor,
    +                    .id = keylet.key,
    +                    .holder = depositor,
    +                });
    +                env(tx, Ter(tecNO_PERMISSION));
    +            }
    +
    +            {
    +                auto tx = vault.clawback({
    +                    .issuer = owner,
    +                    .id = keylet.key,
    +                    .holder = depositor,
    +                });
    +                env(tx, Ter(tecNO_PERMISSION));
    +            }
    +        });
    +
    +        testCase(
    +            [this](
    +                Env& env,
    +                Account const& issuer,
    +                Account const& owner,
    +                Account const& depositor,
    +                PrettyAsset const& asset,
    +                Vault& vault,
    +                MPTTester& mptt) {
    +                testcase("MPT depositor without MPToken, auth required");
    +
    +                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +                env(tx);
    +                env.close();
    +
    +                tx = vault.deposit(
    +                    {.depositor = depositor, .id = keylet.key, .amount = asset(1000)});
    +                env(tx);
    +                env.close();
    +
    +                {
    +                    // Remove depositor MPToken and it will not be re-created
    +                    mptt.authorize({.account = depositor, .flags = tfMPTUnauthorize});
    +                    env.close();
    +
    +                    auto const mptoken = keylet::mptoken(mptt.issuanceID(), depositor);
    +                    auto const sleMPT1 = env.le(mptoken);
    +                    BEAST_EXPECT(sleMPT1 == nullptr);
    +
    +                    tx = vault.withdraw(
    +                        {.depositor = depositor, .id = keylet.key, .amount = asset(100)});
    +                    env(tx, Ter{tecNO_AUTH});
    +                    env.close();
    +
    +                    auto const sleMPT2 = env.le(mptoken);
    +                    BEAST_EXPECT(sleMPT2 == nullptr);
    +                }
    +
    +                {
    +                    // Set destination to 3rd party without MPToken
    +                    Account const charlie{"charlie"};
    +                    env.fund(XRP(1000), charlie);
    +                    env.close();
    +
    +                    tx = vault.withdraw(
    +                        {.depositor = depositor, .id = keylet.key, .amount = asset(100)});
    +                    tx[sfDestination] = charlie.human();
    +                    env(tx, Ter(tecNO_AUTH));
    +                }
    +            },
    +            {.requireAuth = true});
    +
    +        testCase(
    +            [this](
    +                Env& env,
    +                Account const& issuer,
    +                Account const& owner,
    +                Account const& depositor,
    +                PrettyAsset const& asset,
    +                Vault& vault,
    +                MPTTester& mptt) {
    +                testcase("MPT depositor without MPToken, no auth required");
    +
    +                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +                env(tx);
    +                env.close();
    +                auto v = env.le(keylet);
    +                BEAST_EXPECT(v);
    +
    +                tx = vault.deposit(
    +                    {.depositor = depositor,
    +                     .id = keylet.key,
    +                     .amount = asset(1000)});  // all assets held by depositor
    +                env(tx);
    +                env.close();
    +
    +                {
    +                    // Remove depositor's MPToken and it will be re-created
    +                    mptt.authorize({.account = depositor, .flags = tfMPTUnauthorize});
    +                    env.close();
    +
    +                    auto const mptoken = keylet::mptoken(mptt.issuanceID(), depositor);
    +                    auto const sleMPT1 = env.le(mptoken);
    +                    BEAST_EXPECT(sleMPT1 == nullptr);
    +
    +                    tx = vault.withdraw(
    +                        {.depositor = depositor, .id = keylet.key, .amount = asset(100)});
    +                    env(tx);
    +                    env.close();
    +
    +                    auto const sleMPT2 = env.le(mptoken);
    +                    BEAST_EXPECT(sleMPT2 != nullptr);
    +                    BEAST_EXPECT(sleMPT2->at(sfMPTAmount) == 100);
    +                }
    +
    +                {
    +                    // Remove 3rd party MPToken and it will not be re-created
    +                    mptt.authorize({.account = owner, .flags = tfMPTUnauthorize});
    +                    env.close();
    +
    +                    auto const mptoken = keylet::mptoken(mptt.issuanceID(), owner);
    +                    auto const sleMPT1 = env.le(mptoken);
    +                    BEAST_EXPECT(sleMPT1 == nullptr);
    +
    +                    tx = vault.withdraw(
    +                        {.depositor = depositor, .id = keylet.key, .amount = asset(100)});
    +                    tx[sfDestination] = owner.human();
    +                    env(tx, Ter(tecNO_AUTH));
    +                    env.close();
    +
    +                    auto const sleMPT2 = env.le(mptoken);
    +                    BEAST_EXPECT(sleMPT2 == nullptr);
    +                }
    +            },
    +            {.requireAuth = false});
    +
    +        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,
    +                Account const& issuer,
    +                Account const& owner,
    +                Account const& depositor,
    +                PrettyAsset const& asset,
    +                Vault& vault,
    +                MPTTester& mptt) {
    +                testcase("MPT fail reserve to re-create MPToken");
    +
    +                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +                env(tx);
    +                env.close();
    +                auto v = env.le(keylet);
    +                BEAST_EXPECT(v);
    +
    +                env(pay(depositor, owner, asset(1000)));
    +                env.close();
    +
    +                tx = vault.deposit(
    +                    {.depositor = owner,
    +                     .id = keylet.key,
    +                     .amount = asset(1000)});  // all assets held by owner
    +                env(tx);
    +                env.close();
    +
    +                {
    +                    // Remove owners's MPToken and it will not be re-created
    +                    mptt.authorize({.account = owner, .flags = tfMPTUnauthorize});
    +                    env.close();
    +
    +                    auto const mptoken = keylet::mptoken(mptt.issuanceID(), owner);
    +                    auto const sleMPT = env.le(mptoken);
    +                    BEAST_EXPECT(sleMPT == nullptr);
    +
    +                    // Use one reserve so the next transaction fails
    +                    env(ticket::create(owner, 1));
    +                    env.close();
    +
    +                    // No reserve to create MPToken for asset in VaultWithdraw
    +                    tx = vault.withdraw(
    +                        {.depositor = owner, .id = keylet.key, .amount = asset(100)});
    +                    env(tx, Ter{tecINSUFFICIENT_RESERVE});
    +                    env.close();
    +
    +                    env(pay(depositor, owner, XRP(incReserve)));
    +                    env.close();
    +
    +                    // Withdraw can now create asset MPToken, tx will succeed
    +                    env(tx);
    +                    env.close();
    +                }
    +            },
    +            {.requireAuth = false, .initialXRP = acctReserve + (incReserve * 4) + 1});
    +
    +        testCase([this](
    +                     Env& env,
    +                     Account const& issuer,
    +                     Account const& owner,
    +                     Account const& depositor,
    +                     PrettyAsset const& asset,
    +                     Vault& vault,
    +                     MPTTester& mptt) {
    +            testcase("MPT issuance deleted");
    +
    +            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +            env(tx);
    +            env.close();
    +
    +            tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1000)});
    +            env(tx);
    +            env.close();
    +
    +            {
    +                auto tx = vault.clawback(
    +                    {.issuer = issuer, .id = keylet.key, .holder = depositor, .amount = asset(0)});
    +                env(tx);
    +            }
    +
    +            mptt.destroy({.issuer = issuer, .id = mptt.issuanceID()});
    +            env.close();
    +
    +            {
    +                auto [tx, keylet] = vault.create({.owner = depositor, .asset = asset});
    +                env(tx, Ter{tecOBJECT_NOT_FOUND});
    +            }
    +
    +            {
    +                auto tx =
    +                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(10)});
    +                env(tx, Ter{tecOBJECT_NOT_FOUND});
    +            }
    +
    +            {
    +                auto tx =
    +                    vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(10)});
    +                env(tx, Ter{tecOBJECT_NOT_FOUND});
    +            }
    +
    +            {
    +                auto tx = vault.clawback(
    +                    {.issuer = issuer, .id = keylet.key, .holder = depositor, .amount = asset(0)});
    +                env(tx, Ter{tecOBJECT_NOT_FOUND});
    +            }
    +
    +            env(vault.del({.owner = owner, .id = keylet.key}));
    +        });
    +
    +        testCase([this](
    +                     Env& env,
    +                     Account const& issuer,
    +                     Account const& owner,
    +                     Account const& depositor,
    +                     PrettyAsset const& asset,
    +                     Vault& vault,
    +                     MPTTester& mptt) {
    +            testcase("MPT vault owner can receive shares unless unauthorized");
    +
    +            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +            env(tx);
    +            env.close();
    +
    +            tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1000)});
    +            env(tx);
    +            env.close();
    +
    +            auto const issuanceId = [&env](xrpl::Keylet keylet) -> MPTID {
    +                auto const vault = env.le(keylet);
    +                return vault->at(sfShareMPTID);
    +            }(keylet);
    +            PrettyAsset const shares = MPTIssue(issuanceId);
    +
    +            {
    +                // owner has MPToken for shares they did not explicitly create
    +                env(pay(depositor, owner, shares(1)));
    +                env.close();
    +
    +                tx = vault.withdraw({.depositor = owner, .id = keylet.key, .amount = shares(1)});
    +                env(tx);
    +                env.close();
    +
    +                // owner's MPToken for vault shares not destroyed by withdraw
    +                env(pay(depositor, owner, shares(1)));
    +                env.close();
    +
    +                tx = vault.clawback(
    +                    {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(0)});
    +                env(tx);
    +                env.close();
    +
    +                // owner's MPToken for vault shares not destroyed by clawback
    +                env(pay(depositor, owner, shares(1)));
    +                env.close();
    +
    +                // pay back, so we can destroy owner's MPToken now
    +                env(pay(owner, depositor, shares(1)));
    +                env.close();
    +
    +                {
    +                    // explicitly destroy vault owners MPToken with zero balance
    +                    json::Value jv;
    +                    jv[sfAccount] = owner.human();
    +                    jv[sfMPTokenIssuanceID] = to_string(issuanceId);
    +                    jv[sfFlags] = tfMPTUnauthorize;
    +                    jv[sfTransactionType] = jss::MPTokenAuthorize;
    +                    env(jv);
    +                    env.close();
    +                }
    +
    +                // owner no longer has MPToken for vault shares
    +                tx = pay(depositor, owner, shares(1));
    +                env(tx, Ter{tecNO_AUTH});
    +                env.close();
    +
    +                // destroy all remaining shares, so we can delete vault
    +                tx = vault.clawback(
    +                    {.issuer = issuer, .id = keylet.key, .holder = depositor, .amount = asset(0)});
    +                env(tx);
    +                env.close();
    +
    +                // will soft fail destroying MPToken for vault owner
    +                env(vault.del({.owner = owner, .id = keylet.key}));
    +                env.close();
    +            }
    +        });
    +
    +        testCase(
    +            [this](
    +                Env& env,
    +                Account const& issuer,
    +                Account const& owner,
    +                Account const& depositor,
    +                PrettyAsset const& asset,
    +                Vault& vault,
    +                MPTTester& mptt) {
    +                testcase("MPT clawback disabled");
    +
    +                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +                env(tx);
    +                env.close();
    +
    +                tx = vault.deposit(
    +                    {.depositor = depositor, .id = keylet.key, .amount = asset(1000)});
    +                env(tx);
    +                env.close();
    +
    +                {
    +                    auto tx = vault.clawback(
    +                        {.issuer = issuer,
    +                         .id = keylet.key,
    +                         .holder = depositor,
    +                         .amount = asset(0)});
    +                    env(tx, Ter{tecNO_PERMISSION});
    +                }
    +            },
    +            {.enableClawback = false});
    +
    +        testCase([this](
    +                     Env& env,
    +                     Account const& issuer,
    +                     Account const& owner,
    +                     Account const& depositor,
    +                     Asset const& asset,
    +                     Vault& vault,
    +                     MPTTester& mptt) {
    +            testcase("MPT un-authorization");
    +            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +            env(tx);
    +            env.close();
    +            tx = vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1000)});
    +            env(tx);
    +            env.close();
    +
    +            mptt.authorize({.account = issuer, .holder = depositor, .flags = tfMPTUnauthorize});
    +            env.close();
    +
    +            {
    +                auto tx = vault.withdraw(
    +                    {.depositor = depositor, .id = keylet.key, .amount = asset(100)});
    +                env(tx, Ter(tecNO_AUTH));
    +
    +                // Withdrawal to other (authorized) accounts works
    +                tx[sfDestination] = issuer.human();
    +                env(tx);
    +                env.close();
    +
    +                tx[sfDestination] = owner.human();
    +                env(tx);
    +                env.close();
    +            }
    +
    +            {
    +                // Cannot deposit some more
    +                auto tx =
    +                    vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)});
    +                env(tx, Ter(tecNO_AUTH));
    +            }
    +
    +            {
    +                // Cannot clawback if issuer is the holder
    +                tx = vault.clawback(
    +                    {.issuer = issuer, .id = keylet.key, .holder = issuer, .amount = asset(800)});
    +                env(tx, Ter(tecNO_PERMISSION));
    +            }
    +            // Clawback works
    +            tx = vault.clawback(
    +                {.issuer = issuer, .id = keylet.key, .holder = depositor, .amount = asset(800)});
    +            env(tx);
    +            env.close();
    +
    +            env(vault.del({.owner = owner, .id = keylet.key}));
    +        });
    +
    +        {
    +            testcase("MPT shares to a vault");
    +
    +            Env env{*this, testableAmendments()};
    +            Account const owner{"owner"};
    +            Account const issuer{"issuer"};
    +            env.fund(XRP(1000000), owner, issuer);
    +            env.close();
    +            Vault const vault{env};
    +
    +            MPTTester mptt{env, issuer, kMptInitNoFund};
    +            mptt.create(
    +                {.flags = tfMPTCanTransfer | tfMPTCanLock | lsfMPTCanClawback | tfMPTRequireAuth});
    +            mptt.authorize({.account = owner});
    +            mptt.authorize({.account = issuer, .holder = owner});
    +            PrettyAsset const asset = mptt.issuanceID();
    +            env(pay(issuer, owner, asset(100)));
    +            auto [tx1, k1] = vault.create({.owner = owner, .asset = asset});
    +            env(tx1);
    +            env.close();
    +
    +            auto const shares = [&env, keylet = k1, this]() -> Asset {
    +                auto const vault = env.le(keylet);
    +                BEAST_EXPECT(vault != nullptr);
    +                return MPTIssue(vault->at(sfShareMPTID));
    +            }();
    +
    +            auto [tx2, k2] = vault.create({.owner = owner, .asset = shares});
    +            env(tx2, Ter{tecWRONG_ASSET});
    +            env.close();
    +        }
    +
    +        {
    +            testcase("MPT locked: vault shares inherit underlying lock");
    +
    +            Env env{*this, testableAmendments()};
    +            Account const issuer{"issuer"};
    +            Account const owner{"owner"};
    +            Account const alice{"alice"};
    +            Account const bob{"bob"};
    +            Account const carol{"carol"};
    +            env.fund(XRP(10'000), issuer, owner, alice, bob, carol);
    +            env.close();
    +            Vault const vault{env};
    +
    +            MPTTester asset{
    +                {.env = env,
    +                 .issuer = issuer,
    +                 .holders = {owner, alice, bob, carol},
    +                 .flags = tfMPTCanTransfer | tfMPTCanTrade | tfMPTCanLock}};
    +            env(pay(issuer, alice, asset(1'000)));
    +            env(pay(issuer, bob, asset(1'000)));
    +            env.close();
    +
    +            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +            env(tx);
    +            env.close();
    +
    +            env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = asset(500)}));
    +            // Bob also deposits so he has a share MPToken to receive into.
    +            env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = asset(500)}));
    +            env.close();
    +
    +            auto const shares = [&]() -> PrettyAsset {
    +                auto const sle = env.le(keylet);
    +                BEAST_EXPECT(sle != nullptr);
    +                return MPTIssue(sle->at(sfShareMPTID));
    +            }();
    +            auto const shareMptID = shares.raw().get().getMptID();
    +            auto const shareBalance = [&](Account const& account) {
    +                auto const sle = env.le(keylet::mptoken(shareMptID, account));
    +                return sle ? sle->at(sfMPTAmount) : 0;
    +            };
    +
    +            // Sanity: before the underlying lock, peer-to-peer share
    +            // transfers are allowed.
    +            env(pay(alice, bob, shares(1)));
    +            env.close();
    +
    +            // Create the offer while shares are spendable, then lock the
    +            // underlying to test whether a stale offer can still be crossed.
    +            env(offer(alice, XRP(1), shares(1)));
    +            env.close();
    +
    +            // Lock the underlying after the vault and share balances exist.
    +            asset.set({.account = issuer, .flags = tfMPTLock});
    +            env.close();
    +
    +            // Direct vault share payment inherits the underlying lock via
    +            // sfReferenceHolding.
    +            BEAST_EXPECT(shareBalance(alice) == 499);
    +            BEAST_EXPECT(shareBalance(bob) == 501);
    +            env(pay(alice, bob, shares(1)), Ter{tecLOCKED});
    +            env.close();
    +            BEAST_EXPECT(shareBalance(alice) == 499);
    +            BEAST_EXPECT(shareBalance(bob) == 501);
    +
    +            // The same inherited lock must also block DEX payment paths that
    +            // would consume an offer selling vault shares.
    +            env(pay(carol, bob, shares(1)),
    +                Sendmax(XRP(1)),
    +                Path(BookSpec{shares.raw()}),
    +                Ter{tecPATH_PARTIAL});
    +            env.close();
    +            BEAST_EXPECT(shareBalance(alice) == 499);
    +            BEAST_EXPECT(shareBalance(bob) == 501);
    +            BEAST_EXPECT(expectOffers(env, alice, 1));
    +        }
    +
    +        {
    +            testcase("MPT CanTrade governance: share inherits underlying on DEX and AMM");
    +
    +            Env env{*this, testableAmendments()};
    +            Account const issuer{"issuer"};
    +            Account const owner{"owner"};
    +            Account const alice{"alice"};
    +            Account const bob{"bob"};
    +            env.fund(XRP(100'000), issuer, owner, alice, bob);
    +            env.close();
    +            Vault const vault{env};
    +
    +            MPTTester mptt{env, issuer, kMptInitNoFund};
    +            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
    +            PrettyAsset const asset = mptt.issuanceID();
    +            mptt.authorize({.account = owner});
    +            mptt.authorize({.account = alice});
    +            mptt.authorize({.account = bob});
    +            env(pay(issuer, alice, asset(10'000)));
    +            env(pay(issuer, bob, asset(10'000)));
    +            env.close();
    +
    +            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +            env(tx);
    +            env.close();
    +
    +            // Seed shares so we can later place them on trading venues.
    +            env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = asset(5'000)}));
    +            env(vault.deposit({.depositor = bob, .id = keylet.key, .amount = asset(5'000)}));
    +            env.close();
    +
    +            auto const shares = [&]() -> PrettyAsset {
    +                auto const sle = env.le(keylet);
    +                BEAST_EXPECT(sle != nullptr);
    +                return MPTIssue(sle->at(sfShareMPTID));
    +            }();
    +
    +            // CanTrade is not set on the underlying, both the asset and
    +            // the vault share are blocked on the DEX.
    +            env(offer(alice, XRP(1), asset(10)), Ter{tecNO_PERMISSION});
    +            env(offer(alice, XRP(1), shares(1)), Ter{tecNO_PERMISSION});
    +            env.close();
    +
    +            // Deposit still works before enabling CanTrade.
    +            env(vault.deposit({.depositor = alice, .id = keylet.key, .amount = asset(100)}));
    +            env.close();
    +
    +            // Peer-to-peer share transfers still work (CanTransfer is set on
    +            // both layers).
    +            env(pay(alice, bob, shares(1)));
    +            env.close();
    +
    +            // Withdraw still works before enabling CanTrade.
    +            env(vault.withdraw({.depositor = alice, .id = keylet.key, .amount = asset(100)}));
    +            env.close();
    +
    +            // Enable CanTrade on the underlying.
    +            mptt.set({.flags = tfMPTSetCanTrade});
    +            env.close();
    +
    +            env(offer(alice, XRP(1), asset(10)));
    +            env(offer(alice, XRP(1), shares(1)));
    +            env.close();
    +
    +            AMM const ammUnderlying(env, alice, XRP(1'000), asset(1'000));
    +        }
    +
    +        {
    +            testcase("MPT OutstandingAmount > MaximumAmount");
    +
    +            Env env{*this, testableAmendments() | featureSingleAssetVault};
    +            Account const alice{"alice"};
    +            Account const issuer{"issuer"};
    +            env.fund(XRP(1'000), alice, issuer);
    +            env.close();
    +            Vault const vault{env};
    +
    +            MPTTester const btc({.env = env, .issuer = issuer, .holders = {alice}, .maxAmt = 100});
    +
    +            auto [tx, k] = vault.create({.owner = issuer, .asset = btc});
    +            env(tx);
    +            env.close();
    +
    +            tx = vault.deposit({.depositor = issuer, .id = k.key, .amount = btc(110)});
    +            // accountHolds is the first check and the issuer has only BTC(100)
    +            // available
    +            env(tx, Ter{tecINSUFFICIENT_FUNDS});
    +            env.close();
    +
    +            // OutstandingAmount == MaximumAmount
    +            env(pay(issuer, alice, btc(100)));
    +            env.close();
    +
    +            tx = vault.deposit({.depositor = issuer, .id = k.key, .amount = btc(100)});
    +            // the issuer has BTC(0) available
    +            env(tx, Ter{tecINSUFFICIENT_FUNDS});
    +            env.close();
    +
    +            tx = vault.deposit({.depositor = alice, .id = k.key, .amount = btc(100)});
    +            // alice transfers BTC(100), OutstandingAmount is 100
    +            env(tx);
    +            env.close();
    +        }
    +    }
    +
    +    void
    +    testWithIOU()
    +    {
    +        using namespace test::jtx;
    +
    +        struct CaseArgs
    +        {
    +            int initialXRP = 1000;
    +            Number initialIOU = 200;
    +            double transferRate = 1.0;
    +            bool charlieRipple = true;
    +            FeatureBitset features = testableAmendments();
    +        };
    +
    +        auto testCase = [&, this](
    +                            std::function vaultAccount,
    +                                Vault& vault,
    +                                PrettyAsset const& asset,
    +                                std::function issuanceId)> test,
    +                            CaseArgs args = {}) {
    +            Env env{*this, args.features};
    +            Account const owner{"owner"};
    +            Account const issuer{"issuer"};
    +            Account const charlie{"charlie"};
    +            Vault vault{env};
    +            env.fund(XRP(args.initialXRP), issuer, owner, charlie);
    +            env(fset(issuer, asfAllowTrustLineClawback));
    +            env.close();
    +
    +            PrettyAsset const asset = issuer["IOU"];
    +            env.trust(asset(1000), owner);
    +            env(pay(issuer, owner, asset(args.initialIOU)));
    +            env.close();
    +            if (!args.charlieRipple)
    +            {
    +                env(fset(issuer, 0, asfDefaultRipple));
    +                env.close();
    +                env.trust(asset(1000), charlie);
    +                env.close();
    +                env(pay(issuer, charlie, asset(args.initialIOU)));
    +                env.close();
    +                env(fset(issuer, asfDefaultRipple));
    +            }
    +            else
    +            {
    +                env.trust(asset(1000), charlie);
    +            }
    +            env.close();
    +            env(rate(issuer, args.transferRate));
    +            env.close();
    +
    +            auto const vaultAccount = [&env](xrpl::Keylet keylet) -> Account {
    +                return Account("vault", env.le(keylet)->at(sfAccount));
    +            };
    +            auto const issuanceId = [&env](xrpl::Keylet keylet) -> MPTID {
    +                return env.le(keylet)->at(sfShareMPTID);
    +            };
    +
    +            test(env, owner, issuer, charlie, vaultAccount, vault, asset, issuanceId);
    +        };
    +
    +        testCase([&, this](
    +                     Env& env,
    +                     Account const& owner,
    +                     Account const& issuer,
    +                     Account const&,
    +                     auto vaultAccount,
    +                     Vault& vault,
    +                     PrettyAsset const& asset,
    +                     auto&&...) {
    +            testcase("IOU cannot use different asset");
    +            PrettyAsset const foo = issuer["FOO"];
    +
    +            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +            env(tx);
    +            env.close();
    +
    +            {
    +                // Cannot create new trustline to a vault
    +                auto tx = [&, account = vaultAccount(keylet)]() {
    +                    json::Value jv;
    +                    jv[jss::Account] = issuer.human();
    +                    {
    +                        auto& ja = jv[jss::LimitAmount] =
    +                            foo(0).value().getJson(JsonOptions::Values::None);
    +                        ja[jss::issuer] = toBase58(account);
    +                    }
    +                    jv[jss::TransactionType] = jss::TrustSet;
    +                    jv[jss::Flags] = tfSetFreeze;
    +                    return jv;
    +                }();
    +                env(tx, Ter{tecNO_PERMISSION});
    +                env.close();
    +            }
    +
    +            {
    +                auto tx = vault.deposit({.depositor = issuer, .id = keylet.key, .amount = foo(20)});
    +                env(tx, Ter{tecWRONG_ASSET});
    +                env.close();
    +            }
    +
    +            {
    +                auto tx =
    +                    vault.withdraw({.depositor = issuer, .id = keylet.key, .amount = foo(20)});
    +                env(tx, Ter{tecWRONG_ASSET});
    +                env.close();
    +            }
    +
    +            env(vault.del({.owner = owner, .id = keylet.key}));
    +            env.close();
    +        });
    +
    +        testCase(
    +            [&, this](
    +                Env& env,
    +                Account const& owner,
    +                Account const& issuer,
    +                Account const& charlie,
    +                auto vaultAccount,
    +                Vault& vault,
    +                PrettyAsset const& asset,
    +                auto issuanceId) {
    +                testcase("IOU transfer fees not applied");
    +
    +                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +                env(tx);
    +                env.close();
    +
    +                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)}));
    +                env.close();
    +
    +                auto const issue = asset.raw().get();
    +                Asset const share = Asset(issuanceId(keylet));
    +
    +                // transfer fees ignored on deposit
    +                BEAST_EXPECT(env.balance(owner, issue) == asset(100));
    +                BEAST_EXPECT(env.balance(vaultAccount(keylet), issue) == asset(100));
    +
    +                {
    +                    auto tx = vault.clawback(
    +                        {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(50)});
    +                    env(tx);
    +                    env.close();
    +                }
    +
    +                // transfer fees ignored on clawback
    +                BEAST_EXPECT(env.balance(owner, issue) == asset(100));
    +                BEAST_EXPECT(env.balance(vaultAccount(keylet), issue) == asset(50));
    +
    +                env(vault.withdraw(
    +                    {.depositor = owner, .id = keylet.key, .amount = share(20'000'000)}));
    +
    +                // transfer fees ignored on withdraw
    +                BEAST_EXPECT(env.balance(owner, issue) == asset(120));
    +                BEAST_EXPECT(env.balance(vaultAccount(keylet), issue) == asset(30));
    +
    +                {
    +                    auto tx = vault.withdraw(
    +                        {.depositor = owner, .id = keylet.key, .amount = share(30'000'000)});
    +                    tx[sfDestination] = charlie.human();
    +                    env(tx);
    +                }
    +
    +                // transfer fees ignored on withdraw to 3rd party
    +                BEAST_EXPECT(env.balance(owner, issue) == asset(120));
    +                BEAST_EXPECT(env.balance(charlie, issue) == asset(30));
    +                BEAST_EXPECT(env.balance(vaultAccount(keylet), issue) == asset(0));
    +
    +                env(vault.del({.owner = owner, .id = keylet.key}));
    +                env.close();
    +            },
    +            CaseArgs{.transferRate = 1.25});
    +
    +        testCase([&, this](
    +                     Env& env,
    +                     Account const& owner,
    +                     Account const& issuer,
    +                     Account const& charlie,
    +                     auto,
    +                     Vault& vault,
    +                     PrettyAsset const& asset,
    +                     auto&&...) {
    +            testcase("IOU no trust line to 3rd party");
    +
    +            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +            env(tx);
    +            env.close();
    +
    +            env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)}));
    +            env.close();
    +
    +            Account const erin{"erin"};
    +            env.fund(XRP(1000), erin);
    +            env.close();
    +
    +            // Withdraw to 3rd party without trust line
    +            auto const tx1 = [&](xrpl::Keylet keylet) {
    +                auto tx =
    +                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)});
    +                tx[sfDestination] = erin.human();
    +                return tx;
    +            }(keylet);
    +            env(tx1, Ter{tecNO_LINE});
    +        });
    +
    +        testCase([&, this](
    +                     Env& env,
    +                     Account const& owner,
    +                     Account const& issuer,
    +                     Account const& charlie,
    +                     auto,
    +                     Vault& vault,
    +                     PrettyAsset const& asset,
    +                     auto&&...) {
    +            testcase("IOU no trust line to depositor");
    +
    +            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +            env(tx);
    +            env.close();
    +
    +            // reset limit, so deposit of all funds will delete the trust line
    +            env.trust(asset(0), owner);
    +            env.close();
    +
    +            env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(200)}));
    +            env.close();
    +
    +            auto trustline = env.le(keylet::trustLine(owner, asset.raw().get()));
    +            BEAST_EXPECT(trustline == nullptr);
    +
    +            // Withdraw without trust line, will succeed
    +            auto const tx1 = [&](xrpl::Keylet keylet) {
    +                auto tx =
    +                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)});
    +                return tx;
    +            }(keylet);
    +            env(tx1);
    +        });
    +
    +        testCase(
    +            [&, this](
    +                Env& env,
    +                Account const& owner,
    +                Account const& issuer,
    +                Account const& charlie,
    +                auto vaultAccount,
    +                Vault& vault,
    +                PrettyAsset const& asset,
    +                std::function issuanceId) {
    +                testcase("IOU non-transferable");
    +
    +                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +                tx[sfScale] = 0;
    +                env(tx);
    +                env.close();
    +
    +                // Turn on noripple on the pseudo account's trust line.
    +                // Charlie's is already set.
    +                env(trust(issuer, vaultAccount(keylet)["IOU"], tfSetNoRipple));
    +
    +                {
    +                    // Charlie cannot deposit
    +                    auto tx = vault.deposit(
    +                        {.depositor = charlie, .id = keylet.key, .amount = asset(100)});
    +                    env(tx, Ter{terNO_RIPPLE});
    +                    env.close();
    +                }
    +
    +                {
    +                    PrettyAsset const shares = issuanceId(keylet);
    +                    auto tx1 =
    +                        vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)});
    +                    env(tx1);
    +                    env.close();
    +
    +                    // Charlie cannot receive funds
    +                    auto tx2 = vault.withdraw(
    +                        {.depositor = owner, .id = keylet.key, .amount = shares(100)});
    +                    tx2[sfDestination] = charlie.human();
    +                    env(tx2, Ter{terNO_RIPPLE});
    +                    env.close();
    +
    +                    {
    +                        // Create MPToken for shares held by Charlie
    +                        json::Value tx{json::ValueType::Object};
    +                        tx[sfAccount] = charlie.human();
    +                        tx[sfMPTokenIssuanceID] =
    +                            to_string(shares.raw().get().getMptID());
    +                        tx[sfTransactionType] = jss::MPTokenAuthorize;
    +                        env(tx);
    +                        env.close();
    +                    }
    +                    // Behavioral shift introduced by share inheritance:
    +                    // before fixCleanup3_2_0 this share Payment succeeded
    +                    // and the underlying IOU's NoRipple restriction surfaced
    +                    // only later on Charlie's withdrawal (terNO_RIPPLE).
    +                    // Post-amendment, canTransfer reads the share's
    +                    // sfReferenceHolding and dispatches to the underlying IOU;
    +                    // rippling is disabled between owner and charlie so the
    +                    // share payment itself is now blocked. tecPATH_DRY is
    +                    // the path-find layer's translation of the underlying
    +                    // terNO_RIPPLE under featureMPTokensV2.
    +                    env(pay(owner, charlie, shares(100)), Ter{tecPATH_DRY});
    +                    env.close();
    +                }
    +
    +                tx = vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(100)});
    +                env(tx);
    +                env.close();
    +
    +                // Delete vault with zero balance
    +                env(vault.del({.owner = owner, .id = keylet.key}));
    +            },
    +            {.charlieRipple = false});
    +
    +        testCase(
    +            [&, this](
    +                Env& env,
    +                Account const& owner,
    +                Account const& issuer,
    +                Account const& charlie,
    +                auto const& vaultAccount,
    +                Vault& vault,
    +                PrettyAsset const& asset,
    +                auto&&...) {
    +                testcase("IOU calculation rounding");
    +
    +                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +                tx[sfScale] = 1;
    +                env(tx);
    +                env.close();
    +
    +                auto const startingOwnerBalance = env.balance(owner, asset);
    +                BEAST_EXPECT((startingOwnerBalance.value() == STAmount{asset, 11875, -2}));
    +
    +                // This operation (first deposit 100, then 3.75 x 5) is known to
    +                // have triggered calculation rounding errors in Number
    +                // (addition and division), causing the last deposit to be
    +                // blocked by Vault invariants.
    +                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(100)}));
    +
    +                auto const tx1 = vault.deposit(
    +                    {.depositor = owner, .id = keylet.key, .amount = asset(Number(375, -2))});
    +                for (auto i = 0; i < 5; ++i)
    +                {
    +                    env(tx1);
    +                }
    +                env.close();
    +
    +                {
    +                    STAmount const xfer{asset, 1185, -1};
    +                    BEAST_EXPECT(env.balance(owner, asset) == startingOwnerBalance.value() - xfer);
    +                    BEAST_EXPECT(env.balance(vaultAccount(keylet), asset) == xfer);
    +
    +                    auto const vault = env.le(keylet);
    +                    BEAST_EXPECT(vault->at(sfAssetsAvailable) == xfer);
    +                    BEAST_EXPECT(vault->at(sfAssetsTotal) == xfer);
    +                }
    +
    +                // Total vault balance should be 118.5 IOU. Withdraw and delete
    +                // the vault to verify this exact amount was deposited and the
    +                // owner has matching shares
    +                env(vault.withdraw(
    +                    {.depositor = owner,
    +                     .id = keylet.key,
    +                     .amount = asset(Number(1000 + (37 * 5), -1))}));
    +
    +                {
    +                    BEAST_EXPECT(env.balance(owner, asset) == startingOwnerBalance.value());
    +                    BEAST_EXPECT(env.balance(vaultAccount(keylet), asset) == beast::kZero);
    +                    auto const vault = env.le(keylet);
    +                    BEAST_EXPECT(vault->at(sfAssetsAvailable) == beast::kZero);
    +                    BEAST_EXPECT(vault->at(sfAssetsTotal) == beast::kZero);
    +                }
    +
    +                env(vault.del({.owner = owner, .id = keylet.key}));
    +                env.close();
    +            },
    +            {.initialIOU = Number(11875, -2)});
    +
    +        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,
    +                Account const& owner,
    +                Account const& issuer,
    +                Account const& charlie,
    +                auto,
    +                Vault& vault,
    +                PrettyAsset const& asset,
    +                auto&&...) {
    +                testcase("IOU no trust line to depositor no reserve");
    +                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +                env(tx);
    +                env.close();
    +
    +                // reset limit, so deposit of all funds will delete the trust
    +                // line
    +                env.trust(asset(0), owner);
    +                env.close();
    +
    +                env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(200)}));
    +                env.close();
    +
    +                auto trustline = env.le(keylet::trustLine(owner, asset.raw().get()));
    +                BEAST_EXPECT(trustline == nullptr);
    +
    +                env(ticket::create(owner, 1));
    +                env.close();
    +
    +                // Fail because not enough reserve to create trust line
    +                tx = vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)});
    +                env(tx, Ter{tecNO_LINE_INSUF_RESERVE});
    +                env.close();
    +
    +                env(pay(charlie, owner, XRP(incReserve)));
    +                env.close();
    +
    +                // Withdraw can now create trust line, will succeed
    +                env(tx);
    +                env.close();
    +            },
    +            CaseArgs{.initialXRP = acctReserve + (incReserve * 4) + 1});
    +
    +        testCase(
    +            [&, this](
    +                Env& env,
    +                Account const& owner,
    +                Account const& issuer,
    +                Account const& charlie,
    +                auto,
    +                Vault& vault,
    +                PrettyAsset const& asset,
    +                auto&&...) {
    +                testcase("IOU no reserve for share MPToken");
    +                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +                env(tx);
    +                env.close();
    +
    +                env(pay(owner, charlie, asset(100)));
    +                env.close();
    +
    +                env(ticket::create(charlie, 3));
    +                env.close();
    +
    +                // Fail because not enough reserve to create MPToken for shares
    +                tx = vault.deposit({.depositor = charlie, .id = keylet.key, .amount = asset(100)});
    +                env(tx, Ter{tecINSUFFICIENT_RESERVE});
    +                env.close();
    +
    +                env(pay(issuer, charlie, XRP(incReserve)));
    +                env.close();
    +
    +                // Deposit can now create MPToken, will succeed
    +                env(tx);
    +                env.close();
    +            },
    +            CaseArgs{.initialXRP = acctReserve + (incReserve * 4) + 1});
    +    }
    +
    +public:
    +    void
    +    run() override
    +    {
    +        testSequences();
    +        testWithMPT();
    +        testWithIOU();
    +    }
    +};
    +
    +BEAST_DEFINE_TESTSUITE_PRIO(VaultLifecycle, app, xrpl, 1);
    +
    +}  // namespace xrpl
    diff --git a/src/test/app/vault/VaultRPC_test.cpp b/src/test/app/vault/VaultRPC_test.cpp
    new file mode 100644
    index 0000000000..2ac092b5a7
    --- /dev/null
    +++ b/src/test/app/vault/VaultRPC_test.cpp
    @@ -0,0 +1,543 @@
    +#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 {
    +
    +class VaultRPC_test : public VaultTestBase
    +{
    +private:
    +    void
    +    testRPC()
    +    {
    +        using namespace test::jtx;
    +
    +        testcase("RPC");
    +        Env env{*this, testableAmendments()};
    +        Account const owner{"owner"};
    +        Account const issuer{"issuer"};
    +        Vault const vault{env};
    +        env.fund(XRP(1000), issuer, owner);
    +        env.close();
    +
    +        PrettyAsset const asset = issuer["IOU"];
    +        env.trust(asset(1000), owner);
    +        env(pay(issuer, owner, asset(200)));
    +        env.close();
    +
    +        auto const sequence = env.seq(owner);
    +        auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +        env(tx);
    +        env.close();
    +
    +        // Set some fields
    +        {
    +            auto tx1 = vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(50)});
    +            env(tx1);
    +
    +            auto tx2 = vault.set({.owner = owner, .id = keylet.key});
    +            tx2[sfAssetsMaximum] = asset(1000).number();
    +            env(tx2);
    +            env.close();
    +        }
    +
    +        auto const sleVault = [&env, keylet = keylet, this]() {
    +            auto const vault = env.le(keylet);
    +            BEAST_EXPECT(vault != nullptr);
    +            return vault;
    +        }();
    +
    +        auto const check = [&, keylet = keylet, sle = sleVault, this](
    +                               json::Value const& vault,
    +                               json::Value const& issuance = json::ValueType::Null) {
    +            BEAST_EXPECT(vault.isObject());
    +
    +            static constexpr auto kCheckString =
    +                [](auto& node, SField const& field, std::string v) -> bool {
    +                return node.isMember(field.fieldName) && node[field.fieldName].isString() &&
    +                    node[field.fieldName] == v;
    +            };
    +            static constexpr auto kCheckObject =
    +                [](auto& node, SField const& field, json::Value v) -> bool {
    +                return node.isMember(field.fieldName) && node[field.fieldName].isObject() &&
    +                    node[field.fieldName] == v;
    +            };
    +            static constexpr auto kCheckInt = [](auto& node, SField const& field, int v) -> bool {
    +                return node.isMember(field.fieldName) &&
    +                    ((node[field.fieldName].isInt() && node[field.fieldName] == json::Int(v)) ||
    +                     (node[field.fieldName].isUInt() && node[field.fieldName] == json::UInt(v)));
    +            };
    +
    +            BEAST_EXPECT(vault["LedgerEntryType"].asString() == "Vault");
    +            BEAST_EXPECT(vault[jss::index].asString() == strHex(keylet.key));
    +            BEAST_EXPECT(kCheckInt(vault, sfFlags, 0));
    +            // Ignore all other standard fields, this test doesn't care
    +
    +            BEAST_EXPECT(kCheckString(vault, sfAccount, toBase58(sle->at(sfAccount))));
    +            BEAST_EXPECT(kCheckObject(vault, sfAsset, toJson(sle->at(sfAsset))));
    +            BEAST_EXPECT(kCheckString(vault, sfAssetsAvailable, "50"));
    +            BEAST_EXPECT(kCheckString(vault, sfAssetsMaximum, "1000"));
    +            BEAST_EXPECT(kCheckString(vault, sfAssetsTotal, "50"));
    +            BEAST_EXPECT(!vault.isMember(sfLossUnrealized.getJsonName()));
    +
    +            auto const strShareID = strHex(sle->at(sfShareMPTID));
    +            BEAST_EXPECT(kCheckString(vault, sfShareMPTID, strShareID));
    +            BEAST_EXPECT(kCheckString(vault, sfOwner, toBase58(owner.id())));
    +            BEAST_EXPECT(kCheckInt(vault, sfSequence, sequence));
    +            BEAST_EXPECT(kCheckInt(vault, sfWithdrawalPolicy, kVaultStrategyFirstComeFirstServe));
    +
    +            if (issuance.isObject())
    +            {
    +                BEAST_EXPECT(issuance["LedgerEntryType"].asString() == "MPTokenIssuance");
    +                BEAST_EXPECT(issuance[jss::mpt_issuance_id].asString() == strShareID);
    +                BEAST_EXPECT(kCheckInt(issuance, sfSequence, 1));
    +                BEAST_EXPECT(kCheckInt(
    +                    issuance, sfFlags, int(lsfMPTCanEscrow | lsfMPTCanTrade | lsfMPTCanTransfer)));
    +                BEAST_EXPECT(kCheckString(issuance, sfOutstandingAmount, "50000000"));
    +            }
    +        };
    +
    +        {
    +            testcase("RPC ledger_entry selected by key");
    +            json::Value jvParams;
    +            jvParams[jss::ledger_index] = jss::validated;
    +            jvParams[jss::vault] = strHex(keylet.key);
    +            auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
    +
    +            BEAST_EXPECT(!jvVault[jss::result].isMember(jss::error));
    +            BEAST_EXPECT(jvVault[jss::result].isMember(jss::node));
    +            check(jvVault[jss::result][jss::node]);
    +        }
    +
    +        {
    +            testcase("RPC ledger_entry selected by owner and seq");
    +            json::Value jvParams;
    +            jvParams[jss::ledger_index] = jss::validated;
    +            jvParams[jss::vault][jss::owner] = owner.human();
    +            jvParams[jss::vault][jss::seq] = sequence;
    +            auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
    +
    +            BEAST_EXPECT(!jvVault[jss::result].isMember(jss::error));
    +            BEAST_EXPECT(jvVault[jss::result].isMember(jss::node));
    +            check(jvVault[jss::result][jss::node]);
    +        }
    +
    +        {
    +            testcase("RPC ledger_entry cannot find vault by key");
    +            json::Value jvParams;
    +            jvParams[jss::ledger_index] = jss::validated;
    +            jvParams[jss::vault] = to_string(uint256(42));
    +            auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
    +            BEAST_EXPECT(jvVault[jss::result][jss::error].asString() == "entryNotFound");
    +        }
    +
    +        {
    +            testcase("RPC ledger_entry cannot find vault by owner and seq");
    +            json::Value jvParams;
    +            jvParams[jss::ledger_index] = jss::validated;
    +            jvParams[jss::vault][jss::owner] = issuer.human();
    +            jvParams[jss::vault][jss::seq] = 1'000'000;
    +            auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
    +            BEAST_EXPECT(jvVault[jss::result][jss::error].asString() == "entryNotFound");
    +        }
    +
    +        {
    +            testcase("RPC ledger_entry malformed key");
    +            json::Value jvParams;
    +            jvParams[jss::ledger_index] = jss::validated;
    +            jvParams[jss::vault] = 42;
    +            auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
    +            BEAST_EXPECT(jvVault[jss::result][jss::error].asString() == "malformedRequest");
    +        }
    +
    +        {
    +            testcase("RPC ledger_entry malformed owner");
    +            json::Value jvParams;
    +            jvParams[jss::ledger_index] = jss::validated;
    +            jvParams[jss::vault][jss::owner] = 42;
    +            jvParams[jss::vault][jss::seq] = sequence;
    +            auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
    +            BEAST_EXPECT(jvVault[jss::result][jss::error].asString() == "malformedOwner");
    +        }
    +
    +        {
    +            testcase("RPC ledger_entry malformed seq");
    +            json::Value jvParams;
    +            jvParams[jss::ledger_index] = jss::validated;
    +            jvParams[jss::vault][jss::owner] = issuer.human();
    +            jvParams[jss::vault][jss::seq] = "foo";
    +            auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
    +            BEAST_EXPECT(jvVault[jss::result][jss::error].asString() == "malformedRequest");
    +        }
    +
    +        {
    +            testcase("RPC ledger_entry negative seq");
    +            json::Value jvParams;
    +            jvParams[jss::ledger_index] = jss::validated;
    +            jvParams[jss::vault][jss::owner] = issuer.human();
    +            jvParams[jss::vault][jss::seq] = -1;
    +            auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
    +            BEAST_EXPECT(jvVault[jss::result][jss::error].asString() == "malformedRequest");
    +        }
    +
    +        {
    +            testcase("RPC ledger_entry oversized seq");
    +            json::Value jvParams;
    +            jvParams[jss::ledger_index] = jss::validated;
    +            jvParams[jss::vault][jss::owner] = issuer.human();
    +            jvParams[jss::vault][jss::seq] = 1e20;
    +            auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
    +            BEAST_EXPECT(jvVault[jss::result][jss::error].asString() == "malformedRequest");
    +        }
    +
    +        {
    +            testcase("RPC ledger_entry bool seq");
    +            json::Value jvParams;
    +            jvParams[jss::ledger_index] = jss::validated;
    +            jvParams[jss::vault][jss::owner] = issuer.human();
    +            jvParams[jss::vault][jss::seq] = true;
    +            auto jvVault = env.rpc("json", "ledger_entry", to_string(jvParams));
    +            BEAST_EXPECT(jvVault[jss::result][jss::error].asString() == "malformedRequest");
    +        }
    +
    +        {
    +            testcase("RPC account_objects");
    +
    +            json::Value jvParams;
    +            jvParams[jss::account] = owner.human();
    +            jvParams[jss::type] = jss::vault;
    +            auto jv = env.rpc("json", "account_objects", to_string(jvParams))[jss::result];
    +
    +            BEAST_EXPECT(jv[jss::account_objects].size() == 1);
    +            check(jv[jss::account_objects][0u]);
    +        }
    +
    +        {
    +            testcase("RPC ledger_data");
    +
    +            json::Value jvParams;
    +            jvParams[jss::ledger_index] = jss::validated;
    +            jvParams[jss::binary] = false;
    +            jvParams[jss::type] = jss::vault;
    +            json::Value jv = env.rpc("json", "ledger_data", to_string(jvParams));
    +            BEAST_EXPECT(jv[jss::result][jss::state].size() == 1);
    +            check(jv[jss::result][jss::state][0u]);
    +        }
    +
    +        {
    +            testcase("RPC vault_info command line");
    +            json::Value jv = env.rpc("vault_info", strHex(keylet.key), "validated");
    +
    +            BEAST_EXPECT(!jv[jss::result].isMember(jss::error));
    +            BEAST_EXPECT(jv[jss::result].isMember(jss::vault));
    +            check(jv[jss::result][jss::vault], jv[jss::result][jss::vault][jss::shares]);
    +        }
    +
    +        {
    +            testcase("RPC vault_info json");
    +            json::Value jvParams;
    +            jvParams[jss::ledger_index] = jss::validated;
    +            jvParams[jss::vault_id] = strHex(keylet.key);
    +            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
    +
    +            BEAST_EXPECT(!jv[jss::result].isMember(jss::error));
    +            BEAST_EXPECT(jv[jss::result].isMember(jss::vault));
    +            check(jv[jss::result][jss::vault], jv[jss::result][jss::vault][jss::shares]);
    +        }
    +
    +        {
    +            testcase("RPC vault_info invalid vault_id");
    +            json::Value jvParams;
    +            jvParams[jss::ledger_index] = jss::validated;
    +            jvParams[jss::vault_id] = "foobar";
    +            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
    +            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
    +        }
    +
    +        {
    +            testcase("RPC vault_info json invalid index");
    +            json::Value jvParams;
    +            jvParams[jss::ledger_index] = jss::validated;
    +            jvParams[jss::vault_id] = 0;
    +            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
    +            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
    +        }
    +
    +        {
    +            testcase("RPC vault_info json by owner and sequence");
    +            json::Value jvParams;
    +            jvParams[jss::ledger_index] = jss::validated;
    +            jvParams[jss::owner] = owner.human();
    +            jvParams[jss::seq] = sequence;
    +            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
    +
    +            BEAST_EXPECT(!jv[jss::result].isMember(jss::error));
    +            BEAST_EXPECT(jv[jss::result].isMember(jss::vault));
    +            check(jv[jss::result][jss::vault], jv[jss::result][jss::vault][jss::shares]);
    +        }
    +
    +        {
    +            testcase("RPC vault_info json malformed sequence");
    +            json::Value jvParams;
    +            jvParams[jss::ledger_index] = jss::validated;
    +            jvParams[jss::owner] = owner.human();
    +            jvParams[jss::seq] = "foobar";
    +            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
    +            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
    +        }
    +
    +        {
    +            testcase("RPC vault_info json invalid sequence");
    +            json::Value jvParams;
    +            jvParams[jss::ledger_index] = jss::validated;
    +            jvParams[jss::owner] = owner.human();
    +            jvParams[jss::seq] = 0;
    +            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
    +            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
    +        }
    +
    +        {
    +            testcase("RPC vault_info json negative sequence");
    +            json::Value jvParams;
    +            jvParams[jss::ledger_index] = jss::validated;
    +            jvParams[jss::owner] = owner.human();
    +            jvParams[jss::seq] = -1;
    +            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
    +            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
    +        }
    +
    +        {
    +            testcase("RPC vault_info json oversized sequence");
    +            json::Value jvParams;
    +            jvParams[jss::ledger_index] = jss::validated;
    +            jvParams[jss::owner] = owner.human();
    +            jvParams[jss::seq] = 1e20;
    +            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
    +            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
    +        }
    +
    +        {
    +            testcase("RPC vault_info json bool sequence");
    +            json::Value jvParams;
    +            jvParams[jss::ledger_index] = jss::validated;
    +            jvParams[jss::owner] = owner.human();
    +            jvParams[jss::seq] = true;
    +            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
    +            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
    +        }
    +
    +        {
    +            testcase("RPC vault_info json malformed owner");
    +            json::Value jvParams;
    +            jvParams[jss::ledger_index] = jss::validated;
    +            jvParams[jss::owner] = "foobar";
    +            jvParams[jss::seq] = sequence;
    +            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
    +            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
    +        }
    +
    +        {
    +            testcase("RPC vault_info json invalid combination only owner");
    +            json::Value jvParams;
    +            jvParams[jss::ledger_index] = jss::validated;
    +            jvParams[jss::owner] = owner.human();
    +            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
    +            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
    +        }
    +
    +        {
    +            testcase("RPC vault_info json invalid combination only seq");
    +            json::Value jvParams;
    +            jvParams[jss::ledger_index] = jss::validated;
    +            jvParams[jss::seq] = sequence;
    +            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
    +            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
    +        }
    +
    +        {
    +            testcase("RPC vault_info json invalid combination seq vault_id");
    +            json::Value jvParams;
    +            jvParams[jss::ledger_index] = jss::validated;
    +            jvParams[jss::vault_id] = strHex(keylet.key);
    +            jvParams[jss::seq] = sequence;
    +            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
    +            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
    +        }
    +
    +        {
    +            testcase("RPC vault_info json invalid combination owner vault_id");
    +            json::Value jvParams;
    +            jvParams[jss::ledger_index] = jss::validated;
    +            jvParams[jss::vault_id] = strHex(keylet.key);
    +            jvParams[jss::owner] = owner.human();
    +            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
    +            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
    +        }
    +
    +        {
    +            testcase(
    +                "RPC vault_info json invalid combination owner seq "
    +                "vault_id");
    +            json::Value jvParams;
    +            jvParams[jss::ledger_index] = jss::validated;
    +            jvParams[jss::vault_id] = strHex(keylet.key);
    +            jvParams[jss::seq] = sequence;
    +            jvParams[jss::owner] = owner.human();
    +            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
    +            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
    +        }
    +
    +        {
    +            testcase("RPC vault_info json no input");
    +            json::Value jvParams;
    +            jvParams[jss::ledger_index] = jss::validated;
    +            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
    +            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
    +        }
    +
    +        {
    +            testcase("RPC vault_info command line invalid index");
    +            json::Value jv = env.rpc("vault_info", "foobar", "validated");
    +            BEAST_EXPECT(jv[jss::error].asString() == "invalidParams");
    +        }
    +
    +        {
    +            testcase("RPC vault_info command line invalid index");
    +            json::Value jv = env.rpc("vault_info", "0", "validated");
    +            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest");
    +        }
    +
    +        {
    +            testcase("RPC vault_info command line invalid index");
    +            json::Value jv = env.rpc("vault_info", strHex(uint256(42)), "validated");
    +            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "entryNotFound");
    +        }
    +
    +        {
    +            testcase("RPC vault_info command line invalid ledger");
    +            json::Value jv = env.rpc("vault_info", strHex(keylet.key), "0");
    +            BEAST_EXPECT(jv[jss::result][jss::error].asString() == "lgrNotFound");
    +        }
    +    }
    +
    +    // RPC coverage: closed-ended vaults must return VaultKind, SubscriptionDate and RedemptionDate
    +    // in both vault_info and ledger_entry responses. Open-ended vaults must not.
    +    void
    +    testRPCClosedEnded()
    +    {
    +        using namespace test::jtx;
    +
    +        testcase("RPC closed-ended vault fields");
    +        Env env{*this, testableAmendments()};
    +        Account const owner{"owner"};
    +        Account const owner2{"owner2"};
    +        env.fund(XRP(1000), owner, owner2);
    +        env.close();
    +
    +        auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded);
    +        Asset const asset = xrpIssue();
    +        auto const sub = env.now().time_since_epoch().count() + 60;
    +        auto const red = sub + kMinInvestmentPeriod;
    +
    +        Vault const vault{env};
    +        auto [tx, keylet] = vault.create(
    +            {.owner = owner,
    +             .asset = asset,
    +             .vaultKind = closedEnded,
    +             .subscriptionDate = sub,
    +             .redemptionDate = red});
    +        env(tx);
    +        env.close();
    +
    +        auto [tx2, keylet2] = vault.create({.owner = owner2, .asset = asset});
    +        env(tx2);
    +        env.close();
    +
    +        auto const asUInt = [](json::Value const& jv) -> json::UInt {
    +            return jv.isUInt() ? jv.asUInt() : json::UInt(jv.asInt());
    +        };
    +        auto const checkClosedEnded = [&](json::Value const& v) {
    +            BEAST_EXPECT(v.isObject());
    +            BEAST_EXPECT(v.isMember(sfVaultKind.fieldName));
    +            BEAST_EXPECT(asUInt(v[sfVaultKind.fieldName]) == json::UInt(closedEnded));
    +            BEAST_EXPECT(v.isMember(sfSubscriptionDate.fieldName));
    +            BEAST_EXPECT(asUInt(v[sfSubscriptionDate.fieldName]) == json::UInt(sub));
    +            BEAST_EXPECT(v.isMember(sfRedemptionDate.fieldName));
    +            BEAST_EXPECT(asUInt(v[sfRedemptionDate.fieldName]) == json::UInt(red));
    +        };
    +        auto const checkOpenEnded = [&](json::Value const& v) {
    +            BEAST_EXPECT(v.isObject());
    +            BEAST_EXPECT(!v.isMember(sfVaultKind.fieldName));
    +            BEAST_EXPECT(!v.isMember(sfSubscriptionDate.fieldName));
    +            BEAST_EXPECT(!v.isMember(sfRedemptionDate.fieldName));
    +        };
    +
    +        {
    +            json::Value jvParams;
    +            jvParams[jss::vault_id] = strHex(keylet.key);
    +            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
    +            BEAST_EXPECT(!jv[jss::result].isMember(jss::error));
    +            checkClosedEnded(jv[jss::result][jss::vault]);
    +        }
    +        {
    +            json::Value jvParams;
    +            jvParams[jss::ledger_index] = jss::validated;
    +            jvParams[jss::vault] = strHex(keylet.key);
    +            auto jv = env.rpc("json", "ledger_entry", to_string(jvParams));
    +            BEAST_EXPECT(!jv[jss::result].isMember(jss::error));
    +            checkClosedEnded(jv[jss::result][jss::node]);
    +        }
    +        {
    +            json::Value jvParams;
    +            jvParams[jss::vault_id] = strHex(keylet2.key);
    +            auto jv = env.rpc("json", "vault_info", to_string(jvParams));
    +            BEAST_EXPECT(!jv[jss::result].isMember(jss::error));
    +            checkOpenEnded(jv[jss::result][jss::vault]);
    +        }
    +        {
    +            json::Value jvParams;
    +            jvParams[jss::ledger_index] = jss::validated;
    +            jvParams[jss::vault] = strHex(keylet2.key);
    +            auto jv = env.rpc("json", "ledger_entry", to_string(jvParams));
    +            BEAST_EXPECT(!jv[jss::result].isMember(jss::error));
    +            checkOpenEnded(jv[jss::result][jss::node]);
    +        }
    +    }
    +
    +public:
    +    void
    +    run() override
    +    {
    +        testRPC();
    +        testRPCClosedEnded();
    +    }
    +};
    +
    +BEAST_DEFINE_TESTSUITE(VaultRPC, app, xrpl);
    +
    +}  // namespace xrpl
    diff --git a/src/test/app/vault/VaultScale_test.cpp b/src/test/app/vault/VaultScale_test.cpp
    new file mode 100644
    index 0000000000..94c594f674
    --- /dev/null
    +++ b/src/test/app/vault/VaultScale_test.cpp
    @@ -0,0 +1,1228 @@
    +#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 {
    +
    +class VaultScale_test : public VaultTestBase
    +{
    +private:
    +    void
    +    testScaleIOU()
    +    {
    +        using namespace test::jtx;
    +
    +        struct Data
    +        {
    +            Account const& owner;
    +            Account const& issuer;
    +            Account const& depositor;
    +            Account const& vaultAccount;
    +            MPTIssue shares;
    +            PrettyAsset const& share;
    +            Vault& vault;
    +            xrpl::Keylet keylet;
    +            Issue assets;
    +            PrettyAsset const& asset;
    +            std::function)> peek;
    +        };
    +
    +        auto testCase = [&, this](
    +                            std::uint8_t scale, std::function test) {
    +            Env env{*this, testableAmendments()};
    +            Account const owner{"owner"};
    +            Account const issuer{"issuer"};
    +            Account const depositor{"depositor"};
    +            Vault vault{env};
    +            env.fund(XRP(1000), issuer, owner, depositor);
    +            env(fset(issuer, asfAllowTrustLineClawback));
    +            env.close();
    +
    +            PrettyAsset const asset = issuer["IOU"];
    +            env.trust(asset(1000), owner);
    +            env.trust(asset(1000), depositor);
    +            env(pay(issuer, owner, asset(200)));
    +            env(pay(issuer, depositor, asset(200)));
    +            env.close();
    +
    +            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +            tx[sfScale] = scale;
    +            env(tx);
    +
    +            auto const [vaultAccount, issuanceId] =
    +                [&env](xrpl::Keylet keylet) -> std::tuple {
    +                auto const vault = env.le(keylet);
    +                return {Account("vault", vault->at(sfAccount)), vault->at(sfShareMPTID)};
    +            }(keylet);
    +            MPTIssue const shares(issuanceId);
    +            env.memoize(vaultAccount);
    +
    +            auto const peek = [keylet, &env, this](std::function fn) -> bool {
    +                return env.app().getOpenLedger().modify(
    +                    [&](OpenView& view, beast::Journal j) -> bool {
    +                        Sandbox sb(&view, TapNone);
    +                        auto vault = sb.peek(keylet::vault(keylet.key));
    +                        if (!BEAST_EXPECT(vault))
    +                            return false;
    +                        auto shares = sb.peek(keylet::mptokenIssuance(vault->at(sfShareMPTID)));
    +                        if (!BEAST_EXPECT(shares))
    +                            return false;
    +                        if (fn(*vault, *shares))
    +                        {
    +                            sb.update(vault);
    +                            sb.update(shares);
    +                            sb.apply(view);
    +                            return true;
    +                        }
    +                        return false;
    +                    });
    +            };
    +
    +            test(
    +                env,
    +                {.owner = owner,
    +                 .issuer = issuer,
    +                 .depositor = depositor,
    +                 .vaultAccount = vaultAccount,
    +                 .shares = shares,
    +                 .share = PrettyAsset(shares),
    +                 .vault = vault,
    +                 .keylet = keylet,
    +                 .assets = asset.raw().get(),
    +                 .asset = asset,
    +                 .peek = peek});
    +        };
    +
    +        testCase(18, [&, this](Env& env, Data d) {
    +            testcase("Scale deposit overflow on first deposit");
    +            auto tx = d.vault.deposit(
    +                {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(10)});
    +            env(tx, Ter{tecPATH_DRY});
    +            env.close();
    +        });
    +
    +        testCase(18, [&, this](Env& env, Data d) {
    +            testcase("Scale deposit overflow on second deposit");
    +
    +            {
    +                auto tx = d.vault.deposit(
    +                    {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(5)});
    +                env(tx);
    +                env.close();
    +            }
    +
    +            {
    +                auto tx = d.vault.deposit(
    +                    {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(10)});
    +                env(tx, Ter{tecPATH_DRY});
    +                env.close();
    +            }
    +        });
    +
    +        testCase(18, [&, this](Env& env, Data d) {
    +            testcase("Scale deposit overflow on total shares");
    +
    +            {
    +                auto tx = d.vault.deposit(
    +                    {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(5)});
    +                env(tx);
    +                env.close();
    +            }
    +
    +            {
    +                auto tx = d.vault.deposit(
    +                    {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(5)});
    +                env(tx, Ter{tecPATH_DRY});
    +                env.close();
    +            }
    +        });
    +
    +        testCase(1, [&, this](Env& env, Data d) {
    +            testcase("Scale deposit exact");
    +
    +            auto const start = env.balance(d.depositor, d.assets).number();
    +            auto tx = d.vault.deposit(
    +                {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(1)});
    +            env(tx);
    +            env.close();
    +            BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(10));
    +            BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start - 1));
    +        });
    +
    +        testCase(1, [&, this](Env& env, Data d) {
    +            testcase("Scale deposit insignificant amount");
    +
    +            auto tx = d.vault.deposit(
    +                {.depositor = d.depositor,
    +                 .id = d.keylet.key,
    +                 .amount = STAmount(d.asset, Number(9, -2))});
    +            env(tx, Ter{tecPRECISION_LOSS});
    +        });
    +
    +        testCase(1, [&, this](Env& env, Data d) {
    +            testcase("Scale deposit exact, using full precision");
    +
    +            auto const start = env.balance(d.depositor, d.assets).number();
    +            auto tx = d.vault.deposit(
    +                {.depositor = d.depositor,
    +                 .id = d.keylet.key,
    +                 .amount = STAmount(d.asset, Number(15, -1))});
    +            env(tx);
    +            env.close();
    +            BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(15));
    +            BEAST_EXPECT(
    +                env.balance(d.depositor, d.assets) == STAmount(d.asset, start - Number(15, -1)));
    +        });
    +
    +        testCase(1, [&, this](Env& env, Data d) {
    +            testcase("Scale deposit exact, truncating from .5");
    +
    +            auto const start = env.balance(d.depositor, d.assets).number();
    +            // Each of the cases below will transfer exactly 1.2 IOU to the
    +            // vault and receive 12 shares in exchange
    +            {
    +                auto tx = d.vault.deposit(
    +                    {.depositor = d.depositor,
    +                     .id = d.keylet.key,
    +                     .amount = STAmount(d.asset, Number(125, -2))});
    +                env(tx);
    +                env.close();
    +                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(12));
    +                BEAST_EXPECT(
    +                    env.balance(d.depositor, d.assets) ==
    +                    STAmount(d.asset, start - Number(12, -1)));
    +            }
    +
    +            {
    +                auto tx = d.vault.deposit(
    +                    {.depositor = d.depositor,
    +                     .id = d.keylet.key,
    +                     .amount = STAmount(d.asset, Number(1201, -3))});
    +                env(tx);
    +                env.close();
    +                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(24));
    +                BEAST_EXPECT(
    +                    env.balance(d.depositor, d.assets) ==
    +                    STAmount(d.asset, start - Number(24, -1)));
    +            }
    +
    +            {
    +                auto tx = d.vault.deposit(
    +                    {.depositor = d.depositor,
    +                     .id = d.keylet.key,
    +                     .amount = STAmount(d.asset, Number(1299, -3))});
    +                env(tx);
    +                env.close();
    +                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(36));
    +                BEAST_EXPECT(
    +                    env.balance(d.depositor, d.assets) ==
    +                    STAmount(d.asset, start - Number(36, -1)));
    +            }
    +        });
    +
    +        testCase(1, [&, this](Env& env, Data d) {
    +            testcase("Scale deposit exact, truncating from .01");
    +
    +            auto const start = env.balance(d.depositor, d.assets).number();
    +            // round to 12
    +            auto tx = d.vault.deposit(
    +                {.depositor = d.depositor,
    +                 .id = d.keylet.key,
    +                 .amount = STAmount(d.asset, Number(1201, -3))});
    +            env(tx);
    +            env.close();
    +            BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(12));
    +            BEAST_EXPECT(
    +                env.balance(d.depositor, d.assets) == STAmount(d.asset, start - Number(12, -1)));
    +
    +            {
    +                // round to 6
    +                auto tx = d.vault.deposit(
    +                    {.depositor = d.depositor,
    +                     .id = d.keylet.key,
    +                     .amount = STAmount(d.asset, Number(69, -2))});
    +                env(tx);
    +                env.close();
    +                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(18));
    +                BEAST_EXPECT(
    +                    env.balance(d.depositor, d.assets) ==
    +                    STAmount(d.asset, start - Number(18, -1)));
    +            }
    +        });
    +
    +        testCase(1, [&, this](Env& env, Data d) {
    +            testcase("Scale deposit exact, truncating from .99");
    +
    +            auto const start = env.balance(d.depositor, d.assets).number();
    +            // round to 12
    +            auto tx = d.vault.deposit(
    +                {.depositor = d.depositor,
    +                 .id = d.keylet.key,
    +                 .amount = STAmount(d.asset, Number(1299, -3))});
    +            env(tx);
    +            env.close();
    +            BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(12));
    +            BEAST_EXPECT(
    +                env.balance(d.depositor, d.assets) == STAmount(d.asset, start - Number(12, -1)));
    +
    +            {
    +                // round to 6
    +                auto tx = d.vault.deposit(
    +                    {.depositor = d.depositor,
    +                     .id = d.keylet.key,
    +                     .amount = STAmount(d.asset, Number(62, -2))});
    +                env(tx);
    +                env.close();
    +                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(18));
    +                BEAST_EXPECT(
    +                    env.balance(d.depositor, d.assets) ==
    +                    STAmount(d.asset, start - Number(18, -1)));
    +            }
    +        });
    +
    +        testCase(1, [&, this](Env& env, Data d) {
    +            // initial setup: deposit 100 IOU, receive 1000 shares
    +            auto const start = env.balance(d.depositor, d.assets).number();
    +            auto tx = d.vault.deposit(
    +                {.depositor = d.depositor,
    +                 .id = d.keylet.key,
    +                 .amount = STAmount(d.asset, Number(100, 0))});
    +            env(tx);
    +            env.close();
    +            BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(1000));
    +            BEAST_EXPECT(
    +                env.balance(d.depositor, d.assets) == STAmount(d.asset, start - Number(100, 0)));
    +            BEAST_EXPECT(
    +                env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(100, 0)));
    +            BEAST_EXPECT(
    +                env.balance(d.vaultAccount, d.shares) == STAmount(d.share, Number(-1000, 0)));
    +
    +            {
    +                testcase("Scale redeem exact");
    +                // sharesToAssetsWithdraw:
    +                //  assets = assetsTotal * (shares / sharesTotal)
    +                //  assets = 100 * 100 / 1000 = 100 * 0.1 = 10
    +
    +                auto const start = env.balance(d.depositor, d.assets).number();
    +                auto tx = d.vault.withdraw(
    +                    {.depositor = d.depositor,
    +                     .id = d.keylet.key,
    +                     .amount = STAmount(d.share, Number(100, 0))});
    +                env(tx);
    +                env.close();
    +                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(900));
    +                BEAST_EXPECT(
    +                    env.balance(d.depositor, d.assets) == STAmount(d.asset, start + Number(10, 0)));
    +                BEAST_EXPECT(
    +                    env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(90, 0)));
    +                BEAST_EXPECT(
    +                    env.balance(d.vaultAccount, d.shares) == STAmount(d.share, Number(-900, 0)));
    +            }
    +
    +            {
    +                testcase("Scale redeem with rounding");
    +                // sharesToAssetsWithdraw:
    +                //  assets = assetsTotal * (shares / sharesTotal)
    +                //  assets = 90 * 25 / 900 = 90 * 0.02777... = 2.5
    +
    +                auto const start = env.balance(d.depositor, d.assets).number();
    +                d.peek([](SLE& vault, auto&) -> bool {
    +                    vault[sfAssetsAvailable] = Number(1);
    +                    return true;
    +                });
    +
    +                // Note, this transaction fails first (because of above change
    +                // in the open ledger) but then succeeds when the ledger is
    +                // closed (because a modification like above is not persistent),
    +                // which is why the checks below are expected to pass.
    +                auto tx = d.vault.withdraw(
    +                    {.depositor = d.depositor,
    +                     .id = d.keylet.key,
    +                     .amount = STAmount(d.share, Number(25, 0))});
    +                env(tx, Ter{tecINSUFFICIENT_FUNDS});
    +                env.close();
    +                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(900 - 25));
    +                BEAST_EXPECT(
    +                    env.balance(d.depositor, d.assets) ==
    +                    STAmount(d.asset, start + Number(25, -1)));
    +                BEAST_EXPECT(
    +                    env.balance(d.vaultAccount, d.assets) ==
    +                    STAmount(d.asset, Number(900 - 25, -1)));
    +                BEAST_EXPECT(
    +                    env.balance(d.vaultAccount, d.shares) ==
    +                    STAmount(d.share, -Number(900 - 25, 0)));
    +            }
    +
    +            {
    +                testcase("Scale redeem exact");
    +                // sharesToAssetsWithdraw:
    +                //  assets = assetsTotal * (shares / sharesTotal)
    +                //  assets = 87.5 * 21 / 875 = 87.5 * 0.024 = 2.1
    +
    +                auto const start = env.balance(d.depositor, d.assets).number();
    +
    +                tx = d.vault.withdraw(
    +                    {.depositor = d.depositor,
    +                     .id = d.keylet.key,
    +                     .amount = STAmount(d.share, Number(21, 0))});
    +                env(tx);
    +                env.close();
    +                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(875 - 21));
    +                BEAST_EXPECT(
    +                    env.balance(d.depositor, d.assets) ==
    +                    STAmount(d.asset, start + Number(21, -1)));
    +                BEAST_EXPECT(
    +                    env.balance(d.vaultAccount, d.assets) ==
    +                    STAmount(d.asset, Number(875 - 21, -1)));
    +                BEAST_EXPECT(
    +                    env.balance(d.vaultAccount, d.shares) ==
    +                    STAmount(d.share, -Number(875 - 21, 0)));
    +            }
    +
    +            {
    +                testcase("Scale redeem rest");
    +                auto const rest = env.balance(d.depositor, d.shares).number();
    +
    +                tx = d.vault.withdraw(
    +                    {.depositor = d.depositor,
    +                     .id = d.keylet.key,
    +                     .amount = STAmount(d.share, rest)});
    +                env(tx);
    +                env.close();
    +                BEAST_EXPECT(env.balance(d.depositor, d.shares).number() == 0);
    +                BEAST_EXPECT(env.balance(d.vaultAccount, d.assets).number() == 0);
    +                BEAST_EXPECT(env.balance(d.vaultAccount, d.shares).number() == 0);
    +            }
    +        });
    +
    +        testCase(18, [&, this](Env& env, Data d) {
    +            testcase("Scale withdraw overflow");
    +
    +            {
    +                auto tx = d.vault.deposit(
    +                    {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(5)});
    +                env(tx);
    +                env.close();
    +            }
    +
    +            {
    +                auto tx = d.vault.withdraw(
    +                    {.depositor = d.depositor,
    +                     .id = d.keylet.key,
    +                     .amount = STAmount(d.asset, Number(10, 0))});
    +                env(tx, Ter{tecPATH_DRY});
    +                env.close();
    +            }
    +        });
    +
    +        testCase(1, [&, this](Env& env, Data d) {
    +            // initial setup: deposit 100 IOU, receive 1000 shares
    +            auto const start = env.balance(d.depositor, d.assets).number();
    +            auto tx = d.vault.deposit(
    +                {.depositor = d.depositor,
    +                 .id = d.keylet.key,
    +                 .amount = STAmount(d.asset, Number(100, 0))});
    +            env(tx);
    +            env.close();
    +            BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(1000));
    +            BEAST_EXPECT(
    +                env.balance(d.depositor, d.assets) == STAmount(d.asset, start - Number(100, 0)));
    +            BEAST_EXPECT(
    +                env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(100, 0)));
    +            BEAST_EXPECT(
    +                env.balance(d.vaultAccount, d.shares) == STAmount(d.share, Number(-1000, 0)));
    +
    +            {
    +                testcase("Scale withdraw exact");
    +                // assetsToSharesWithdraw:
    +                //  shares = sharesTotal * (assets / assetsTotal)
    +                //  shares = 1000 * 10 / 100 = 1000 * 0.1 = 100
    +                // sharesToAssetsWithdraw:
    +                //  assets = assetsTotal * (shares / sharesTotal)
    +                //  assets = 100 * 100 / 1000 = 100 * 0.1 = 10
    +
    +                auto const start = env.balance(d.depositor, d.assets).number();
    +                auto tx = d.vault.withdraw(
    +                    {.depositor = d.depositor,
    +                     .id = d.keylet.key,
    +                     .amount = STAmount(d.asset, Number(10, 0))});
    +                env(tx);
    +                env.close();
    +                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(900));
    +                BEAST_EXPECT(
    +                    env.balance(d.depositor, d.assets) == STAmount(d.asset, start + Number(10, 0)));
    +                BEAST_EXPECT(
    +                    env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(90, 0)));
    +                BEAST_EXPECT(
    +                    env.balance(d.vaultAccount, d.shares) == STAmount(d.share, Number(-900, 0)));
    +            }
    +
    +            {
    +                testcase("Scale withdraw insignificant amount");
    +                auto tx = d.vault.withdraw(
    +                    {.depositor = d.depositor,
    +                     .id = d.keylet.key,
    +                     .amount = STAmount(d.asset, Number(4, -2))});
    +                env(tx, Ter{tecPRECISION_LOSS});
    +            }
    +
    +            {
    +                testcase("Scale withdraw with rounding assets");
    +                // assetsToSharesWithdraw:
    +                //  shares = sharesTotal * (assets / assetsTotal)
    +                //  shares = 900 * 2.5 / 90 = 900 * 0.02777... = 25
    +                // sharesToAssetsWithdraw:
    +                //  assets = assetsTotal * (shares / sharesTotal)
    +                //  assets = 90 * 25 / 900 = 90 * 0.02777... = 2.5
    +
    +                auto const start = env.balance(d.depositor, d.assets).number();
    +                d.peek([](SLE& vault, auto&) -> bool {
    +                    vault[sfAssetsAvailable] = Number(1);
    +                    return true;
    +                });
    +
    +                // Note, this transaction fails first (because of above change
    +                // in the open ledger) but then succeeds when the ledger is
    +                // closed (because a modification like above is not persistent),
    +                // which is why the checks below are expected to pass.
    +                auto tx = d.vault.withdraw(
    +                    {.depositor = d.depositor,
    +                     .id = d.keylet.key,
    +                     .amount = STAmount(d.asset, Number(25, -1))});
    +                env(tx, Ter{tecINSUFFICIENT_FUNDS});
    +                env.close();
    +                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(900 - 25));
    +                BEAST_EXPECT(
    +                    env.balance(d.depositor, d.assets) ==
    +                    STAmount(d.asset, start + Number(25, -1)));
    +                BEAST_EXPECT(
    +                    env.balance(d.vaultAccount, d.assets) ==
    +                    STAmount(d.asset, Number(900 - 25, -1)));
    +                BEAST_EXPECT(
    +                    env.balance(d.vaultAccount, d.shares) ==
    +                    STAmount(d.share, -Number(900 - 25, 0)));
    +            }
    +
    +            {
    +                testcase("Scale withdraw with rounding shares up");
    +                // assetsToSharesWithdraw:
    +                //  shares = sharesTotal * (assets / assetsTotal)
    +                //  shares = 875 * 3.75 / 87.5 = 875 * 0.042857... = 37.5
    +                // sharesToAssetsWithdraw:
    +                //  assets = assetsTotal * (shares / sharesTotal)
    +                //  assets = 87.5 * 38 / 875 = 87.5 * 0.043428... = 3.8
    +
    +                auto const start = env.balance(d.depositor, d.assets).number();
    +                auto tx = d.vault.withdraw(
    +                    {.depositor = d.depositor,
    +                     .id = d.keylet.key,
    +                     .amount = STAmount(d.asset, Number(375, -2))});
    +                env(tx);
    +                env.close();
    +                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(875 - 38));
    +                BEAST_EXPECT(
    +                    env.balance(d.depositor, d.assets) ==
    +                    STAmount(d.asset, start + Number(38, -1)));
    +                BEAST_EXPECT(
    +                    env.balance(d.vaultAccount, d.assets) ==
    +                    STAmount(d.asset, Number(875 - 38, -1)));
    +                BEAST_EXPECT(
    +                    env.balance(d.vaultAccount, d.shares) ==
    +                    STAmount(d.share, -Number(875 - 38, 0)));
    +            }
    +
    +            {
    +                testcase("Scale withdraw with rounding shares down");
    +                // assetsToSharesWithdraw:
    +                //  shares = sharesTotal * (assets / assetsTotal)
    +                //  shares = 837 * 3.72 / 83.7 = 837 * 0.04444... = 37.2
    +                // sharesToAssetsWithdraw:
    +                //  assets = assetsTotal * (shares / sharesTotal)
    +                //  assets = 83.7 * 37 / 837 = 83.7 * 0.044205... = 3.7
    +
    +                auto const start = env.balance(d.depositor, d.assets).number();
    +                auto tx = d.vault.withdraw(
    +                    {.depositor = d.depositor,
    +                     .id = d.keylet.key,
    +                     .amount = STAmount(d.asset, Number(372, -2))});
    +                env(tx);
    +                env.close();
    +                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(837 - 37));
    +                BEAST_EXPECT(
    +                    env.balance(d.depositor, d.assets) ==
    +                    STAmount(d.asset, start + Number(37, -1)));
    +                BEAST_EXPECT(
    +                    env.balance(d.vaultAccount, d.assets) ==
    +                    STAmount(d.asset, Number(837 - 37, -1)));
    +                BEAST_EXPECT(
    +                    env.balance(d.vaultAccount, d.shares) ==
    +                    STAmount(d.share, -Number(837 - 37, 0)));
    +            }
    +
    +            {
    +                testcase("Scale withdraw tiny amount");
    +
    +                auto const start = env.balance(d.depositor, d.assets).number();
    +                auto tx = d.vault.withdraw(
    +                    {.depositor = d.depositor,
    +                     .id = d.keylet.key,
    +                     .amount = STAmount(d.asset, Number(9, -2))});
    +                env(tx);
    +                env.close();
    +                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(800 - 1));
    +                BEAST_EXPECT(
    +                    env.balance(d.depositor, d.assets) == STAmount(d.asset, start + Number(1, -1)));
    +                BEAST_EXPECT(
    +                    env.balance(d.vaultAccount, d.assets) ==
    +                    STAmount(d.asset, Number(800 - 1, -1)));
    +                BEAST_EXPECT(
    +                    env.balance(d.vaultAccount, d.shares) ==
    +                    STAmount(d.share, -Number(800 - 1, 0)));
    +            }
    +
    +            {
    +                testcase("Scale withdraw rest");
    +                auto const rest = env.balance(d.vaultAccount, d.assets).number();
    +
    +                tx = d.vault.withdraw(
    +                    {.depositor = d.depositor,
    +                     .id = d.keylet.key,
    +                     .amount = STAmount(d.asset, rest)});
    +                env(tx);
    +                env.close();
    +                BEAST_EXPECT(env.balance(d.depositor, d.shares).number() == 0);
    +                BEAST_EXPECT(env.balance(d.vaultAccount, d.assets).number() == 0);
    +                BEAST_EXPECT(env.balance(d.vaultAccount, d.shares).number() == 0);
    +            }
    +        });
    +
    +        testCase(18, [&, this](Env& env, Data d) {
    +            testcase("Scale clawback overflow");
    +
    +            {
    +                auto tx = d.vault.deposit(
    +                    {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(5)});
    +                env(tx);
    +                env.close();
    +            }
    +
    +            {
    +                auto tx = d.vault.clawback(
    +                    {.issuer = d.issuer,
    +                     .id = d.keylet.key,
    +                     .holder = d.depositor,
    +                     .amount = STAmount(d.asset, Number(10, 0))});
    +                env(tx, Ter{tecPATH_DRY});
    +                env.close();
    +            }
    +        });
    +
    +        testCase(1, [&, this](Env& env, Data d) {
    +            // initial setup: deposit 100 IOU, receive 1000 shares
    +            auto const start = env.balance(d.depositor, d.assets).number();
    +            auto tx = d.vault.deposit(
    +                {.depositor = d.depositor,
    +                 .id = d.keylet.key,
    +                 .amount = STAmount(d.asset, Number(100, 0))});
    +            env(tx);
    +            env.close();
    +            BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(1000));
    +            BEAST_EXPECT(
    +                env.balance(d.depositor, d.assets) == STAmount(d.asset, start - Number(100, 0)));
    +            BEAST_EXPECT(
    +                env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(100, 0)));
    +            BEAST_EXPECT(
    +                env.balance(d.vaultAccount, d.shares) == STAmount(d.share, -Number(1000, 0)));
    +            {
    +                testcase("Scale clawback exact");
    +                // assetsToSharesWithdraw:
    +                //  shares = sharesTotal * (assets / assetsTotal)
    +                //  shares = 1000 * 10 / 100 = 1000 * 0.1 = 100
    +                // sharesToAssetsWithdraw:
    +                //  assets = assetsTotal * (shares / sharesTotal)
    +                //  assets = 100 * 100 / 1000 = 100 * 0.1 = 10
    +
    +                auto const start = env.balance(d.depositor, d.assets).number();
    +                auto tx = d.vault.clawback(
    +                    {.issuer = d.issuer,
    +                     .id = d.keylet.key,
    +                     .holder = d.depositor,
    +                     .amount = STAmount(d.asset, Number(10, 0))});
    +                env(tx);
    +                env.close();
    +                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(900));
    +                BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start));
    +                BEAST_EXPECT(
    +                    env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(90, 0)));
    +                BEAST_EXPECT(
    +                    env.balance(d.vaultAccount, d.shares) == STAmount(d.share, -Number(900, 0)));
    +            }
    +
    +            {
    +                testcase("Scale clawback insignificant amount");
    +                auto tx = d.vault.clawback(
    +                    {.issuer = d.issuer,
    +                     .id = d.keylet.key,
    +                     .holder = d.depositor,
    +                     .amount = STAmount(d.asset, Number(4, -2))});
    +                env(tx, Ter{tecPRECISION_LOSS});
    +            }
    +
    +            {
    +                testcase("Scale clawback with rounding assets");
    +                // assetsToSharesWithdraw:
    +                //  shares = sharesTotal * (assets / assetsTotal)
    +                //  shares = 900 * 2.5 / 90 = 900 * 0.02777... = 25
    +                // sharesToAssetsWithdraw:
    +                //  assets = assetsTotal * (shares / sharesTotal)
    +                //  assets = 90 * 25 / 900 = 90 * 0.02777... = 2.5
    +
    +                auto const start = env.balance(d.depositor, d.assets).number();
    +                auto tx = d.vault.clawback(
    +                    {.issuer = d.issuer,
    +                     .id = d.keylet.key,
    +                     .holder = d.depositor,
    +                     .amount = STAmount(d.asset, Number(25, -1))});
    +                env(tx);
    +                env.close();
    +                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(900 - 25));
    +                BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start));
    +                BEAST_EXPECT(
    +                    env.balance(d.vaultAccount, d.assets) ==
    +                    STAmount(d.asset, Number(900 - 25, -1)));
    +                BEAST_EXPECT(
    +                    env.balance(d.vaultAccount, d.shares) ==
    +                    STAmount(d.share, -Number(900 - 25, 0)));
    +            }
    +
    +            {
    +                testcase("Scale clawback with rounding shares up");
    +                // assetsToSharesWithdraw:
    +                //  shares = sharesTotal * (assets / assetsTotal)
    +                //  shares = 875 * 3.75 / 87.5 = 875 * 0.042857... = 37.5
    +                // sharesToAssetsWithdraw:
    +                //  assets = assetsTotal * (shares / sharesTotal)
    +                //  assets = 87.5 * 38 / 875 = 87.5 * 0.043428... = 3.8
    +
    +                auto const start = env.balance(d.depositor, d.assets).number();
    +                auto tx = d.vault.clawback(
    +                    {.issuer = d.issuer,
    +                     .id = d.keylet.key,
    +                     .holder = d.depositor,
    +                     .amount = STAmount(d.asset, Number(375, -2))});
    +                env(tx);
    +                env.close();
    +                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(875 - 38));
    +                BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start));
    +                BEAST_EXPECT(
    +                    env.balance(d.vaultAccount, d.assets) ==
    +                    STAmount(d.asset, Number(875 - 38, -1)));
    +                BEAST_EXPECT(
    +                    env.balance(d.vaultAccount, d.shares) ==
    +                    STAmount(d.share, -Number(875 - 38, 0)));
    +            }
    +
    +            {
    +                testcase("Scale clawback with rounding shares down");
    +                // assetsToSharesWithdraw:
    +                //  shares = sharesTotal * (assets / assetsTotal)
    +                //  shares = 837 * 3.72 / 83.7 = 837 * 0.04444... = 37.2
    +                // sharesToAssetsWithdraw:
    +                //  assets = assetsTotal * (shares / sharesTotal)
    +                //  assets = 83.7 * 37 / 837 = 83.7 * 0.044205... = 3.7
    +
    +                auto const start = env.balance(d.depositor, d.assets).number();
    +                auto tx = d.vault.clawback(
    +                    {.issuer = d.issuer,
    +                     .id = d.keylet.key,
    +                     .holder = d.depositor,
    +                     .amount = STAmount(d.asset, Number(372, -2))});
    +                env(tx);
    +                env.close();
    +                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(837 - 37));
    +                BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start));
    +                BEAST_EXPECT(
    +                    env.balance(d.vaultAccount, d.assets) ==
    +                    STAmount(d.asset, Number(837 - 37, -1)));
    +                BEAST_EXPECT(
    +                    env.balance(d.vaultAccount, d.shares) ==
    +                    STAmount(d.share, -Number(837 - 37, 0)));
    +            }
    +
    +            {
    +                testcase("Scale clawback tiny amount");
    +
    +                auto const start = env.balance(d.depositor, d.assets).number();
    +                auto tx = d.vault.clawback(
    +                    {.issuer = d.issuer,
    +                     .id = d.keylet.key,
    +                     .holder = d.depositor,
    +                     .amount = STAmount(d.asset, Number(9, -2))});
    +                env(tx);
    +                env.close();
    +                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(800 - 1));
    +                BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start));
    +                BEAST_EXPECT(
    +                    env.balance(d.vaultAccount, d.assets) ==
    +                    STAmount(d.asset, Number(800 - 1, -1)));
    +                BEAST_EXPECT(
    +                    env.balance(d.vaultAccount, d.shares) ==
    +                    STAmount(d.share, -Number(800 - 1, 0)));
    +            }
    +
    +            {
    +                testcase("Scale clawback rest");
    +                auto const rest = env.balance(d.vaultAccount, d.assets).number();
    +                d.peek([](SLE& vault, auto&) -> bool {
    +                    vault[sfAssetsAvailable] = Number(5);
    +                    return true;
    +                });
    +
    +                // Note, this transaction yields two different results:
    +                // * in the open ledger, with AssetsAvailable = 5
    +                // * when the ledger is closed with unmodified AssetsAvailable
    +                //   because a modification like above is not persistent.
    +                tx = d.vault.clawback(
    +                    {.issuer = d.issuer,
    +                     .id = d.keylet.key,
    +                     .holder = d.depositor,
    +                     .amount = STAmount(d.asset, rest)});
    +                env(tx);
    +                env.close();
    +                BEAST_EXPECT(env.balance(d.depositor, d.shares).number() == 0);
    +                BEAST_EXPECT(env.balance(d.vaultAccount, d.assets).number() == 0);
    +                BEAST_EXPECT(env.balance(d.vaultAccount, d.shares).number() == 0);
    +            }
    +        });
    +
    +        // Non-1:1 ratio (scale=1, 10:1 shares:assets) with an outstanding loan.
    +        // Deposit 100 IOU → 1000 shares. Borrow 40 → assetsAvailable=60.
    +        // Clawback 80 IOU → clamped to 60, then share math uses truncation.
    +        testCase(1, [&, this](Env& env, Data d) {
    +            using namespace loan_broker;
    +            using namespace loan;
    +
    +            testcase("Scale clawback clamped with outstanding loan");
    +
    +            auto tx = d.vault.deposit(
    +                {.depositor = d.depositor,
    +                 .id = d.keylet.key,
    +                 .amount = STAmount(d.asset, Number(100, 0))});
    +            env(tx);
    +            env.close();
    +            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(), SeqProxy::rawSequence(env.seq(d.owner)));
    +            env(set(d.owner, d.keylet.key));
    +            env.close();
    +
    +            // Borrow 40: assetsAvailable=60, assetsTotal=100
    +            env(set(d.depositor, brokerKeylet.key, STAmount(d.asset, Number(40, 0))),
    +                loan::kInterestRate(TenthBips32(0)),
    +                kGracePeriod(60),
    +                kPaymentInterval(120),
    +                kPaymentTotal(10),
    +                Sig(sfCounterpartySignature, d.owner),
    +                Fee(env.current()->fees().base * 2),
    +                Ter(tesSUCCESS));
    +            env.close();
    +
    +            {
    +                auto const sle = env.le(d.keylet);
    +                BEAST_EXPECT(sle->at(sfAssetsAvailable) == STAmount(d.asset, Number(60, 0)));
    +                BEAST_EXPECT(sle->at(sfAssetsTotal) == STAmount(d.asset, Number(100, 0)));
    +            }
    +
    +            // Request 80 IOU clawback — clamped to assetsAvailable (60)
    +            // With scale=1 (10:1), 60 assets = 600 shares destroyed
    +            tx = d.vault.clawback(
    +                {.issuer = d.issuer,
    +                 .id = d.keylet.key,
    +                 .holder = d.depositor,
    +                 .amount = STAmount(d.asset, Number(80, 0))});
    +            env(tx, Ter(tesSUCCESS));
    +            env.close();
    +
    +            {
    +                auto const sle = env.le(d.keylet);
    +                BEAST_EXPECT(sle != nullptr);
    +                BEAST_EXPECT(sle->at(sfAssetsAvailable) == STAmount(d.asset, Number(0, 0)));
    +                BEAST_EXPECT(sle->at(sfAssetsTotal) == STAmount(d.asset, Number(40, 0)));
    +
    +                // 600 of 1000 shares destroyed, 400 remain
    +                BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(400));
    +            }
    +        });
    +    }
    +
    +    void
    +    testAssetsMaximum()
    +    {
    +        testcase("Assets Maximum");
    +
    +        using namespace test::jtx;
    +
    +        Env env{*this, testableAmendments()};
    +        Account const owner{"owner"};
    +        Account const issuer{"issuer"};
    +
    +        Vault const vault{env};
    +        env.fund(XRP(1'000'000), issuer, owner);
    +        env.close();
    +
    +        auto const maxInt64 = std::to_string(std::numeric_limits::max());
    +        BEAST_EXPECT(maxInt64 == "9223372036854775807");
    +
    +        auto const maxInt64Plus1 = std::to_string(
    +            static_cast(std::numeric_limits::max()) + 1);
    +        BEAST_EXPECT(maxInt64Plus1 == "9223372036854775808");
    +
    +        // Naming things is hard
    +        auto const maxInt64Plus2 = std::to_string(
    +            static_cast(std::numeric_limits::max()) + 2);
    +        BEAST_EXPECT(maxInt64Plus2 == "9223372036854775809");
    +
    +        auto const initialXRP = to_string(kInitialXrp);
    +        BEAST_EXPECT(initialXRP == "100000000000000000");
    +
    +        auto const initialXRPPlus1 = to_string(kInitialXrp + 1);
    +        BEAST_EXPECT(initialXRPPlus1 == "100000000000000001");
    +
    +        {
    +            testcase("Assets Maximum: XRP");
    +
    +            PrettyAsset const xrpAsset = xrpIssue();
    +
    +            auto [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset});
    +            tx[sfData] = "4D65746144617461";
    +
    +            tx[sfAssetsMaximum] = maxInt64;
    +            env(tx, Ter(tefEXCEPTION));
    +            env.close();
    +
    +            tx[sfAssetsMaximum] = initialXRPPlus1;
    +            env(tx, Ter(tefEXCEPTION));
    +            env.close();
    +
    +            tx[sfAssetsMaximum] = initialXRP;
    +            env(tx);
    +            env.close();
    +
    +            // There are several parse failures expected in this function, so just disable it once.
    +            env.setParseFailureExpected(true);
    +            try
    +            {
    +                tx[sfAssetsMaximum] = maxInt64Plus1;
    +                env(tx, Ter(tefEXCEPTION));
    +                env.close();
    +                // should throw in parser
    +                fail();
    +            }
    +            catch (std::exception const& e)
    +            {
    +                BEAST_EXPECT(
    +                    std::string(e.what()) ==
    +                    "invalidParamsField 'tx_json.AssetsMaximum' has invalid data.");
    +            }
    +
    +            try
    +            {
    +                tx[sfAssetsMaximum] = maxInt64Plus2;
    +                env(tx, Ter(tefEXCEPTION));
    +                // should throw in parser
    +                fail();
    +            }
    +            catch (std::exception const& e)
    +            {
    +                BEAST_EXPECT(
    +                    std::string(e.what()) ==
    +                    "invalidParamsField 'tx_json.AssetsMaximum' has invalid data.");
    +            }
    +
    +            auto const newKeylet = keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
    +            try
    +            {
    +                auto const insertAt = maxInt64Plus2.size() - 3;
    +                auto const decimalTest = maxInt64Plus2.substr(0, insertAt) + "." +
    +                    maxInt64Plus2.substr(insertAt);  // (max int64+2) / 1000
    +                BEAST_EXPECT(decimalTest == "9223372036854775.809");
    +                tx[sfAssetsMaximum] = decimalTest;
    +                env(tx);
    +                // should throw in parser
    +                fail();
    +            }
    +            catch (std::exception const& e)
    +            {
    +                BEAST_EXPECT(
    +                    std::string(e.what()) ==
    +                    "invalidParamsField 'tx_json.AssetsMaximum' has invalid data.");
    +            }
    +
    +            auto const vaultSle = env.le(newKeylet);
    +            BEAST_EXPECT(!vaultSle);
    +        }
    +
    +        {
    +            testcase("Assets Maximum: MPT");
    +
    +            PrettyAsset const mptAsset = [&]() {
    +                MPTTester mptt{env, issuer, kMptInitNoFund};
    +                mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
    +                env.close();
    +                PrettyAsset const mptAsset = mptt["MPT"];
    +                mptt.authorize({.account = owner});
    +                env.close();
    +                return mptAsset;
    +            }();
    +
    +            env(pay(issuer, owner, mptAsset(100'000)));
    +            env.close();
    +
    +            auto [tx, keylet] = vault.create({.owner = owner, .asset = mptAsset});
    +            tx[sfData] = "4D65746144617461";
    +
    +            tx[sfAssetsMaximum] = maxInt64;
    +            env(tx);
    +            env.close();
    +
    +            tx[sfAssetsMaximum] = initialXRPPlus1;
    +            env(tx);
    +            env.close();
    +
    +            tx[sfAssetsMaximum] = initialXRP;
    +            env(tx);
    +            env.close();
    +
    +            try
    +            {
    +                tx[sfAssetsMaximum] = maxInt64Plus2;
    +                env(tx, Ter(tefEXCEPTION));
    +                // should throw in parser
    +                fail();
    +            }
    +            catch (std::exception const& e)
    +            {
    +                BEAST_EXPECT(
    +                    std::string(e.what()) ==
    +                    "invalidParamsField 'tx_json.AssetsMaximum' has invalid data.");
    +            }
    +
    +            auto const newKeylet = keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
    +            try
    +            {
    +                auto const insertAt = maxInt64Plus2.size() - 1;
    +                auto const decimalTest = maxInt64Plus2.substr(0, insertAt) + "." +
    +                    maxInt64Plus2.substr(insertAt);  // (max int64+2) / 10
    +                BEAST_EXPECT(decimalTest == "922337203685477580.9");
    +                tx[sfAssetsMaximum] = decimalTest;
    +                env(tx);
    +                // should throw in parser
    +                fail();
    +            }
    +            catch (std::exception const& e)
    +            {
    +                BEAST_EXPECT(
    +                    std::string(e.what()) ==
    +                    "invalidParamsField 'tx_json.AssetsMaximum' has invalid data.");
    +            }
    +
    +            auto const vaultSle = env.le(newKeylet);
    +            BEAST_EXPECT(!vaultSle);
    +        }
    +
    +        {
    +            testcase("Assets Maximum: IOU");
    +
    +            // Almost anything goes with IOUs
    +            PrettyAsset const iouAsset = issuer["IOU"];
    +            env.trust(iouAsset(1000), owner);
    +            env(pay(issuer, owner, iouAsset(200)));
    +            env.close();
    +
    +            auto [tx, keylet] = vault.create({.owner = owner, .asset = iouAsset});
    +            tx[sfData] = "4D65746144617461";
    +
    +            tx[sfAssetsMaximum] = maxInt64;
    +            env(tx);
    +            env.close();
    +
    +            tx[sfAssetsMaximum] = initialXRPPlus1;
    +            env(tx);
    +            env.close();
    +
    +            tx[sfAssetsMaximum] = initialXRP;
    +            env(tx);
    +            env.close();
    +
    +            // Since several tests are expected to have parser failures, leave this flag set for the
    +            // remainder of this function.
    +            env.setParseFailureExpected(true);
    +            try
    +            {
    +                tx[sfAssetsMaximum] = maxInt64Plus2;
    +                env(tx);
    +                // should throw in parser
    +                fail();
    +            }
    +            catch (std::exception const& e)
    +            {
    +                BEAST_EXPECT(
    +                    std::string(e.what()) ==
    +                    "invalidParamsField 'tx_json.AssetsMaximum' has invalid data.");
    +            }
    +
    +            tx[sfAssetsMaximum] = "1000000000000000e80";
    +            env.close();
    +
    +            tx[sfAssetsMaximum] = "1000000000000000e-96";
    +            env.close();
    +
    +            // These values will be rounded to 15 significant digits
    +            {
    +                auto const newKeylet =
    +                    keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
    +                try
    +                {
    +                    auto const insertAt = maxInt64Plus2.size() - 1;
    +                    auto const decimalTest = maxInt64Plus2.substr(0, insertAt) + "." +
    +                        maxInt64Plus2.substr(insertAt);  // (max int64+2) / 10
    +                    BEAST_EXPECT(decimalTest == "922337203685477580.9");
    +                    tx[sfAssetsMaximum] = decimalTest;
    +                    env(tx);
    +                    // should throw in parser
    +                    fail();
    +                }
    +                catch (std::exception const& e)
    +                {
    +                    BEAST_EXPECT(
    +                        std::string(e.what()) ==
    +                        "invalidParamsField 'tx_json.AssetsMaximum' has invalid data.");
    +                }
    +
    +                auto const vaultSle = env.le(newKeylet);
    +                BEAST_EXPECT(!vaultSle);
    +            }
    +            {
    +                tx[sfAssetsMaximum] = "9223372036854775807e40";  // max int64 * 10^40
    +                auto const newKeylet =
    +                    keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
    +                env(tx);
    +                env.close();
    +
    +                auto const vaultSle = env.le(newKeylet);
    +                if (!BEAST_EXPECT(vaultSle))
    +                    return;
    +
    +                BEAST_EXPECT(
    +                    (vaultSle->at(sfAssetsMaximum) ==
    +                     Number{9223372036854776, 43, Number::Normalized{}}));
    +            }
    +            {
    +                tx[sfAssetsMaximum] = "9223372036854775807e-40";  // max int64 * 10^-40
    +                auto const newKeylet =
    +                    keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
    +                env(tx);
    +                env.close();
    +
    +                auto const vaultSle = env.le(newKeylet);
    +                if (!BEAST_EXPECT(vaultSle))
    +                    return;
    +
    +                BEAST_EXPECT(
    +                    (vaultSle->at(sfAssetsMaximum) ==
    +                     Number{9223372036854776, -37, Number::Normalized{}}));
    +            }
    +            {
    +                tx[sfAssetsMaximum] = "9223372036854775807e-100";  // max int64 * 10^-100
    +                auto const newKeylet =
    +                    keylet::vault(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
    +                env(tx);
    +                env.close();
    +
    +                // Field 'AssetsMaximum' may not be explicitly set to default.
    +                auto const vaultSle = env.le(newKeylet);
    +                if (!BEAST_EXPECT(vaultSle))
    +                    return;
    +
    +                BEAST_EXPECT(vaultSle->at(sfAssetsMaximum) == kNumZero);
    +            }
    +
    +            // What _can't_ IOUs do?
    +            // 1. Exceed maximum exponent / offset
    +            tx[sfAssetsMaximum] = "1000000000000000e81";
    +            env(tx, Ter(tefEXCEPTION));
    +            env.close();
    +
    +            // 2. Mantissa larger than uint64 max
    +            try
    +            {
    +                auto const g = env.getParseFailureGuard(true);
    +                tx[sfAssetsMaximum] = "18446744073709551617e5";  // uint64 max + 1
    +                env(tx);
    +                BEAST_EXPECTS(false, "Expected parse_error for mantissa larger than uint64 max");
    +            }
    +            catch (ParseError const& e)
    +            {
    +                using namespace std::string_literals;
    +                BEAST_EXPECT(
    +                    e.what() == "invalidParamsField 'tx_json.AssetsMaximum' has invalid data."s);
    +            }
    +        }
    +    }
    +
    +public:
    +    void
    +    run() override
    +    {
    +        testScaleIOU();
    +        testAssetsMaximum();
    +    }
    +};
    +
    +BEAST_DEFINE_TESTSUITE_PRIO(VaultScale, app, xrpl, 1);
    +
    +}  // namespace xrpl
    diff --git a/src/test/app/vault/VaultShares_test.cpp b/src/test/app/vault/VaultShares_test.cpp
    new file mode 100644
    index 0000000000..037ee3e057
    --- /dev/null
    +++ b/src/test/app/vault/VaultShares_test.cpp
    @@ -0,0 +1,736 @@
    +#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 {
    +
    +class VaultShares_test : public VaultTestBase
    +{
    +private:
    +    void
    +    testNonTransferableShares()
    +    {
    +        using namespace test::jtx;
    +
    +        Env env{*this, testableAmendments()};
    +        Account const issuer{"issuer"};
    +        Account const owner{"owner"};
    +        Account const depositor{"depositor"};
    +        env.fund(XRP(1000), issuer, owner, depositor);
    +        env.close();
    +
    +        Vault const vault{env};
    +        PrettyAsset const asset = issuer["IOU"];
    +        env.trust(asset(1000), owner);
    +        env(pay(issuer, owner, asset(100)));
    +        env.trust(asset(1000), depositor);
    +        env(pay(issuer, depositor, asset(100)));
    +        env.close();
    +
    +        auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +        tx[sfFlags] = tfVaultShareNonTransferable;
    +        env(tx);
    +        env.close();
    +
    +        {
    +            testcase("nontransferable deposits");
    +            auto tx1 =
    +                vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(40)});
    +            env(tx1);
    +
    +            auto tx2 = vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(60)});
    +            env(tx2);
    +            env.close();
    +        }
    +
    +        auto const vaultAccount =  //
    +            [&env, key = keylet.key, this]() -> AccountID {
    +            auto jvVault = env.rpc("vault_info", strHex(key));
    +
    +            BEAST_EXPECT(jvVault[jss::result][jss::vault][sfAssetsTotal] == "100");
    +            BEAST_EXPECT(
    +                jvVault[jss::result][jss::vault][jss::shares][sfOutstandingAmount] == "100000000");
    +
    +            // Vault pseudo-account
    +            return parseBase58(jvVault[jss::result][jss::vault][jss::Account].asString())
    +                .value();
    +        }();
    +
    +        auto const mptId = makeMptID(1, vaultAccount);
    +        Asset const shares = mptId;
    +
    +        {
    +            testcase("nontransferable shares cannot be moved");
    +            env(pay(owner, depositor, shares(10)), Ter{tecNO_AUTH});
    +            env(pay(depositor, owner, shares(10)), Ter{tecNO_AUTH});
    +        }
    +
    +        {
    +            testcase("nontransferable shares can be used to withdraw");
    +            auto tx1 =
    +                vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(20)});
    +            env(tx1);
    +
    +            auto tx2 = vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(30)});
    +            env(tx2);
    +            env.close();
    +        }
    +
    +        {
    +            testcase("nontransferable shares balance check");
    +            auto jvVault = env.rpc("vault_info", strHex(keylet.key));
    +            BEAST_EXPECT(jvVault[jss::result][jss::vault][sfAssetsTotal] == "50");
    +            BEAST_EXPECT(
    +                jvVault[jss::result][jss::vault][jss::shares][sfOutstandingAmount] == "50000000");
    +        }
    +
    +        {
    +            testcase("nontransferable shares withdraw rest");
    +            auto tx1 =
    +                vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(20)});
    +            env(tx1);
    +
    +            auto tx2 = vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(30)});
    +            env(tx2);
    +            env.close();
    +        }
    +
    +        {
    +            testcase("nontransferable shares delete empty vault");
    +            auto tx = vault.del({.owner = owner, .id = keylet.key});
    +            env(tx);
    +            BEAST_EXPECT(!env.le(keylet));
    +        }
    +    }
    +
    +    void
    +    testFailedPseudoAccount()
    +    {
    +        using namespace test::jtx;
    +
    +        testcase("fail pseudo-account allocation");
    +        Env env{*this, testableAmendments()};
    +        Account const owner{"owner"};
    +        Vault const vault{env};
    +        env.fund(XRP(1000), 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);
    +
    +            env(pay(env.master.id(), accountId, XRP(1000)),
    +                Seq(kAutofill),
    +                Fee(kAutofill),
    +                Sig(kAutofill));
    +        }
    +
    +        auto [tx, keylet1] = vault.create({.owner = owner, .asset = xrpIssue()});
    +        BEAST_EXPECT(keylet.key == keylet1.key);
    +        env(tx, Ter{terADDRESS_COLLISION});
    +    }
    +
    +    void
    +    testRemoveEmptyHoldingLockedAmount()
    +    {
    +        testcase("removeEmptyHolding deletes MPToken with sfLockedAmount");
    +        using namespace test::jtx;
    +        using namespace std::literals;
    +
    +        auto const amendments = testableAmendments();
    +        auto runTest = [&](FeatureBitset f) {
    +            Env env{*this, f};
    +            auto const baseFee = env.current()->fees().base;
    +
    +            Account const issuer{"issuer"};
    +            Account const owner{"owner"};
    +            Account const depositor{"depositor"};
    +            Account const bob{"bob"};
    +
    +            env.fund(XRP(100000), issuer, owner, depositor, bob);
    +            env.close();
    +
    +            Vault const vault{env};
    +
    +            // Create an MPT asset for the vault
    +            MPTTester mptt{env, issuer, kMptInitNoFund};
    +            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
    +            PrettyAsset const asset = mptt.issuanceID();
    +            mptt.authorize({.account = owner});
    +            mptt.authorize({.account = depositor});
    +            env(pay(issuer, depositor, asset(1000)));
    +            env.close();
    +
    +            // Create vault
    +            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +            env(tx);
    +            env.close();
    +
    +            auto const vaultSle = env.le(keylet);
    +            BEAST_EXPECT(vaultSle != nullptr);
    +            auto const shareMptID = vaultSle->at(sfShareMPTID);
    +            MPTIssue const shareIssue{shareMptID};
    +
    +            // Depositor deposits 1000 asset units into vault, receiving shares
    +            env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1000)}));
    +            env.close();
    +
    +            // Check depositor has shares
    +            {
    +                auto const sleMpt = env.le(keylet::mptoken(shareMptID, depositor));
    +                BEAST_EXPECT(sleMpt != nullptr);
    +                BEAST_EXPECT(sleMpt->at(sfMPTAmount) == 1000);
    +            }
    +
    +            // Escrow 500 of those shares
    +            env(escrow::create(depositor, bob, STAmount{shareIssue, 500}),
    +                escrow::kCondition(escrow::kCb1),
    +                escrow::kFinishTime(env.now() + 1s),
    +                Fee(baseFee * 150),
    +                Ter(tesSUCCESS));
    +            env.close();
    +
    +            // Verify: sfMPTAmount=500, sfLockedAmount=500
    +            {
    +                auto const sleMpt = env.le(keylet::mptoken(shareMptID, depositor));
    +                BEAST_EXPECT(sleMpt != nullptr);
    +                BEAST_EXPECT(sleMpt->at(sfLockedAmount) == 500);
    +                BEAST_EXPECT(sleMpt->at(sfMPTAmount) == 500);
    +            }
    +
    +            // Withdraw remaining spendable shares — triggers removeEmptyHolding
    +            env(vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(500)}),
    +                Ter(tesSUCCESS));
    +            env.close();
    +
    +            auto const sleMptAfter = env.le(keylet::mptoken(shareMptID, depositor));
    +            if (!f[fixCleanup3_1_3])
    +            {
    +                // Without the fix, removeEmptyHolding deletes the MPToken
    +                // even though sfLockedAmount > 0, leaving the escrow's locked
    +                // amount untracked.
    +                BEAST_EXPECT(sleMptAfter == nullptr);
    +            }
    +            else
    +            {
    +                // With the fix, MPToken must still exist with sfLockedAmount > 0
    +                // and sfMPTAmount == 0 (all spendable shares withdrawn).
    +                BEAST_EXPECT(sleMptAfter != nullptr);
    +                if (sleMptAfter)
    +                {
    +                    BEAST_EXPECT(sleMptAfter->at(sfLockedAmount) == 500);
    +                    BEAST_EXPECT(sleMptAfter->at(sfMPTAmount) == 0);
    +                }
    +            }
    +        };
    +
    +        runTest(amendments - fixCleanup3_1_3);
    +        runTest(amendments);
    +    }
    +
    +    void
    +    testRemoveEmptyHoldingConfidentialBalances()
    +    {
    +        testcase("removeEmptyHolding keeps MPToken with confidential balances");
    +        using namespace test::jtx;
    +
    +        Env env{*this, testableAmendments()};
    +
    +        Account const issuer{"issuer"};
    +        Account const holder{"holder"};
    +        MPTTester mpt{env, issuer, {.holders = {holder}}};
    +        mpt.create({.authorize = MPTCreate::allHolders});
    +
    +        auto const tokenKeylet = keylet::mptoken(mpt.issuanceID(), holder.id());
    +        auto const encryptedBalanceFields = {
    +            &sfConfidentialBalanceInbox,
    +            &sfConfidentialBalanceSpending,
    +            &sfIssuerEncryptedBalance,
    +            &sfAuditorEncryptedBalance};
    +
    +        env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal j) {
    +            for (auto const field : encryptedBalanceFields)
    +            {
    +                Sandbox sb(&view, TapNone);
    +                auto const token = sb.peek(tokenKeylet);
    +                if (!BEAST_EXPECT(token))
    +                    return false;
    +
    +                token->setFieldVL(*field, gMakeZeroBuffer(kEcGamalEncryptedTotalLength));
    +                sb.update(token);
    +
    +                auto const dummyTx = *env.jt(noop(holder)).stx;
    +                BEAST_EXPECT(
    +                    removeEmptyHolding({sb, dummyTx}, holder.id(), MPTIssue(mpt.issuanceID()), j) ==
    +                    tecHAS_OBLIGATIONS);
    +                BEAST_EXPECT(sb.peek(tokenKeylet) != nullptr);
    +            }
    +            return true;
    +        });
    +    }
    +
    +    void
    +    testReferenceHolding()
    +    {
    +        using namespace test::jtx;
    +
    +        auto readReferenceHolding = [&](Env const& env,
    +                                        Keylet const& vaultKeylet) -> std::optional {
    +            auto const sleVault = env.le(vaultKeylet);
    +            if (!sleVault)
    +                return std::nullopt;
    +            auto const sleIssuance = env.le(keylet::mptokenIssuance(sleVault->at(sfShareMPTID)));
    +            if (!sleIssuance || !sleIssuance->isFieldPresent(sfReferenceHolding))
    +                return std::nullopt;
    +            return sleIssuance->getFieldH256(sfReferenceHolding);
    +        };
    +
    +        // Post-fixCleanup3_2_0: vault share carries sfReferenceHolding
    +        // pointing to the vault pseudo's MPToken (for MPT-backed vaults)
    +        // or RippleState (for IOU-backed vaults).
    +        {
    +            testcase("sfReferenceHolding: MPT-backed vault, post-amendment");
    +            Env env{*this, testableAmendments()};
    +            Account const issuer{"issuer"};
    +            Account const owner{"owner"};
    +            env.fund(XRP(10'000), issuer, owner);
    +            env.close();
    +
    +            MPTTester mptt{env, issuer, kMptInitNoFund};
    +            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
    +            PrettyAsset const asset = mptt.issuanceID();
    +            mptt.authorize({.account = owner});
    +
    +            Vault const vault{env};
    +            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +            env(tx);
    +            env.close();
    +
    +            auto const sleVault = env.le(keylet);
    +            BEAST_EXPECT(sleVault != nullptr);
    +            auto const pseudoId = sleVault->at(sfAccount);
    +            auto const expected = keylet::mptoken(mptt.issuanceID(), pseudoId).key;
    +
    +            auto const stored = readReferenceHolding(env, keylet);
    +            BEAST_EXPECT(stored.has_value());
    +            BEAST_EXPECT(stored && *stored == expected);
    +            // The pointed-to MPToken must actually exist.
    +            BEAST_EXPECT(env.le(keylet::mptoken(mptt.issuanceID(), pseudoId)) != nullptr);
    +        }
    +
    +        {
    +            testcase("sfReferenceHolding: IOU-backed vault, post-amendment");
    +            Env env{*this, testableAmendments()};
    +            Account const issuer{"issuer"};
    +            Account const owner{"owner"};
    +            env.fund(XRP(10'000), issuer, owner);
    +            env(fset(issuer, asfDefaultRipple));
    +            env.close();
    +
    +            PrettyAsset const asset = issuer["IOU"];
    +            env.trust(asset(1'000'000), owner);
    +            env.close();
    +
    +            Vault const vault{env};
    +            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +            env(tx);
    +            env.close();
    +
    +            auto const sleVault = env.le(keylet);
    +            BEAST_EXPECT(sleVault != nullptr);
    +            auto const pseudoId = sleVault->at(sfAccount);
    +            auto const expected = keylet::trustLine(pseudoId, asset.raw().get()).key;
    +
    +            auto const stored = readReferenceHolding(env, keylet);
    +            BEAST_EXPECT(stored.has_value());
    +            BEAST_EXPECT(stored && *stored == expected);
    +            // The pointed-to RippleState must actually exist.
    +            BEAST_EXPECT(env.le(keylet::trustLine(pseudoId, asset.raw().get())) != nullptr);
    +        }
    +
    +        // XRP-backed vaults leave the field absent: XRP has no separate
    +        // holding ledger entry and no transferability concept to inherit.
    +        {
    +            testcase("sfReferenceHolding: XRP-backed vault, field absent");
    +            Env env{*this, testableAmendments()};
    +            Account const owner{"owner"};
    +            env.fund(XRP(10'000), owner);
    +            env.close();
    +
    +            PrettyAsset const asset{xrpIssue(), 1'000'000};
    +            Vault const vault{env};
    +            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +            env(tx);
    +            env.close();
    +
    +            BEAST_EXPECT(!readReferenceHolding(env, keylet).has_value());
    +        }
    +
    +        // Pre-fixCleanup3_2_0: vault share has the field absent regardless
    +        // of underlying type.
    +        {
    +            testcase("sfReferenceHolding: vault share, pre-amendment");
    +            Env env{*this, testableAmendments() - fixCleanup3_2_0};
    +            Account const issuer{"issuer"};
    +            Account const owner{"owner"};
    +            env.fund(XRP(10'000), issuer, owner);
    +            env.close();
    +
    +            MPTTester mptt{env, issuer, kMptInitNoFund};
    +            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
    +            PrettyAsset const asset = mptt.issuanceID();
    +            mptt.authorize({.account = owner});
    +
    +            Vault const vault{env};
    +            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +            env(tx);
    +            env.close();
    +
    +            BEAST_EXPECT(!readReferenceHolding(env, keylet).has_value());
    +        }
    +
    +        // Plain MPTokenIssuanceCreate (not a vault share) must never
    +        // populate the field. Only the post-amendment case is
    +        // interesting; pre-amendment nothing writes the field at all.
    +        {
    +            testcase("sfReferenceHolding: plain MPT issuance never set");
    +            Env env{*this, testableAmendments()};
    +            Account const issuer{"issuer"};
    +            env.fund(XRP(10'000), issuer);
    +            env.close();
    +
    +            MPTTester mptt{env, issuer, kMptInitNoFund};
    +            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
    +            env.close();
    +
    +            auto const sleIssuance = env.le(keylet::mptokenIssuance(mptt.issuanceID()));
    +            if (BEAST_EXPECT(sleIssuance))
    +                BEAST_EXPECT(!sleIssuance->isFieldPresent(sfReferenceHolding));
    +        }
    +    }
    +
    +    // Probe every transactor surface that might delete the vault pseudo-
    +    // account's underlying holding (the MPToken or RippleState pointed to
    +    // by sfReferenceHolding). Each scenario asserts either that the
    +    // existing pseudo-account guards stop the deletion at preclaim, or
    +    // that the ledger leaves the holding intact afterwards. This is a
    +    // regression guard: if any of these guards regresses, the share's
    +    // sfReferenceHolding pointer would dangle and the new ValidMPTIssuance
    +    // invariant would catch it - but we want to fail much earlier, at
    +    // the transactor's preclaim / doApply, not at invariant time.
    +    void
    +    testHoldingDeletionBlocked()
    +    {
    +        using namespace test::jtx;
    +
    +        // Helper: read the share's referenced holding and confirm the
    +        // pointed-to SLE still exists after the probe.
    +        auto referencedHoldingExists = [&](Env const& env, Keylet const& vaultKeylet) -> bool {
    +            auto const sleVault = env.le(vaultKeylet);
    +            if (!sleVault)
    +                return false;
    +            auto const sleIssuance = env.le(keylet::mptokenIssuance(sleVault->at(sfShareMPTID)));
    +            if (!sleIssuance || !sleIssuance->isFieldPresent(sfReferenceHolding))
    +                return false;
    +            auto const holdingKey = sleIssuance->getFieldH256(sfReferenceHolding);
    +            return env.le(keylet::unchecked(holdingKey)) != nullptr;
    +        };
    +
    +        // ---- MPT-backed vault ----------------------------------------
    +        {
    +            testcase("vault pseudo MPToken: Clawback blocked by tecPSEUDO_ACCOUNT");
    +            Env env{*this, testableAmendments()};
    +            Account const issuer{"issuer"};
    +            Account const owner{"owner"};
    +            Account const depositor{"depositor"};
    +            env.fund(XRP(10'000), issuer, owner, depositor);
    +            env.close();
    +
    +            MPTTester mptt{env, issuer, kMptInitNoFund};
    +            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTCanClawback});
    +            PrettyAsset const asset = mptt.issuanceID();
    +            mptt.authorize({.account = owner});
    +            mptt.authorize({.account = depositor});
    +            env(pay(issuer, depositor, asset(1'000)));
    +            env.close();
    +
    +            Vault const vault{env};
    +            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +            env(tx);
    +            env.close();
    +
    +            env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(500)}));
    +            env.close();
    +
    +            BEAST_EXPECT(referencedHoldingExists(env, keylet));
    +
    +            Account const pseudoAccount{"vault-pseudo", env.le(keylet)->at(sfAccount)};
    +            // Issuer attempts to claw back the FULL underlying balance
    +            // (500) directly from the vault pseudo-account. With the
    +            // full amount, the doApply path would drain the pseudo's
    +            // MPToken to zero and removeEmptyHolding would erase it -
    +            // if doApply ever ran. SAV's pseudo-account guard at
    +            // Clawback.cpp:201 refuses at preclaim with
    +            // tecPSEUDO_ACCOUNT before any state change.
    +            env(claw(issuer, asset(500), pseudoAccount), Ter{tecPSEUDO_ACCOUNT});
    +            env.close();
    +            BEAST_EXPECT(referencedHoldingExists(env, keylet));
    +            // Sanity: pseudo's full balance is intact.
    +            BEAST_EXPECT(env.balance(pseudoAccount, asset).number() == 500);
    +        }
    +
    +        {
    +            testcase("vault pseudo MPToken: Issuer cannot Unauthorize pseudo");
    +            Env env{*this, testableAmendments()};
    +            Account const issuer{"issuer"};
    +            Account const owner{"owner"};
    +            env.fund(XRP(10'000), issuer, owner);
    +            env.close();
    +
    +            MPTTester mptt{env, issuer, kMptInitNoFund};
    +            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock | tfMPTRequireAuth});
    +            PrettyAsset const asset = mptt.issuanceID();
    +            mptt.authorize({.account = owner});
    +            mptt.authorize({.account = issuer, .holder = owner});
    +            env.close();
    +
    +            Vault const vault{env};
    +            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +            env(tx);
    +            env.close();
    +
    +            BEAST_EXPECT(referencedHoldingExists(env, keylet));
    +
    +            auto const pseudoId = env.le(keylet)->at(sfAccount);
    +            // Issuer attempts MPTokenAuthorize against the pseudo with
    +            // tfMPTUnauthorize. MPTokenAuthorize.cpp blocks pseudo
    +            // accounts via isPseudoAccount; the pseudo's MPToken is
    +            // preserved. Construct the tx manually since the pseudo
    +            // lacks a signing key, and the issuer-driven flavour is
    +            // expressed via sfHolder.
    +            json::Value jv;
    +            jv[sfAccount] = issuer.human();
    +            jv[sfHolder] = toBase58(pseudoId);
    +            jv[sfMPTokenIssuanceID] = to_string(mptt.issuanceID());
    +            jv[sfFlags] = tfMPTUnauthorize;
    +            jv[sfTransactionType] = jss::MPTokenAuthorize;
    +            env(jv, Ter{tecNO_PERMISSION});
    +            env.close();
    +            BEAST_EXPECT(referencedHoldingExists(env, keylet));
    +        }
    +
    +        {
    +            testcase("vault pseudo MPToken: MPTokenIssuanceDestroy blocked while vault holds");
    +            Env env{*this, testableAmendments()};
    +            Account const issuer{"issuer"};
    +            Account const owner{"owner"};
    +            Account const depositor{"depositor"};
    +            env.fund(XRP(10'000), issuer, owner, depositor);
    +            env.close();
    +
    +            MPTTester mptt{env, issuer, kMptInitNoFund};
    +            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
    +            PrettyAsset const asset = mptt.issuanceID();
    +            mptt.authorize({.account = owner});
    +            mptt.authorize({.account = depositor});
    +            env(pay(issuer, depositor, asset(1'000)));
    +            env.close();
    +
    +            Vault const vault{env};
    +            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +            env(tx);
    +            env.close();
    +
    +            env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(500)}));
    +            env.close();
    +
    +            BEAST_EXPECT(referencedHoldingExists(env, keylet));
    +
    +            // While the vault holds outstanding underlying, the issuer
    +            // cannot destroy the issuance. tecHAS_OBLIGATIONS confirms
    +            // the protection - and as a side effect, the share's
    +            // sfReferenceHolding pointer cannot be left pointing at a
    +            // ghost issuance.
    +            mptt.destroy({.id = mptt.issuanceID(), .err = tecHAS_OBLIGATIONS});
    +            env.close();
    +            BEAST_EXPECT(referencedHoldingExists(env, keylet));
    +        }
    +
    +        // ---- IOU-backed vault ----------------------------------------
    +        {
    +            testcase("vault pseudo trust line: Clawback blocked by tecPSEUDO_ACCOUNT");
    +            Env env{*this, testableAmendments()};
    +            Account const issuer{"issuer"};
    +            Account const owner{"owner"};
    +            env.fund(XRP(10'000), issuer, owner);
    +            env(fset(issuer, asfAllowTrustLineClawback));
    +            env.close();
    +
    +            PrettyAsset const asset = issuer["IOU"];
    +            env.trust(asset(1'000'000), owner);
    +            env(pay(issuer, owner, asset(1'000)));
    +            env.close();
    +
    +            Vault const vault{env};
    +            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +            env(tx);
    +            env.close();
    +
    +            env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(500)}));
    +            env.close();
    +
    +            BEAST_EXPECT(referencedHoldingExists(env, keylet));
    +
    +            Account const pseudoAccount{"vault-pseudo", env.le(keylet)->at(sfAccount)};
    +            // Issuer attempts to claw back the FULL IOU balance (500)
    +            // directly from the vault pseudo. With the full amount, the
    +            // doApply path would drain the trust line to zero and (if
    +            // both reserve flags clear) trustDelete would erase it - if
    +            // doApply ever ran. The same SAV pseudo-account guard
    +            // refuses at preclaim with tecPSEUDO_ACCOUNT. The amount's
    +            // STAmount issuer field is the holder, per IOU clawback
    +            // convention.
    +            env(claw(issuer, pseudoAccount["IOU"](500)), Ter{tecPSEUDO_ACCOUNT});
    +            env.close();
    +            BEAST_EXPECT(referencedHoldingExists(env, keylet));
    +            // Sanity: pseudo's full balance is intact.
    +            BEAST_EXPECT(env.balance(pseudoAccount, asset).number() == 500);
    +        }
    +
    +        {
    +            testcase("vault pseudo trust line: TrustSet limit=0 from issuer preserves line");
    +            Env env{*this, testableAmendments()};
    +            Account const issuer{"issuer"};
    +            Account const owner{"owner"};
    +            env.fund(XRP(10'000), issuer, owner);
    +            env(fset(issuer, asfDefaultRipple));
    +            env.close();
    +
    +            PrettyAsset const asset = issuer["IOU"];
    +            env.trust(asset(1'000'000), owner);
    +            env(pay(issuer, owner, asset(1'000)));
    +            env.close();
    +
    +            Vault const vault{env};
    +            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +            env(tx);
    +            env.close();
    +
    +            env(vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(500)}));
    +            env.close();
    +
    +            BEAST_EXPECT(referencedHoldingExists(env, keylet));
    +
    +            // Issuer submits TrustSet with limit=0 against the vault
    +            // pseudo. The pseudo's side of the line still has the
    +            // original (non-zero) limit and a non-zero balance, so the
    +            // line is preserved - even though the issuer cleared its
    +            // own side. trustDelete only fires when both limits clear
    +            // and the balance is zero.
    +            Account const pseudoAccount{"vault-pseudo", env.le(keylet)->at(sfAccount)};
    +            env(trust(issuer, pseudoAccount["IOU"](0)));
    +            env.close();
    +            BEAST_EXPECT(referencedHoldingExists(env, keylet));
    +        }
    +
    +        // ---- Positive control: VaultDelete is the only legitimate path
    +        {
    +            testcase("vault pseudo holding: VaultDelete is the legitimate cleanup path");
    +            Env env{*this, testableAmendments()};
    +            Account const issuer{"issuer"};
    +            Account const owner{"owner"};
    +            env.fund(XRP(10'000), issuer, owner);
    +            env.close();
    +
    +            MPTTester mptt{env, issuer, kMptInitNoFund};
    +            mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock});
    +            PrettyAsset const asset = mptt.issuanceID();
    +            mptt.authorize({.account = owner});
    +
    +            Vault const vault{env};
    +            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +            env(tx);
    +            env.close();
    +
    +            BEAST_EXPECT(referencedHoldingExists(env, keylet));
    +            auto const pseudoId = env.le(keylet)->at(sfAccount);
    +            auto const sharedMptId = env.le(keylet)->at(sfShareMPTID);
    +            auto const holdingKeylet = keylet::mptoken(mptt.issuanceID(), pseudoId);
    +
    +            // VaultDelete tears down the vault pseudo's holding, the
    +            // share issuance, and the pseudo-account itself. Invariant
    +            // permits this because the tx is ttVAULT_DELETE.
    +            env(vault.del({.owner = owner, .id = keylet.key}));
    +            env.close();
    +
    +            BEAST_EXPECT(env.le(keylet) == nullptr);
    +            BEAST_EXPECT(env.le(holdingKeylet) == nullptr);
    +            BEAST_EXPECT(env.le(keylet::mptokenIssuance(sharedMptId)) == nullptr);
    +        }
    +    }
    +
    +public:
    +    void
    +    run() override
    +    {
    +        testNonTransferableShares();
    +        testFailedPseudoAccount();
    +        testRemoveEmptyHoldingLockedAmount();
    +        testRemoveEmptyHoldingConfidentialBalances();
    +        testReferenceHolding();
    +        testHoldingDeletionBlocked();
    +    }
    +};
    +
    +BEAST_DEFINE_TESTSUITE(VaultShares, app, xrpl);
    +
    +}  // namespace xrpl
    diff --git a/src/test/app/vault/VaultSoleShareholder_test.cpp b/src/test/app/vault/VaultSoleShareholder_test.cpp
    new file mode 100644
    index 0000000000..ffaad07112
    --- /dev/null
    +++ b/src/test/app/vault/VaultSoleShareholder_test.cpp
    @@ -0,0 +1,655 @@
    +#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 {
    +
    +class VaultSoleShareholder_test : public VaultTestBase
    +{
    +private:
    +    // design doc:
    +    //     AssetsAvailable ≈ 3,333.50
    +    //     AssetsTotal     ≈ 6,666.50  (3,333.50 cash + 3,333 receivable)
    +    //     LossUnrealized  =  3,333
    +    //     OutstandingShares = sharesLender   (5e9 at IOU scale 1e6)
    +    struct StuckDepositorFixture
    +    {
    +        test::jtx::Account issuer{"issuer"};
    +        test::jtx::Account lender{"lender"};
    +        test::jtx::Account bob{"bob"};
    +        test::jtx::Account borrower{"borrower"};
    +        std::optional asset;
    +        std::optional vaultKeylet;
    +        uint256 brokerID;
    +        std::optional loanKeylet;
    +        MPTID shareAsset;
    +        std::uint64_t sharesLender = 0;
    +    };
    +
    +    static constexpr std::int64_t kStuckFunding = 1'000'000;
    +    static constexpr std::int64_t kStuckDepositorIOU = 1'000'000;
    +    static constexpr std::int64_t kStuckBorrowerIOU = 100'000;
    +    static constexpr std::int64_t kStuckDeposit = 5'000;
    +    static constexpr std::int64_t kStuckPrincipal = 3'333;
    +    static constexpr std::uint32_t kStuckPayInterval = 600;
    +    static constexpr std::uint32_t kStuckPayTotal = 2;
    +
    +    [[nodiscard]] StuckDepositorFixture
    +    setupStuckDepositor(test::jtx::Env& env)
    +    {
    +        using namespace test::jtx;
    +
    +        StuckDepositorFixture f;
    +        f.asset = f.issuer[iouCurrency_];
    +
    +        env.fund(XRP(kStuckFunding), f.issuer, f.lender, f.bob, f.borrower);
    +        env.close();
    +
    +        env(trust(f.lender, (*f.asset)(10'000'000)));
    +        env(trust(f.bob, (*f.asset)(10'000'000)));
    +        env(trust(f.borrower, (*f.asset)(10'000'000)));
    +        env.close();
    +
    +        env(pay(f.issuer, f.lender, (*f.asset)(kStuckDepositorIOU)));
    +        env(pay(f.issuer, f.bob, (*f.asset)(kStuckDepositorIOU)));
    +        env(pay(f.issuer, f.borrower, (*f.asset)(kStuckBorrowerIOU)));
    +        env.close();
    +
    +        // Vault: Lender creates and seeds it; Bob matches the deposit for a
    +        // clean 50/50 split.
    +        Vault const v{env};
    +        auto [createTx, vaultKeylet] = v.create({.owner = f.lender, .asset = *f.asset});
    +        env(createTx);
    +        env.close();
    +        if (!BEAST_EXPECT(env.le(vaultKeylet)))
    +            return f;
    +        f.vaultKeylet = vaultKeylet;
    +
    +        env(v.deposit({
    +                .depositor = f.lender,
    +                .id = vaultKeylet.key,
    +                .amount = (*f.asset)(kStuckDeposit),
    +            }),
    +            Ter(tesSUCCESS));
    +        env(v.deposit({
    +                .depositor = f.bob,
    +                .id = vaultKeylet.key,
    +                .amount = (*f.asset)(kStuckDeposit),
    +            }),
    +            Ter(tesSUCCESS));
    +        env.close();
    +
    +        // Loan broker: no cover, no management fee, debt cap 10x principal.
    +        f.brokerID =
    +            keylet::loanBroker(f.lender.id(), SeqProxy::rawSequence(env.seq(f.lender))).key;
    +        {
    +            using namespace loan_broker;
    +            env(set(f.lender, vaultKeylet.key),
    +                kDebtMaximum((*f.asset)(kStuckPrincipal * 10).value()));
    +            env.close();
    +        }
    +
    +        // Loan: 3,333 USD principal, impaired immediately.
    +        auto const sleBroker = env.le(keylet::loanBroker(f.brokerID));
    +        if (!BEAST_EXPECT(sleBroker))
    +            return f;
    +        f.loanKeylet =
    +            keylet::loan(f.brokerID, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence)));
    +
    +        {
    +            using namespace loan;
    +            env(set(f.borrower, f.brokerID, kStuckPrincipal),
    +                Sig(sfCounterpartySignature, f.lender),
    +                kPaymentTotal(kStuckPayTotal),
    +                kPaymentInterval(kStuckPayInterval),
    +                Fee(env.current()->fees().base * 2),
    +                Ter(tesSUCCESS));
    +            env.close();
    +            env(manage(f.lender, f.loanKeylet->key, tfLoanImpair), Ter(tesSUCCESS));
    +            env.close();
    +        }
    +
    +        auto const vaultSle = env.le(vaultKeylet);
    +        if (!BEAST_EXPECT(vaultSle))
    +            return f;
    +        BEAST_EXPECT(vaultSle->at(sfLossUnrealized) == (*f.asset)(kStuckPrincipal).value());
    +
    +        f.shareAsset = vaultSle->at(sfShareMPTID);
    +
    +        auto const tokenBob = env.le(keylet::mptoken(f.shareAsset, f.bob.id()));
    +        if (!BEAST_EXPECT(tokenBob))
    +            return f;
    +        std::uint64_t const sharesBob = tokenBob->getFieldU64(sfMPTAmount);
    +
    +        // Bob (non-sole) exits at the discounted rate. Always succeeds.
    +        STAmount const bobShareAmt{MPTIssue{f.shareAsset}, Number(sharesBob)};
    +        env(v.withdraw({
    +                .depositor = f.bob,
    +                .id = vaultKeylet.key,
    +                .amount = bobShareAmt,
    +            }),
    +            Ter(tesSUCCESS));
    +        env.close();
    +
    +        auto const tokenLender = env.le(keylet::mptoken(f.shareAsset, f.lender.id()));
    +        if (!BEAST_EXPECT(tokenLender))
    +            return f;
    +        f.sharesLender = tokenLender->getFieldU64(sfMPTAmount);
    +
    +        auto const sleIssuance = env.le(keylet::mptokenIssuance(f.shareAsset));
    +        if (!BEAST_EXPECT(sleIssuance))
    +            return f;
    +        BEAST_EXPECT(sleIssuance->getFieldU64(sfOutstandingAmount) == f.sharesLender);
    +
    +        auto const vaultAfterBob = env.le(vaultKeylet);
    +        if (!BEAST_EXPECT(vaultAfterBob))
    +            return f;
    +        // After Bob's exit: loss is unchanged (3,333 receivable), and the
    +        // gap between assetsTotal and assetsAvailable equals exactly that
    +        // receivable.
    +        BEAST_EXPECT(vaultAfterBob->at(sfLossUnrealized) == (*f.asset)(kStuckPrincipal).value());
    +        BEAST_EXPECT(
    +            vaultAfterBob->at(sfAssetsTotal) - vaultAfterBob->at(sfAssetsAvailable) ==
    +            vaultAfterBob->at(sfLossUnrealized));
    +
    +        return f;
    +    }
    +
    +    // Reproduces the worked example from the XLS-0065 design doc. The sole
    +    // remaining shareholder asks (via fixed-asset input) for the vault's
    +    // entire AssetsAvailable. Pre-fix this fails with the zero-sized-vault
    +    // invariant violation. Post-fix the full-price exchange rate burns
    +    // only a portion of the shares, the depositor receives all of
    +    // AssetsAvailable, and the residual shares remain backed by the
    +    // impaired-loan receivable.
    +    void
    +    testWithdrawSoleShareholderFixedAssetExit(FeatureBitset features)
    +    {
    +        using namespace test::jtx;
    +
    +        bool const withFix = features[fixCleanup3_2_0];
    +        testcase(
    +            std::string{"Vault withdraw: sole shareholder exits via "
    +                        "fixed-asset amount with impaired loan"} +
    +            (withFix ? " (fixCleanup3_2_0)" : " (pre-fix)"));
    +
    +        std::string logs;
    +        Env env(*this, features, std::make_unique(&logs));
    +        auto const f = setupStuckDepositor(env);
    +        if (!f.vaultKeylet || !f.asset || f.sharesLender == 0)
    +        {
    +            BEAST_EXPECT(false);
    +            return;
    +        }
    +        Keylet const& vaultKey = *f.vaultKeylet;
    +        PrettyAsset const& asset = *f.asset;
    +
    +        auto const vaultBefore = env.le(vaultKey);
    +        if (!BEAST_EXPECT(vaultBefore))
    +            return;
    +        Number const availableBefore = vaultBefore->at(sfAssetsAvailable);
    +        Number const totalBefore = vaultBefore->at(sfAssetsTotal);
    +        Number const lossBefore = vaultBefore->at(sfLossUnrealized);
    +
    +        STAmount const lenderBalanceBefore = env.balance(f.lender, asset);
    +
    +        // The requested amount differs between feature regimes because
    +        // the two regimes are testing different behaviors:
    +        //
    +        // - Pre-fix: request the full AssetsAvailable (3,333.50). Under
    +        //   the discounted formula this would burn every outstanding
    +        //   share, hitting the zero-sized-vault invariant. The
    +        //   transaction is rejected with tecINVARIANT_FAILED — the
    +        //   stuck-depositor bug.
    +        //
    +        // - Post-fix: request a strictly smaller amount (1,000 USD).
    +        //   The full-price formula burns only ~30% of the outstanding
    +        //   shares; the vault retains the rest, backed by the impaired
    +        //   receivable. Requesting *exactly* AssetsAvailable post-fix
    +        //   would currently fail with tecINSUFFICIENT_FUNDS due to the
    +        //   round-to-nearest used by assetsToSharesWithdraw (the
    +        //   recomputed payout can overshoot the request by a few ULPs).
    +        //   The "force payout to AssetsAvailable" branch in doApply
    +        //   only triggers when every share is burned, which is covered
    +        //   by the loan-repayment test.
    +        STAmount const requestAssets =
    +            withFix ? asset(1000).value() : STAmount{asset.raw(), availableBefore};
    +        Vault const v{env};
    +        env(v.withdraw({
    +                .depositor = f.lender,
    +                .id = vaultKey.key,
    +                .amount = requestAssets,
    +            }),
    +            Ter(withFix ? TER{tesSUCCESS} : TER{tecINVARIANT_FAILED}));
    +        env.close();
    +
    +        auto const vaultAfter = env.le(vaultKey);
    +        if (!BEAST_EXPECT(vaultAfter))
    +            return;
    +        auto const issuanceAfter = env.le(keylet::mptokenIssuance(f.shareAsset));
    +        if (!BEAST_EXPECT(issuanceAfter))
    +            return;
    +
    +        std::uint64_t const sharesAfter = issuanceAfter->getFieldU64(sfOutstandingAmount);
    +        Number const availableAfter = vaultAfter->at(sfAssetsAvailable);
    +        Number const totalAfter = vaultAfter->at(sfAssetsTotal);
    +        Number const lossAfter = vaultAfter->at(sfLossUnrealized);
    +
    +        if (!withFix)
    +        {
    +            // Pre-fix: rejected — vault state unchanged.
    +            BEAST_EXPECT(sharesAfter == f.sharesLender);
    +            BEAST_EXPECT(availableAfter == availableBefore);
    +            BEAST_EXPECT(totalAfter == totalBefore);
    +            BEAST_EXPECT(lossAfter == lossBefore);
    +            return;
    +        }
    +
    +        // Post-fix exact-value derivation (fixture: sharesLender=5e9,
    +        // totalBefore=6666.5, request=1000):
    +        //   sharesRedeemed = round(sharesLender * request / totalBefore)
    +        //                  = round(750,018,750.469) = 750,018,750
    +        //   received       = totalBefore * sharesRedeemed / sharesLender
    +        //                  = 999.999999375  (slightly under 1,000 due to
    +        //                                    integer-share rounding)
    +        constexpr std::uint64_t kExpectedSharesRedeemed = 750'018'750;
    +        Number const expectedReceived =
    +            totalBefore * Number(kExpectedSharesRedeemed) / Number(f.sharesLender);
    +
    +        BEAST_EXPECT(sharesAfter == f.sharesLender - kExpectedSharesRedeemed);
    +
    +        // LossUnrealized is unchanged: the loan-protocol side is untouched.
    +        BEAST_EXPECT(lossAfter == lossBefore);
    +
    +        // The entire (total - available) gap is the impaired receivable,
    +        // i.e. equal to lossUnrealized.
    +        BEAST_EXPECT(totalAfter - availableAfter == lossAfter);
    +
    +        STAmount const lenderBalanceAfter = env.balance(f.lender, asset);
    +        Number const received{lenderBalanceAfter - lenderBalanceBefore};
    +        BEAST_EXPECT(received == expectedReceived);
    +
    +        // Conservation: assets removed from the vault equal what the
    +        // depositor received.
    +        BEAST_EXPECT(totalBefore - totalAfter == received);
    +        BEAST_EXPECT(availableBefore - availableAfter == received);
    +    }
    +
    +    // Sole shareholder attempts to burn ALL outstanding shares via
    +    // fixed-shares input while the vault still holds an impaired
    +    // receivable. Pre-fix this fails with the zero-sized-vault invariant
    +    // violation. Post-fix the full-price rate causes assetsWithdrawn to
    +    // equal assetsTotal, which exceeds assetsAvailable, so the transaction
    +    // is rejected with tecINSUFFICIENT_FUNDS.
    +    void
    +    testWithdrawSoleShareholderFullSharesRejected(FeatureBitset features)
    +    {
    +        using namespace test::jtx;
    +
    +        bool const withFix = features[fixCleanup3_2_0];
    +        testcase(
    +            std::string{"Vault withdraw: sole shareholder full-shares "
    +                        "burn is rejected while loss outstanding"} +
    +            (withFix ? " (fixCleanup3_2_0)" : " (pre-fix)"));
    +
    +        std::string logs;
    +        Env env(*this, features, std::make_unique(&logs));
    +        auto const f = setupStuckDepositor(env);
    +        if (!f.vaultKeylet || f.sharesLender == 0)
    +        {
    +            BEAST_EXPECT(false);
    +            return;
    +        }
    +        Keylet const& vaultKey = *f.vaultKeylet;
    +
    +        auto const vaultBefore = env.le(vaultKey);
    +        if (!BEAST_EXPECT(vaultBefore))
    +            return;
    +        Number const availableBefore = vaultBefore->at(sfAssetsAvailable);
    +        Number const totalBefore = vaultBefore->at(sfAssetsTotal);
    +        Number const lossBefore = vaultBefore->at(sfLossUnrealized);
    +
    +        // Fixed-shares input: ask for ALL outstanding shares.
    +        STAmount const shareAmt{MPTIssue{f.shareAsset}, Number(f.sharesLender)};
    +        Vault const v{env};
    +        env(v.withdraw({
    +                .depositor = f.lender,
    +                .id = vaultKey.key,
    +                .amount = shareAmt,
    +            }),
    +            Ter(withFix ? TER{tecINSUFFICIENT_FUNDS} : TER{tecINVARIANT_FAILED}));
    +        env.close();
    +
    +        // Either way the transaction was rejected; vault state unchanged.
    +        auto const vaultAfter = env.le(vaultKey);
    +        if (!BEAST_EXPECT(vaultAfter))
    +            return;
    +        auto const issuanceAfter = env.le(keylet::mptokenIssuance(f.shareAsset));
    +        if (!BEAST_EXPECT(issuanceAfter))
    +            return;
    +        BEAST_EXPECT(issuanceAfter->getFieldU64(sfOutstandingAmount) == f.sharesLender);
    +        BEAST_EXPECT(vaultAfter->at(sfAssetsAvailable) == availableBefore);
    +        BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == totalBefore);
    +        BEAST_EXPECT(vaultAfter->at(sfLossUnrealized) == lossBefore);
    +    }
    +
    +    // Clean-state regression: with no impaired loan, a sole shareholder
    +    // burning all their shares fully empties the vault under both the
    +    // pre-fix and post-fix code paths. Confirms the new logic doesn't
    +    // break the existing happy-path close-out.
    +    void
    +    testWithdrawSoleShareholderCleanVaultUnaffected(FeatureBitset features)
    +    {
    +        using namespace test::jtx;
    +
    +        bool const withFix = features[fixCleanup3_2_0];
    +        testcase(
    +            std::string{"Vault withdraw: sole shareholder clean-state "
    +                        "close-out unchanged"} +
    +            (withFix ? " (fixCleanup3_2_0)" : " (pre-fix)"));
    +
    +        Env env(*this, features);
    +
    +        Account const issuer{"issuer"};
    +        Account const lender{"lender"};
    +
    +        env.fund(XRP(kStuckFunding), issuer, lender);
    +        env.close();
    +
    +        PrettyAsset const asset = issuer[iouCurrency_];
    +        env(trust(lender, asset(10'000'000)));
    +        env.close();
    +        env(pay(issuer, lender, asset(kStuckDepositorIOU)));
    +        env.close();
    +
    +        // Sole shareholder of a clean vault — no loan broker needed.
    +        Vault const v{env};
    +        auto [createTx, vaultKeylet] = v.create({.owner = lender, .asset = asset});
    +        env(createTx);
    +        env.close();
    +
    +        env(v.deposit({
    +                .depositor = lender,
    +                .id = vaultKeylet.key,
    +                .amount = asset(kStuckDeposit),
    +            }),
    +            Ter(tesSUCCESS));
    +        env.close();
    +
    +        auto const vaultBefore = env.le(vaultKeylet);
    +        if (!BEAST_EXPECT(vaultBefore))
    +            return;
    +        auto const shareAsset = vaultBefore->at(sfShareMPTID);
    +        auto const tokenLender = env.le(keylet::mptoken(shareAsset, lender.id()));
    +        if (!BEAST_EXPECT(tokenLender))
    +            return;
    +        std::uint64_t const sharesLender = tokenLender->getFieldU64(sfMPTAmount);
    +
    +        // Sole shareholder, no loans, no loss. Burn everything.
    +        STAmount const allShares{MPTIssue{shareAsset}, Number(sharesLender)};
    +        env(v.withdraw({
    +                .depositor = lender,
    +                .id = vaultKeylet.key,
    +                .amount = allShares,
    +            }),
    +            Ter(tesSUCCESS));
    +        env.close();
    +
    +        auto const vaultFinal = env.le(vaultKeylet);
    +        if (!BEAST_EXPECT(vaultFinal))
    +            return;
    +        auto const issuanceFinal = env.le(keylet::mptokenIssuance(shareAsset));
    +        if (!BEAST_EXPECT(issuanceFinal))
    +            return;
    +        BEAST_EXPECT(issuanceFinal->getFieldU64(sfOutstandingAmount) == 0);
    +        BEAST_EXPECT(vaultFinal->at(sfAssetsTotal) == beast::kZero);
    +        BEAST_EXPECT(vaultFinal->at(sfAssetsAvailable) == beast::kZero);
    +        BEAST_EXPECT(vaultFinal->at(sfLossUnrealized) == beast::kZero);
    +
    +        // (Pre-fix path takes the regular code path; post-fix path enters
    +        // the new final-withdrawal guard, which forces payout to exactly
    +        // assetsAvailable. Either way the result is identical for a clean
    +        // vault.)
    +        (void)withFix;
    +    }
    +
    +    // Sole shareholder in an impaired vault redeems a *partial* count of
    +    // shares via fixed-shares input. Pre-fix the discounted formula is
    +    // used; post-fix the full-price formula is used (waiveUnrealizedLoss
    +    // = Yes). The relative payout therefore differs, and post-fix the
    +    // depositor recovers proportionally more of the residual cash for
    +    // the shares burned. In both cases the vault is left in a valid
    +    // (non-empty) state.
    +    void
    +    testWithdrawSoleShareholderPartialFixedSharesUsesFullPrice()
    +    {
    +        using namespace test::jtx;
    +
    +        testcase(
    +            "Vault withdraw: sole-shareholder partial fixed-shares uses "
    +            "full-price rate (fixCleanup3_2_0)");
    +
    +        Env env(*this, all_ | fixCleanup3_2_0);
    +        auto const f = setupStuckDepositor(env);
    +        if (!f.vaultKeylet || !f.asset || f.sharesLender == 0)
    +        {
    +            BEAST_EXPECT(false);
    +            return;
    +        }
    +        Keylet const& vaultKey = *f.vaultKeylet;
    +        PrettyAsset const& asset = *f.asset;
    +
    +        auto const vaultBefore = env.le(vaultKey);
    +        if (!BEAST_EXPECT(vaultBefore))
    +            return;
    +        Number const totalBefore = vaultBefore->at(sfAssetsTotal);
    +        Number const availableBefore = vaultBefore->at(sfAssetsAvailable);
    +        Number const lossBefore = vaultBefore->at(sfLossUnrealized);
    +
    +        // Burn exactly half of the outstanding shares.
    +        std::uint64_t const halfShares = f.sharesLender / 2;
    +        STAmount const halfAmt{MPTIssue{f.shareAsset}, Number(halfShares)};
    +
    +        STAmount const lenderBalanceBefore = env.balance(f.lender, asset);
    +
    +        Vault const v{env};
    +        env(v.withdraw({
    +                .depositor = f.lender,
    +                .id = vaultKey.key,
    +                .amount = halfAmt,
    +            }),
    +            Ter(tesSUCCESS));
    +        env.close();
    +
    +        // Expected payout under the full-price formula:
    +        //   assets = totalBefore * halfShares / sharesLender
    +        // which (with halfShares == sharesLender/2) is roughly
    +        //   totalBefore / 2.
    +        STAmount const lenderBalanceAfter = env.balance(f.lender, asset);
    +        Number const received{lenderBalanceAfter - lenderBalanceBefore};
    +        Number const expected = totalBefore * Number(halfShares) / Number(f.sharesLender);
    +        BEAST_EXPECT(received == expected);
    +
    +        // The full-price payout exceeds the discounted formula by exactly
    +        // lossBefore * halfShares / sharesLender — that's the whole point
    +        // of the waive.
    +        Number const discounted =
    +            (totalBefore - lossBefore) * Number(halfShares) / Number(f.sharesLender);
    +        Number const expectedDelta = lossBefore * Number(halfShares) / Number(f.sharesLender);
    +        BEAST_EXPECT(received - discounted == expectedDelta);
    +
    +        auto const vaultAfter = env.le(vaultKey);
    +        if (!BEAST_EXPECT(vaultAfter))
    +            return;
    +        auto const issuanceAfter = env.le(keylet::mptokenIssuance(f.shareAsset));
    +        if (!BEAST_EXPECT(issuanceAfter))
    +            return;
    +
    +        // Vault remains valid: half the shares remain, lossUnrealized
    +        // is untouched, and the entire (total - available) gap is still
    +        // the impaired receivable.
    +        BEAST_EXPECT(
    +            issuanceAfter->getFieldU64(sfOutstandingAmount) == f.sharesLender - halfShares);
    +        BEAST_EXPECT(vaultAfter->at(sfAssetsTotal) == totalBefore - received);
    +        BEAST_EXPECT(vaultAfter->at(sfLossUnrealized) == lossBefore);
    +        BEAST_EXPECT(
    +            vaultAfter->at(sfAssetsTotal) - vaultAfter->at(sfAssetsAvailable) ==
    +            vaultAfter->at(sfLossUnrealized));
    +
    +        // Conservation: vault delta matches the depositor's gain.
    +        BEAST_EXPECT(totalBefore - vaultAfter->at(sfAssetsTotal) == received);
    +        BEAST_EXPECT(availableBefore - vaultAfter->at(sfAssetsAvailable) == received);
    +    }
    +
    +    // Post-fix end-to-end resolution: after the sole-shareholder partial
    +    // exit, the loan is repaid in full. With unrealized loss cleared and
    +    // all assets back as cash, the depositor can burn all remaining
    +    // shares and fully exit the vault. The final withdrawal hits the
    +    // "force payout to assetsAvailable" branch in doApply.
    +    void
    +    testWithdrawSoleShareholderLoanRepaymentExit()
    +    {
    +        using namespace test::jtx;
    +        using namespace loan;
    +
    +        testcase(
    +            "Vault withdraw: sole shareholder fully exits after impaired "
    +            "loan is repaid (fixCleanup3_2_0)");
    +
    +        Env env(*this, all_ | fixCleanup3_2_0);
    +        auto const f = setupStuckDepositor(env);
    +        if (!f.vaultKeylet || !f.asset || !f.loanKeylet || f.sharesLender == 0)
    +        {
    +            BEAST_EXPECT(false);
    +            return;
    +        }
    +        Keylet const& vaultKey = *f.vaultKeylet;
    +        Keylet const& loanKey = *f.loanKeylet;
    +        PrettyAsset const& asset = *f.asset;
    +
    +        Vault const v{env};
    +
    +        // Sole-shareholder partial exit (see comment in
    +        // testWithdrawSoleShareholderFixedAssetExit for why we request
    +        // less than full AssetsAvailable).
    +        {
    +            STAmount const requestAssets = asset(1000).value();
    +            env(v.withdraw({
    +                    .depositor = f.lender,
    +                    .id = vaultKey.key,
    +                    .amount = requestAssets,
    +                }),
    +                Ter(tesSUCCESS));
    +            env.close();
    +        }
    +
    +        // Confirm the "dormant-but-alive" state from the design doc. The
    +        // partial exit burned exactly 750,018,750 shares (see derivation
    +        // in testWithdrawSoleShareholderFixedAssetExit).
    +        auto const tokenAfterExit = env.le(keylet::mptoken(f.shareAsset, f.lender.id()));
    +        if (!BEAST_EXPECT(tokenAfterExit))
    +            return;
    +        std::uint64_t const retainedShares = tokenAfterExit->getFieldU64(sfMPTAmount);
    +        BEAST_EXPECT(retainedShares == f.sharesLender - 750'018'750);
    +
    +        // Borrower repays the loan in full (pays more than the outstanding
    +        // total; the loan transactor caps the receivable).
    +        env(pay(f.borrower, loanKey.key, asset(kStuckPrincipal * 2)), Ter(tesSUCCESS));
    +        env.close();
    +
    +        auto const vaultAfterRepay = env.le(vaultKey);
    +        if (!BEAST_EXPECT(vaultAfterRepay))
    +            return;
    +        // Repayment converts the 3,333 receivable back to cash; assetsTotal
    +        // is unchanged but assetsAvailable jumps by exactly the same amount,
    +        // and lossUnrealized clears to zero.
    +        BEAST_EXPECT(vaultAfterRepay->at(sfLossUnrealized) == beast::kZero);
    +        BEAST_EXPECT(vaultAfterRepay->at(sfAssetsAvailable) == vaultAfterRepay->at(sfAssetsTotal));
    +
    +        STAmount const lenderBalanceBeforeFinal = env.balance(f.lender, asset);
    +        Number const availableBeforeFinal = vaultAfterRepay->at(sfAssetsAvailable);
    +
    +        // Burn all remaining shares — the clean-state preconditions of
    +        // the "final withdrawal" guard are now satisfied.
    +        STAmount const allShares{MPTIssue{f.shareAsset}, Number(retainedShares)};
    +        env(v.withdraw({
    +                .depositor = f.lender,
    +                .id = vaultKey.key,
    +                .amount = allShares,
    +            }),
    +            Ter(tesSUCCESS));
    +        env.close();
    +
    +        auto const vaultFinal = env.le(vaultKey);
    +        if (!BEAST_EXPECT(vaultFinal))
    +            return;
    +        auto const issuanceFinal = env.le(keylet::mptokenIssuance(f.shareAsset));
    +        if (!BEAST_EXPECT(issuanceFinal))
    +            return;
    +
    +        // Zero-sized vault invariant satisfied: 0 shares, 0 assets.
    +        BEAST_EXPECT(issuanceFinal->getFieldU64(sfOutstandingAmount) == 0);
    +        BEAST_EXPECT(vaultFinal->at(sfAssetsTotal) == beast::kZero);
    +        BEAST_EXPECT(vaultFinal->at(sfAssetsAvailable) == beast::kZero);
    +        BEAST_EXPECT(vaultFinal->at(sfLossUnrealized) == beast::kZero);
    +
    +        // The final payout equals exactly the AssetsAvailable that
    +        // existed before the call (the "force payout" branch).
    +        STAmount const lenderBalanceAfter = env.balance(f.lender, asset);
    +        Number const finalReceived{lenderBalanceAfter - lenderBalanceBeforeFinal};
    +        BEAST_EXPECT(finalReceived == availableBeforeFinal);
    +    }
    +
    +public:
    +    void
    +    run() override
    +    {
    +        testWithdrawSoleShareholderFixedAssetExit(all_ - fixCleanup3_2_0);
    +        testWithdrawSoleShareholderFixedAssetExit(all_);
    +        testWithdrawSoleShareholderFullSharesRejected(all_ - fixCleanup3_2_0);
    +        testWithdrawSoleShareholderFullSharesRejected(all_);
    +        testWithdrawSoleShareholderCleanVaultUnaffected(all_ - fixCleanup3_2_0);
    +        testWithdrawSoleShareholderCleanVaultUnaffected(all_);
    +        testWithdrawSoleShareholderPartialFixedSharesUsesFullPrice();
    +        testWithdrawSoleShareholderLoanRepaymentExit();
    +    }
    +};
    +
    +BEAST_DEFINE_TESTSUITE(VaultSoleShareholder, app, xrpl);
    +
    +}  // namespace xrpl
    diff --git a/src/test/app/vault/VaultTestBase.h b/src/test/app/vault/VaultTestBase.h
    new file mode 100644
    index 0000000000..538f3b72d8
    --- /dev/null
    +++ b/src/test/app/vault/VaultTestBase.h
    @@ -0,0 +1,120 @@
    +#pragma once
    +
    +#include 
    +#include 
    +#include 
    +#include 
    +
    +#include 
    +#include 
    +#include 
    +#include 
    +#include 
    +#include 
    +#include 
    +
    +#include 
    +#include 
    +#include 
    +#include 
    +#include 
    +#include 
    +
    +namespace xrpl {
    +
    +/**
    + * Shared base for the Vault*_test family under src/test/app/vault/.
    + *
    + * Owns the class-level helpers (type aliases, closed-ended vault
    + * scaffolding, standard feature bitset, IOU currency string) that every
    + * topical Vault*_test suite depends on. Mirrors
    + * src/test/app/lending/LoanTestBase.h.
    + *
    + * Run all suites in this family with `xrpld -u Vault` (the "Vault" prefix
    + * is matched against every suite name via
    + * beast::unit_test::Selector::ModeT::Automatch).
    + */
    +class VaultTestBase : public beast::unit_test::Suite
    +{
    +protected:
    +    using PrettyAsset = test::jtx::PrettyAsset;
    +    using PrettyAmount = test::jtx::PrettyAmount;
    +
    +    static constexpr auto kNegativeAmount = [](PrettyAsset const& asset) -> PrettyAmount {
    +        return {STAmount{asset.raw(), 1ul, 0, true, STAmount::Unchecked{}}, ""};
    +    };
    +
    +    /**
    +     * Get the current ledger's close time resolution.
    +     * @param env The test environment.
    +     */
    +    static NetClock::duration
    +    getLedgerTimeResolution(test::jtx::Env& env)
    +    {
    +        return env.current()->header().closeTimeResolution;
    +    }
    +
    +    void
    +    closeToTime(
    +        test::jtx::Env& env,
    +        NetClock::time_point time,
    +        std::source_location const& loc = std::source_location::current())
    +    {
    +        using namespace std::chrono_literals;
    +        env.close(time - env.closed()->header().closeTimeResolution + 1s);
    +        expect(
    +            env.closed()->header().closeTime == time,
    +            std::format(
    +                "current ledger time {} is not equal to the target ledger time {}",
    +                env.closed()->header().closeTime.time_since_epoch(),
    +                time.time_since_epoch()),
    +            loc.file_name(),
    +            loc.line());
    +    }
    +
    +    using d = NetClock::duration;
    +    using tp = NetClock::time_point;
    +
    +    // Vault holds an Env& so no default initializer is possible; the
    +    // struct is always aggregate-initialized by makeClosedEndedVault.
    +    // NOLINTBEGIN(cppcoreguidelines-pro-type-member-init)
    +    struct ClosedEndedSetup
    +    {
    +        test::jtx::Vault vault;
    +        Keylet keylet;
    +        std::uint32_t sub = 0;
    +        std::uint32_t red = 0;
    +    };
    +    // NOLINTEND(cppcoreguidelines-pro-type-member-init)
    +
    +    // Submit a VaultCreate for a closed-ended vault with SubscriptionDate at
    +    // env.now() + subOffset and RedemptionDate at SubscriptionDate + gap, then
    +    // close the ledger. Returns the Vault helper, the vault's keylet and the
    +    // resolved sub/red timestamps.
    +    static ClosedEndedSetup
    +    makeClosedEndedVault(
    +        test::jtx::Env& env,
    +        test::jtx::Account const& owner,
    +        Asset const& asset,
    +        std::uint32_t subOffset,
    +        std::uint32_t gap)
    +    {
    +        auto const sub = env.now().time_since_epoch().count() + subOffset;
    +        auto const red = sub + gap;
    +        test::jtx::Vault const vault{env};
    +        auto [tx, keylet] = vault.create(
    +            {.owner = owner,
    +             .asset = asset,
    +             .vaultKind = std::to_underlying(VaultKind::ClosedEnded),
    +             .subscriptionDate = sub,
    +             .redemptionDate = red});
    +        env(tx);
    +        env.close();
    +        return {.vault = vault, .keylet = keylet, .sub = sub, .red = red};
    +    }
    +
    +    FeatureBitset const all_{test::jtx::testableAmendments()};
    +    std::string const iouCurrency_{"IOU"};
    +};
    +
    +}  // namespace xrpl
    diff --git a/src/test/app/vault/VaultValidation_test.cpp b/src/test/app/vault/VaultValidation_test.cpp
    new file mode 100644
    index 0000000000..4219ce4661
    --- /dev/null
    +++ b/src/test/app/vault/VaultValidation_test.cpp
    @@ -0,0 +1,1086 @@
    +#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 {
    +
    +class VaultValidation_test : public VaultTestBase
    +{
    +private:
    +    void
    +    testPreflight()
    +    {
    +        using namespace test::jtx;
    +
    +        struct CaseArgs
    +        {
    +            FeatureBitset features = testableAmendments();
    +        };
    +
    +        auto testCase = [&, this](
    +                            std::function test,
    +                            CaseArgs args = {}) {
    +            Env env{*this, args.features};
    +            Account const issuer{"issuer"};
    +            Account const owner{"owner"};
    +            Vault vault{env};
    +            env.fund(XRP(1000), issuer, owner);
    +            env.close();
    +
    +            env(fset(issuer, asfAllowTrustLineClawback));
    +            env(fset(issuer, asfRequireAuth));
    +            env.close();
    +
    +            PrettyAsset const asset = issuer["IOU"];
    +            env(trust(owner, asset(1000)));
    +            env(trust(issuer, asset(0), owner, tfSetfAuth));
    +            env(pay(issuer, owner, asset(1000)));
    +            env.close();
    +
    +            test(env, issuer, owner, asset, vault);
    +        };
    +
    +        auto testDisabled = [&](TER resultAfterCreate = temDISABLED) {
    +            return [&, resultAfterCreate](
    +                       Env& env,
    +                       Account const& issuer,
    +                       Account const& owner,
    +                       Asset const& asset,
    +                       Vault& vault) {
    +                testcase("disabled single asset vault");
    +
    +                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +                env(tx, Ter{temDISABLED});
    +
    +                {
    +                    auto tx = vault.set({.owner = owner, .id = keylet.key});
    +                    env(tx, kData("test"), Ter{resultAfterCreate});
    +                }
    +
    +                {
    +                    auto tx =
    +                        vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(10)});
    +                    env(tx, Ter{resultAfterCreate});
    +                }
    +
    +                {
    +                    auto tx =
    +                        vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)});
    +                    env(tx, Ter{resultAfterCreate});
    +                }
    +
    +                {
    +                    auto tx = vault.clawback(
    +                        {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(10)});
    +                    env(tx, Ter{resultAfterCreate});
    +                }
    +
    +                {
    +                    auto tx = vault.del({.owner = owner, .id = keylet.key});
    +                    env(tx, Ter{resultAfterCreate});
    +                }
    +            };
    +        };
    +
    +        testCase(testDisabled(), {.features = testableAmendments() - featureSingleAssetVault});
    +
    +        testCase(testDisabled(tecNO_ENTRY), {.features = testableAmendments() - featureMPTokensV1});
    +
    +        testCase(
    +            [&](Env& env,
    +                Account const& issuer,
    +                Account const& owner,
    +                Asset const& asset,
    +                Vault& vault) {
    +                testcase("disabled permissioned domains");
    +
    +                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +                env(tx);
    +
    +                tx[sfFlags] = tx[sfFlags].asUInt() | tfVaultPrivate;
    +                tx[sfDomainID] = to_string(BaseUInt<256>(42ul));
    +                env(tx, Ter{temDISABLED});
    +
    +                {
    +                    auto tx = vault.set({.owner = owner, .id = keylet.key});
    +                    env(tx, kData("Test"));
    +
    +                    tx[sfDomainID] = to_string(BaseUInt<256>(13ul));
    +                    env(tx, Ter{temDISABLED});
    +                }
    +            },
    +            {.features = testableAmendments() - featurePermissionedDomains});
    +
    +        testCase([&](Env& env,
    +                     Account const& issuer,
    +                     Account const& owner,
    +                     Asset const& asset,
    +                     Vault& vault) {
    +            testcase("invalid flags");
    +
    +            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +            tx[sfFlags] = tfClearDeepFreeze;
    +            env(tx, Ter{temINVALID_FLAG});
    +
    +            {
    +                auto tx = vault.set({.owner = owner, .id = keylet.key});
    +                tx[sfFlags] = tfClearDeepFreeze;
    +                env(tx, Ter{temINVALID_FLAG});
    +            }
    +
    +            {
    +                auto tx =
    +                    vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(10)});
    +                tx[sfFlags] = tfClearDeepFreeze;
    +                env(tx, Ter{temINVALID_FLAG});
    +            }
    +
    +            {
    +                auto tx =
    +                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)});
    +                tx[sfFlags] = tfClearDeepFreeze;
    +                env(tx, Ter{temINVALID_FLAG});
    +            }
    +
    +            {
    +                auto tx = vault.clawback(
    +                    {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(10)});
    +                tx[sfFlags] = tfClearDeepFreeze;
    +                env(tx, Ter{temINVALID_FLAG});
    +            }
    +
    +            {
    +                auto tx = vault.del({.owner = owner, .id = keylet.key});
    +                tx[sfFlags] = tfClearDeepFreeze;
    +                env(tx, Ter{temINVALID_FLAG});
    +            }
    +        });
    +
    +        testCase([&](Env& env,
    +                     Account const& issuer,
    +                     Account const& owner,
    +                     Asset const& asset,
    +                     Vault& vault) {
    +            testcase("invalid fee");
    +
    +            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +            tx[jss::Fee] = "-1";
    +            env(tx, Ter{temBAD_FEE});
    +
    +            {
    +                auto tx = vault.set({.owner = owner, .id = keylet.key});
    +                tx[jss::Fee] = "-1";
    +                env(tx, Ter{temBAD_FEE});
    +            }
    +
    +            {
    +                auto tx =
    +                    vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(10)});
    +                tx[jss::Fee] = "-1";
    +                env(tx, Ter{temBAD_FEE});
    +            }
    +
    +            {
    +                auto tx =
    +                    vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)});
    +                tx[jss::Fee] = "-1";
    +                env(tx, Ter{temBAD_FEE});
    +            }
    +
    +            {
    +                auto tx = vault.clawback(
    +                    {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(10)});
    +                tx[jss::Fee] = "-1";
    +                env(tx, Ter{temBAD_FEE});
    +            }
    +
    +            {
    +                auto tx = vault.del({.owner = owner, .id = keylet.key});
    +                tx[jss::Fee] = "-1";
    +                env(tx, Ter{temBAD_FEE});
    +            }
    +        });
    +
    +        testCase(
    +            [&](Env& env, Account const&, Account const& owner, Asset const&, Vault& vault) {
    +                testcase("disabled permissioned domain");
    +
    +                auto [tx, keylet] = vault.create({.owner = owner, .asset = xrpIssue()});
    +                tx[sfDomainID] = to_string(BaseUInt<256>(42ul));
    +                env(tx, Ter{temDISABLED});
    +
    +                {
    +                    auto tx = vault.set({.owner = owner, .id = keylet.key});
    +                    tx[sfDomainID] = to_string(BaseUInt<256>(42ul));
    +                    env(tx, Ter{temDISABLED});
    +                }
    +
    +                {
    +                    auto tx = vault.set({.owner = owner, .id = keylet.key});
    +                    tx[sfDomainID] = "0";
    +                    env(tx, Ter{temDISABLED});
    +                }
    +            },
    +            {.features = (testableAmendments()) - featurePermissionedDomains});
    +
    +        testCase([&](Env& env,
    +                     Account const& issuer,
    +                     Account const& owner,
    +                     Asset const& asset,
    +                     Vault& vault) {
    +            testcase("use zero vault");
    +
    +            auto [tx, keylet] = vault.create({.owner = owner, .asset = xrpIssue()});
    +
    +            {
    +                auto tx = vault.set({
    +                    .owner = owner,
    +                    .id = beast::kZero,
    +                });
    +                env(tx, Ter{temMALFORMED});
    +            }
    +
    +            {
    +                auto tx =
    +                    vault.deposit({.depositor = owner, .id = beast::kZero, .amount = asset(10)});
    +                env(tx, Ter(temMALFORMED));
    +            }
    +
    +            {
    +                auto tx =
    +                    vault.withdraw({.depositor = owner, .id = beast::kZero, .amount = asset(10)});
    +                env(tx, Ter{temMALFORMED});
    +            }
    +
    +            {
    +                auto tx = vault.clawback(
    +                    {.issuer = issuer, .id = beast::kZero, .holder = owner, .amount = asset(10)});
    +                env(tx, Ter{temMALFORMED});
    +            }
    +
    +            {
    +                auto tx = vault.del({
    +                    .owner = owner,
    +                    .id = beast::kZero,
    +                });
    +                env(tx, Ter{temMALFORMED});
    +            }
    +        });
    +
    +        testCase(
    +            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
    +                testcase("withdraw to bad destination");
    +
    +                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +
    +                {
    +                    auto tx =
    +                        vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(10)});
    +                    tx[jss::Destination] = "0";
    +                    env(tx, Ter{temMALFORMED});
    +                }
    +            });
    +
    +        testCase(
    +            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
    +                testcase("create with Scale");
    +
    +                {
    +                    auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +                    tx[sfScale] = 255;
    +                    env(tx, Ter(temMALFORMED));
    +                }
    +
    +                {
    +                    auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +                    tx[sfScale] = 19;
    +                    env(tx, Ter(temMALFORMED));
    +                }
    +
    +                // accepted range from 0 to 18
    +                {
    +                    auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +                    tx[sfScale] = 18;
    +                    env(tx);
    +                    env.close();
    +                    auto const sleVault = env.le(keylet);
    +                    BEAST_EXPECT(sleVault);
    +                    BEAST_EXPECT((*sleVault)[sfScale] == 18);
    +                }
    +
    +                {
    +                    auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +                    tx[sfScale] = 0;
    +                    env(tx);
    +                    env.close();
    +                    auto const sleVault = env.le(keylet);
    +                    BEAST_EXPECT(sleVault);
    +                    BEAST_EXPECT((*sleVault)[sfScale] == 0);
    +                }
    +
    +                {
    +                    auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +                    env(tx);
    +                    env.close();
    +                    auto const sleVault = env.le(keylet);
    +                    BEAST_EXPECT(sleVault);
    +                    BEAST_EXPECT((*sleVault)[sfScale] == 6);
    +                }
    +            });
    +
    +        testCase(
    +            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
    +                testcase("create or set invalid data");
    +
    +                auto [tx1, keylet] = vault.create({.owner = owner, .asset = asset});
    +
    +                {
    +                    auto tx = tx1;
    +                    tx[sfData] = "";
    +                    env(tx, Ter(temMALFORMED));
    +                }
    +
    +                {
    +                    auto tx = tx1;
    +                    // A hexadecimal string of 257 bytes.
    +                    tx[sfData] = std::string(514, 'A');
    +                    env(tx, Ter(temMALFORMED));
    +                }
    +
    +                {
    +                    auto tx = vault.set({.owner = owner, .id = keylet.key});
    +                    tx[sfData] = "";
    +                    env(tx, Ter{temMALFORMED});
    +                }
    +
    +                {
    +                    auto tx = vault.set({.owner = owner, .id = keylet.key});
    +                    // A hexadecimal string of 257 bytes.
    +                    tx[sfData] = std::string(514, 'A');
    +                    env(tx, Ter{temMALFORMED});
    +                }
    +            });
    +
    +        testCase(
    +            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
    +                testcase("set nothing updated");
    +
    +                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +
    +                {
    +                    auto tx = vault.set({.owner = owner, .id = keylet.key});
    +                    env(tx, Ter{temMALFORMED});
    +                }
    +            });
    +
    +        testCase(
    +            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
    +                testcase("create with invalid metadata");
    +
    +                auto [tx1, keylet] = vault.create({.owner = owner, .asset = asset});
    +
    +                {
    +                    auto tx = tx1;
    +                    tx[sfMPTokenMetadata] = "";
    +                    env(tx, Ter(temMALFORMED));
    +                }
    +
    +                {
    +                    auto tx = tx1;
    +                    // This metadata is for the share token.
    +                    // A hexadecimal string of 1025 bytes.
    +                    tx[sfMPTokenMetadata] = std::string(2050, 'B');
    +                    env(tx, Ter(temMALFORMED));
    +                }
    +            });
    +
    +        testCase(
    +            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
    +                testcase("set negative maximum");
    +
    +                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +
    +                {
    +                    auto tx = vault.set({.owner = owner, .id = keylet.key});
    +                    tx[sfAssetsMaximum] = kNegativeAmount(asset).number();
    +                    env(tx, Ter{temMALFORMED});
    +                }
    +            });
    +
    +        testCase(
    +            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
    +                testcase("invalid deposit amount");
    +
    +                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +
    +                {
    +                    auto tx = vault.deposit(
    +                        {.depositor = owner, .id = keylet.key, .amount = kNegativeAmount(asset)});
    +                    env(tx, Ter(temBAD_AMOUNT));
    +                }
    +
    +                {
    +                    auto tx =
    +                        vault.deposit({.depositor = owner, .id = keylet.key, .amount = asset(0)});
    +                    env(tx, Ter(temBAD_AMOUNT));
    +                }
    +            });
    +
    +        testCase(
    +            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
    +                testcase("invalid set immutable flag");
    +
    +                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +
    +                {
    +                    auto tx = vault.set({.owner = owner, .id = keylet.key});
    +                    tx[sfFlags] = tfVaultPrivate;
    +                    env(tx, Ter(temINVALID_FLAG));
    +                }
    +            });
    +
    +        testCase(
    +            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
    +                testcase("invalid withdraw amount");
    +
    +                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +
    +                {
    +                    auto tx = vault.withdraw(
    +                        {.depositor = owner, .id = keylet.key, .amount = kNegativeAmount(asset)});
    +                    env(tx, Ter(temBAD_AMOUNT));
    +                }
    +
    +                {
    +                    auto tx =
    +                        vault.withdraw({.depositor = owner, .id = keylet.key, .amount = asset(0)});
    +                    env(tx, Ter(temBAD_AMOUNT));
    +                }
    +            });
    +
    +        testCase([&](Env& env,
    +                     Account const& issuer,
    +                     Account const& owner,
    +                     Asset const& asset,
    +                     Vault& vault) {
    +            testcase("invalid clawback");
    +
    +            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +
    +            // Preclaim only checks for native assets.
    +            if (asset.native())
    +            {
    +                auto tx = vault.clawback(
    +                    {.issuer = issuer, .id = keylet.key, .holder = owner, .amount = asset(50)});
    +                env(tx, Ter(temMALFORMED));
    +            }
    +
    +            {
    +                auto tx = vault.clawback(
    +                    {.issuer = issuer,
    +                     .id = keylet.key,
    +                     .holder = owner,
    +                     .amount = kNegativeAmount(asset)});
    +                env(tx, Ter(temBAD_AMOUNT));
    +            }
    +        });
    +
    +        testCase(
    +            [&](Env& env, Account const&, Account const& owner, Asset const& asset, Vault& vault) {
    +                testcase("invalid create");
    +
    +                auto [tx1, keylet] = vault.create({.owner = owner, .asset = asset});
    +
    +                {
    +                    auto tx = tx1;
    +                    tx[sfWithdrawalPolicy] = 0;
    +                    env(tx, Ter(temMALFORMED));
    +                }
    +
    +                {
    +                    auto tx = tx1;
    +                    tx[sfDomainID] = to_string(BaseUInt<256>(42ul));
    +                    env(tx, Ter{temMALFORMED});
    +                }
    +
    +                {
    +                    auto tx = tx1;
    +                    tx[sfAssetsMaximum] = kNegativeAmount(asset).number();
    +                    env(tx, Ter{temMALFORMED});
    +                }
    +
    +                {
    +                    auto tx = tx1;
    +                    tx[sfFlags] = tfVaultPrivate;
    +                    tx[sfDomainID] = "0";
    +                    env(tx, Ter{temMALFORMED});
    +                }
    +            });
    +    }
    +
    +    // Test for non-asset specific behaviors.
    +    void
    +    testCreateFailXRP()
    +    {
    +        using namespace test::jtx;
    +
    +        auto testCase = [this](
    +                            std::function test) {
    +            Env env{*this, testableAmendments()};
    +            Account const issuer{"issuer"};
    +            Account const owner{"owner"};
    +            Account const depositor{"depositor"};
    +
    +            env.fund(XRP(1000), issuer, owner, depositor);
    +            env.close();
    +            Vault vault{env};
    +            Asset const asset = xrpIssue();
    +
    +            test(env, issuer, owner, depositor, asset, vault);
    +        };
    +
    +        testCase([this](
    +                     Env& env,
    +                     Account const& issuer,
    +                     Account const& owner,
    +                     Account const& depositor,
    +                     PrettyAsset const& asset,
    +                     Vault& vault) {
    +            testcase("nothing to set");
    +            auto tx = vault.set({.owner = owner, .id = keylet::skip().key});
    +            tx[sfAssetsMaximum] = asset(0).number();
    +            env(tx, Ter(tecNO_ENTRY));
    +        });
    +
    +        testCase([this](
    +                     Env& env,
    +                     Account const& issuer,
    +                     Account const& owner,
    +                     Account const& depositor,
    +                     PrettyAsset const& asset,
    +                     Vault& vault) {
    +            testcase("nothing to deposit to");
    +            auto tx = vault.deposit(
    +                {.depositor = depositor, .id = keylet::skip().key, .amount = asset(10)});
    +            env(tx, Ter(tecNO_ENTRY));
    +        });
    +
    +        testCase([this](
    +                     Env& env,
    +                     Account const& issuer,
    +                     Account const& owner,
    +                     Account const& depositor,
    +                     PrettyAsset const& asset,
    +                     Vault& vault) {
    +            testcase("nothing to withdraw from");
    +            auto tx = vault.withdraw(
    +                {.depositor = depositor, .id = keylet::skip().key, .amount = asset(10)});
    +            env(tx, Ter(tecNO_ENTRY));
    +        });
    +
    +        testCase([this](
    +                     Env& env,
    +                     Account const& issuer,
    +                     Account const& owner,
    +                     Account const& depositor,
    +                     Asset const& asset,
    +                     Vault& vault) {
    +            testcase("nothing to delete");
    +            auto tx = vault.del({.owner = owner, .id = keylet::skip().key});
    +            env(tx, Ter(tecNO_ENTRY));
    +        });
    +
    +        testCase([this](
    +                     Env& env,
    +                     Account const& issuer,
    +                     Account const& owner,
    +                     Account const& depositor,
    +                     Asset const& asset,
    +                     Vault& vault) {
    +            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +            testcase("transaction is good");
    +            env(tx);
    +        });
    +
    +        testCase([this](
    +                     Env& env,
    +                     Account const& issuer,
    +                     Account const& owner,
    +                     Account const& depositor,
    +                     Asset const& asset,
    +                     Vault& vault) {
    +            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +            tx[sfWithdrawalPolicy] = 1;
    +            testcase("explicitly select withdrawal policy");
    +            env(tx);
    +        });
    +
    +        testCase([this](
    +                     Env& env,
    +                     Account const& issuer,
    +                     Account const& owner,
    +                     Account const& depositor,
    +                     Asset const& asset,
    +                     Vault& vault) {
    +            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +            testcase("insufficient fee");
    +            env(tx, Fee(env.current()->fees().base - 1), Ter(telINSUF_FEE_P));
    +        });
    +
    +        testCase([this](
    +                     Env& env,
    +                     Account const& issuer,
    +                     Account const& owner,
    +                     Account const& depositor,
    +                     Asset const& asset,
    +                     Vault& vault) {
    +            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +            testcase("insufficient reserve");
    +            // It is possible to construct a complicated mathematical
    +            // expression for this amount, but it is sadly not easy.
    +            env(pay(owner, issuer, XRP(775)));
    +            env.close();
    +            env(tx, Ter(tecINSUFFICIENT_RESERVE));
    +        });
    +
    +        testCase([this](
    +                     Env& env,
    +                     Account const& issuer,
    +                     Account const& owner,
    +                     Account const& depositor,
    +                     Asset const& asset,
    +                     Vault& vault) {
    +            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +            tx[sfFlags] = tfVaultPrivate;
    +            tx[sfDomainID] = to_string(BaseUInt<256>(42ul));
    +            testcase("non-existing domain");
    +            env(tx, Ter{tecOBJECT_NOT_FOUND});
    +        });
    +
    +        testCase([this](
    +                     Env& env,
    +                     Account const& issuer,
    +                     Account const& owner,
    +                     Account const& depositor,
    +                     Asset const& asset,
    +                     Vault& vault) {
    +            testcase("cannot set Scale=0");
    +            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +            tx[sfScale] = 0;
    +            env(tx, Ter{temMALFORMED});
    +        });
    +
    +        testCase([this](
    +                     Env& env,
    +                     Account const& issuer,
    +                     Account const& owner,
    +                     Account const& depositor,
    +                     Asset const& asset,
    +                     Vault& vault) {
    +            testcase("cannot set Scale=1");
    +            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +            tx[sfScale] = 1;
    +            env(tx, Ter{temMALFORMED});
    +        });
    +    }
    +
    +    void
    +    testCreateFailIOU()
    +    {
    +        using namespace test::jtx;
    +        {
    +            {
    +                testcase("IOU fail because MPT is disabled");
    +                Env env{*this, (testableAmendments() - featureMPTokensV1)};
    +                Account const issuer{"issuer"};
    +                Account const owner{"owner"};
    +                env.fund(XRP(1000), issuer, owner);
    +                env.close();
    +
    +                Vault const vault{env};
    +                Asset const asset = issuer["IOU"].asset();
    +                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +
    +                env(tx, Ter(temDISABLED));
    +                env.close();
    +            }
    +
    +            {
    +                testcase("IOU fail create frozen");
    +                Env env{*this, testableAmendments()};
    +                Account const issuer{"issuer"};
    +                Account const owner{"owner"};
    +                env.fund(XRP(1000), issuer, owner);
    +                env.close();
    +                env(fset(issuer, asfGlobalFreeze));
    +                env.close();
    +
    +                Vault const vault{env};
    +                Asset const asset = issuer["IOU"].asset();
    +                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +
    +                env(tx, Ter(tecFROZEN));
    +                env.close();
    +            }
    +
    +            {
    +                testcase("IOU fail create no ripling");
    +                Env env{*this, testableAmendments()};
    +                Account const issuer{"issuer"};
    +                Account const owner{"owner"};
    +                env.fund(XRP(1000), issuer, owner);
    +                env.close();
    +                env(fclear(issuer, asfDefaultRipple));
    +                env.close();
    +
    +                Vault const vault{env};
    +                Asset const asset = issuer["IOU"].asset();
    +                auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +                env(tx, Ter(terNO_RIPPLE));
    +                env.close();
    +            }
    +
    +            {
    +                testcase("IOU no issuer");
    +                Env env{*this, testableAmendments()};
    +                Account const issuer{"issuer"};
    +                Account const owner{"owner"};
    +                env.fund(XRP(1000), owner);
    +                env.close();
    +
    +                Vault const vault{env};
    +                Asset const asset = issuer["IOU"].asset();
    +                {
    +                    auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +                    env(tx, Ter(terNO_ACCOUNT));
    +                    env.close();
    +                }
    +            }
    +        }
    +
    +        {
    +            testcase("IOU fail create vault for AMM LPToken");
    +            Env env{*this, testableAmendments()};
    +            Account const gw("gateway");
    +            Account const alice("alice");
    +            Account const carol("carol");
    +            IOU const usd = gw["USD"];
    +
    +            auto const [asset1, asset2] = std::pair(XRP(10000), usd(10000));
    +            auto toFund = [&](STAmount const& a) -> STAmount {
    +                if (a.native())
    +                {
    +                    auto const defXRP = XRP(30000);
    +                    if (a <= defXRP)
    +                        return defXRP;
    +                    return a + XRP(1000);
    +                }
    +                auto defIOU = STAmount{a.asset(), 30000};
    +                if (a <= defIOU)
    +                    return defIOU;
    +                return a + STAmount{a.asset(), 1000};
    +            };
    +            auto const toFund1 = toFund(asset1);
    +            auto const toFund2 = toFund(asset2);
    +            BEAST_EXPECT(asset1 <= toFund1 && asset2 <= toFund2);
    +
    +            if (!asset1.native() && !asset2.native())
    +            {
    +                fund(env, gw, {alice, carol}, {toFund1, toFund2}, Fund::All);
    +            }
    +            else if (asset1.native())
    +            {
    +                fund(env, gw, {alice, carol}, toFund1, {toFund2}, Fund::All);
    +            }
    +            else if (asset2.native())
    +            {
    +                fund(env, gw, {alice, carol}, toFund2, {toFund1}, Fund::All);
    +            }
    +
    +            AMM const ammAlice(env, alice, asset1, asset2, CreateArg{.log = false, .tfee = 0});
    +
    +            Account const owner{"owner"};
    +            env.fund(XRP(1000000), owner);
    +
    +            Vault const vault{env};
    +            auto [tx, k] = vault.create({.owner = owner, .asset = ammAlice.lptIssue()});
    +            env(tx, Ter{tecWRONG_ASSET});
    +            env.close();
    +        }
    +    }
    +
    +    void
    +    testCreateFailMPT()
    +    {
    +        using namespace test::jtx;
    +
    +        auto testCase = [this](
    +                            std::function test) {
    +            Env env{*this, testableAmendments()};
    +            Account const issuer{"issuer"};
    +            Account const owner{"owner"};
    +            Account const depositor{"depositor"};
    +            env.fund(XRP(1000), issuer, owner, depositor);
    +            env.close();
    +            Vault vault{env};
    +            MPTTester mptt{env, issuer, kMptInitNoFund};
    +            // Locked because that is the default flag.
    +            mptt.create();
    +            Asset const asset = mptt.issuanceID();
    +
    +            test(env, issuer, owner, depositor, asset, vault);
    +        };
    +
    +        testCase([this](
    +                     Env& env,
    +                     Account const& issuer,
    +                     Account const& owner,
    +                     Account const& depositor,
    +                     Asset const& asset,
    +                     Vault& vault) {
    +            testcase("MPT no authorization");
    +            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +            env(tx, Ter(tecNO_AUTH));
    +        });
    +
    +        testCase([this](
    +                     Env& env,
    +                     Account const& issuer,
    +                     Account const& owner,
    +                     Account const& depositor,
    +                     Asset const& asset,
    +                     Vault& vault) {
    +            testcase("MPT cannot set Scale=0");
    +            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +            tx[sfScale] = 0;
    +            env(tx, Ter{temMALFORMED});
    +        });
    +
    +        testCase([this](
    +                     Env& env,
    +                     Account const& issuer,
    +                     Account const& owner,
    +                     Account const& depositor,
    +                     Asset const& asset,
    +                     Vault& vault) {
    +            testcase("MPT cannot set Scale=1");
    +            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
    +            tx[sfScale] = 1;
    +            env(tx, Ter{temMALFORMED});
    +        });
    +    }
    +
    +    void
    +    testVaultDeleteMemoData()
    +    {
    +        using namespace test::jtx;
    +
    +        Env env{*this};
    +
    +        Account const owner{"owner"};
    +        env.fund(XRP(1'000'000), owner);
    +        env.close();
    +
    +        Vault const vault{env};
    +
    +        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
    +        // Transaction fails if the data field is provided
    +        {
    +            testcase("VaultDelete memo data featureLendingProtocolV1_1 disabled");
    +            env.disableFeature(featureLendingProtocolV1_1);
    +            delTx[sfMemoData] = strHex(std::string(kMaxDataPayloadLength, 'A'));
    +            env(delTx, Ter(temDISABLED));
    +            env.enableFeature(featureLendingProtocolV1_1);
    +            env.close();
    +        }
    +
    +        // Transaction fails if the data field is too large
    +        {
    +            testcase("VaultDelete memo data featureLendingProtocolV1_1 enabled data too large");
    +            delTx[sfMemoData] = strHex(std::string(kMaxDataPayloadLength + 1, 'A'));
    +            env(delTx, Ter(temMALFORMED));
    +            env.close();
    +        }
    +
    +        // Transaction fails if the data field is set, but is empty
    +        {
    +            testcase("VaultDelete memo data featureLendingProtocolV1_1 enabled data empty");
    +            delTx[sfMemoData] = strHex(std::string());
    +            env(delTx, Ter(temMALFORMED));
    +            env.close();
    +        }
    +
    +        {
    +            testcase("VaultDelete memo data featureLendingProtocolV1_1 enabled no vault");
    +            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});
    +            delTx[sfMemoData] = strHex(std::string(kMaxDataPayloadLength, 'A'));
    +            env(delTx, Ter(tecNO_ENTRY));
    +            env.close();
    +        }
    +
    +        {
    +            testcase("VaultDelete memo data featureLendingProtocolV1_1 enabled data valid");
    +            PrettyAsset const xrpAsset = xrpIssue();
    +            auto const [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset});
    +            env(tx, Ter(tesSUCCESS));
    +            env.close();
    +            // Recreate the transaction as the vault keylet changed
    +            auto delTx = vault.del({.owner = owner, .id = keylet.key});
    +            delTx[sfMemoData] = strHex(std::string(kMaxDataPayloadLength, 'A'));
    +            env(delTx, Ter(tesSUCCESS));
    +            env.close();
    +        }
    +    }
    +
    +    void
    +    testVaultCreateLEVersion()
    +    {
    +        using namespace test::jtx;
    +
    +        Account const owner{"owner"};
    +        PrettyAsset const xrpAsset = xrpIssue();
    +
    +        {
    +            testcase("VaultCreate LEVersion: featureLendingProtocolV1_1 disabled, field absent");
    +            Env env{*this};
    +            env.disableFeature(featureLendingProtocolV1_1);
    +            env.fund(XRP(1'000'000), owner);
    +            env.close();
    +
    +            Vault const vault{env};
    +            auto const [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset});
    +            env(tx, Ter(tesSUCCESS));
    +            env.close();
    +
    +            auto const sleVault = env.le(keylet);
    +            BEAST_EXPECT(sleVault);
    +            BEAST_EXPECT(!sleVault->isFieldPresent(sfLEVersion));
    +        }
    +
    +        {
    +            testcase(
    +                "VaultCreate LEVersion: featureLendingProtocolV1_1 enabled, LEVersion == "
    +                "VaultVersion::CashBasis");
    +            Env env{*this};
    +            env.fund(XRP(1'000'000), owner);
    +            env.close();
    +
    +            Vault const vault{env};
    +            auto const [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset});
    +            env(tx, Ter(tesSUCCESS));
    +            env.close();
    +
    +            auto const sleVault = env.le(keylet);
    +            BEAST_EXPECT(sleVault);
    +            BEAST_EXPECT(sleVault->isFieldPresent(sfLEVersion));
    +            BEAST_EXPECT(sleVault->at(sfLEVersion) == std::to_underlying(VaultVersion::CashBasis));
    +        }
    +
    +        {
    +            testcase("VaultCreate rejects LEVersion set in the transaction");
    +            Env env{*this};
    +            env.fund(XRP(1'000'000), owner);
    +            env.close();
    +
    +            Vault const vault{env};
    +            auto [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset});
    +            tx[sfLEVersion] = 2;
    +            env(tx, Ter(temMALFORMED));
    +            env.close();
    +
    +            BEAST_EXPECT(!env.le(keylet));
    +        }
    +
    +        {
    +            testcase("VaultSet rejects LEVersion set in the transaction");
    +            Env env{*this};
    +            env.fund(XRP(1'000'000), owner);
    +            env.close();
    +
    +            Vault const vault{env};
    +            auto const [createTx, keylet] = vault.create({.owner = owner, .asset = xrpAsset});
    +            env(createTx, Ter(tesSUCCESS));
    +            env.close();
    +
    +            auto setTx = vault.set({.owner = owner, .id = keylet.key});
    +            setTx[sfLEVersion] = 2;
    +            env(setTx, Ter(temMALFORMED));
    +            env.close();
    +        }
    +    }
    +
    +public:
    +    void
    +    run() override
    +    {
    +        testPreflight();
    +        testCreateFailXRP();
    +        testCreateFailIOU();
    +        testCreateFailMPT();
    +        testVaultDeleteMemoData();
    +        testVaultCreateLEVersion();
    +    }
    +};
    +
    +BEAST_DEFINE_TESTSUITE(VaultValidation, app, xrpl);
    +
    +}  // namespace xrpl
    
    From 666e77b22c0c973773078702595e0a15b59c5455 Mon Sep 17 00:00:00 2001
    From: Kassaking7 <96991820+Kassaking7@users.noreply.github.com>
    Date: Tue, 18 Aug 2026 21:08:02 +0000
    Subject: [PATCH 092/102] fix: Add ValidPermissionedDEX invariant track for
     fully consumed offer (#6736)
    
    ---
     .../invariants/PermissionedDEXInvariant.cpp   |  8 +-
     src/test/app/Invariants_test.cpp              | 86 +++++++++++++++++++
     2 files changed, 93 insertions(+), 1 deletion(-)
    
    diff --git a/src/libxrpl/tx/invariants/PermissionedDEXInvariant.cpp b/src/libxrpl/tx/invariants/PermissionedDEXInvariant.cpp
    index 44f623f284..5c53552a3f 100644
    --- a/src/libxrpl/tx/invariants/PermissionedDEXInvariant.cpp
    +++ b/src/libxrpl/tx/invariants/PermissionedDEXInvariant.cpp
    @@ -7,6 +7,7 @@
     #include 
     #include 
     #include 
    +#include 
     #include 
     #include 
     #include 
    @@ -18,8 +19,13 @@
     namespace xrpl {
     
     void
    -ValidPermissionedDEX::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after)
    +ValidPermissionedDEX::visitEntry(bool isDelete, SLE::const_ref, SLE::const_ref after)
     {
    +    // Post-fixCleanup3_4_0: skip when after is null (defensive).
    +    // Pre-amendment: original after-only path via the `if (after && ...)` checks below.
    +    if (isFeatureEnabled(fixCleanup3_4_0) && !after)
    +        return;
    +
         auto trackDomain = [this, isDelete](uint256 const& domain) {
             domainsOld_.insert(domain);
             if (!isDelete)
    diff --git a/src/test/app/Invariants_test.cpp b/src/test/app/Invariants_test.cpp
    index 6878b2b5d0..70eaadbe17 100644
    --- a/src/test/app/Invariants_test.cpp
    +++ b/src/test/app/Invariants_test.cpp
    @@ -56,6 +56,7 @@
     #include 
     #include 
     #include 
    +#include 
     #include 
     
     #include 
    @@ -2247,6 +2248,90 @@ class Invariants_test : public beast::unit_test::Suite
             }
         }
     
    +    void
    +    testPermissionedDEXDeletedOfferFallback()
    +    {
    +        using namespace test::jtx;
    +
    +        testcase << "PermissionedDEX null after";
    +
    +        // Tx is OfferCreate on pd2. Tracking pd1 fails the invariant iff that
    +        // domain lands in the set finalize consults. after == null is never
    +        // tracked (pre-340: after-only; post-340: early return) — same result,
    +        // both sides are coverage/regression that we do not fall back to before.
    +        auto const check = [this](
    +                               FeatureBitset features,
    +                               bool const afterIsNull,
    +                               bool const isDelete,
    +                               bool const expectInvariantFailure) {
    +            Env env(*this, features);
    +
    +            Account const a1{"A1"};
    +            Account const a2{"A2"};
    +            env.fund(XRP(1000), a1, a2);
    +            env.close();
    +
    +            [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env, a1, a2);
    +            [[maybe_unused]] auto [seq2, pd2] = createPermissionedDomainEnv(env, a1, a2);
    +            env.close();
    +
    +            auto sleOffer =
    +                std::make_shared(keylet::offer(a2.id(), SeqProxy::rawSequence(10)));
    +            sleOffer->setAccountID(sfAccount, a2);
    +            sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10));
    +            sleOffer->setFieldAmount(sfTakerGets, XRP(1));
    +            sleOffer->setFieldH256(sfDomainID, pd1);
    +
    +            CurrentTransactionRulesGuard const rulesGuard(env.current()->rules());
    +
    +            ValidPermissionedDEX invariant;
    +            if (afterIsNull)
    +            {
    +                // Defensive path: after is null. Must not fall back to before.
    +                invariant.visitEntry(isDelete, sleOffer, nullptr);
    +            }
    +            else
    +            {
    +                // Normal / real-erase path: after is the offer on pd1.
    +                invariant.visitEntry(isDelete, nullptr, sleOffer);
    +            }
    +
    +            STTx const tx{ttOFFER_CREATE, [&pd2, &a1](STObject& tx) {
    +                              tx.setFieldH256(sfDomainID, pd2);
    +                              tx.setFieldAmount(sfTakerPays, a1["USD"](10));
    +                              tx.setFieldAmount(sfTakerGets, XRP(1));
    +                          }};
    +
    +            test::StreamSink sink{beast::Severity::Warning};
    +            beast::Journal const jlog{sink};
    +            bool const passed =
    +                invariant.finalize(tx, tesSUCCESS, XRPAmount{}, *env.current(), jlog);
    +            BEAST_EXPECT(passed != expectInvariantFailure);
    +            if (expectInvariantFailure)
    +            {
    +                BEAST_EXPECT(sink.messages().str().contains("transaction consumed wrong domains"));
    +            }
    +            else
    +            {
    +                BEAST_EXPECT(sink.messages().str().empty());
    +            }
    +        };
    +
    +        auto const pre = defaultAmendments() - fixCleanup3_4_0;
    +        auto const post = defaultAmendments() | fixCleanup3_4_0;
    +
    +        // after == null: not tracked
    +        check(pre, true, true, false);
    +        check(post, true, true, false);
    +
    +        // after == offer on pd1
    +        // pre-340: domainsOld_ (delete still inserted) → fail
    +        check(pre, false, true, true);
    +        // post-340: isDelete → only domainsOld_ → pass; !isDelete → domains_ → fail
    +        check(post, false, true, false);
    +        check(post, false, false, true);
    +    }
    +
         void
         testBookDirectoryExchangeRate()
         {
    @@ -6571,6 +6656,7 @@ public:
             testPermissionedDomainInvariants(defaultAmendments() - fixCleanup3_1_3);
             testPermissionedDEX(defaultAmendments() | fixCleanup3_1_3);
             testPermissionedDEX(defaultAmendments() - fixCleanup3_1_3);
    +        testPermissionedDEXDeletedOfferFallback();
             testBookDirectoryExchangeRate();
             testNoModifiedUnmodifiableFields();
             testValidPseudoAccounts();
    
    From 7442ff2dec1adea36c27245b558c3e05a1d06fd2 Mon Sep 17 00:00:00 2001
    From: Olek <115580134+oleks-rip@users.noreply.github.com>
    Date: Tue, 18 Aug 2026 22:39:32 +0000
    Subject: [PATCH 093/102] fix: Enable reserve checking on ending sponsorship
     (#8044)
    
    ---
     .../sponsor/SponsorshipTransfer.cpp           | 22 +++++++--
     src/test/app/Sponsor_test.cpp                 | 48 ++++++++++++-------
     2 files changed, 50 insertions(+), 20 deletions(-)
    
    diff --git a/src/libxrpl/tx/transactors/sponsor/SponsorshipTransfer.cpp b/src/libxrpl/tx/transactors/sponsor/SponsorshipTransfer.cpp
    index 0e036649fd..c3131714f8 100644
    --- a/src/libxrpl/tx/transactors/sponsor/SponsorshipTransfer.cpp
    +++ b/src/libxrpl/tx/transactors/sponsor/SponsorshipTransfer.cpp
    @@ -8,6 +8,7 @@
     #include 
     #include 
     #include 
    +#include 
     #include 
     #include 
     #include 
    @@ -412,9 +413,24 @@ SponsorshipTransfer::doApply()
                 if (!oldSponsorSle)
                     return tefINTERNAL;  // LCOV_EXCL_LINE
     
    -            // The owner reclaims the reserve burden when the object is no longer sponsored.
    -            // We do not check the sponsee's reserve here (via `checkReserve`) so that a sponsor can
    -            // always end a sponsorship, even if the sponsee lacks sufficient reserve.
    +            // The owner reclaims the reserve burden when the object is no longer
    +            // sponsored, so it must be able to hold that reserve on its own once the
    +            // sponsorship is removed. This mirrors the account-level End check below,
    +            // keeping the behavior consistent across accounts and objects: a
    +            // sponsorship can only be ended if the sponsee self-funds, another sponsor
    +            // steps in (Reassign), or the object/account is deleted.
    +            if (view().rules().enabled(fixCleanup3_4_0))
    +            {
    +                if (auto const ter = checkReserve(
    +                        ctx_.getApplyViewContext(),
    +                        sponseeSle,
    +                        balanceBeforeFee(sponseeSle),
    +                        SLE::pointer(),
    +                        {.ownerCountDelta = ownerCountDelta},
    +                        ctx_.journal);
    +                    !isTesSuccess(ter))
    +                    return ter;
    +            }
     
                 // Decrement sponsored count
                 if (auto const ter = decrementSponsorCount(
    diff --git a/src/test/app/Sponsor_test.cpp b/src/test/app/Sponsor_test.cpp
    index bcd31bc6a0..a1a9f80a11 100644
    --- a/src/test/app/Sponsor_test.cpp
    +++ b/src/test/app/Sponsor_test.cpp
    @@ -1073,14 +1073,17 @@ public:
         }
     
         void
    -    testTransferSponsor()
    +    testTransferSponsor(FeatureBitset features)
         {
    -        testcase("Transfer Sponsor");
    +        testcase(
    +            std::string("Transfer Sponsor ") +
    +            (features[fixCleanup3_4_0] ? "(fixCleanup3_4_0 enabled)"
    +                                       : "(fixCleanup3_4_0 disabled)"));
             using namespace test::jtx;
     
             // Verify preflight checks
             {
    -            Env env{*this, testableAmendments()};
    +            Env env{*this, features};
                 Account const alice("alice");
                 Account const bob("bob");
                 Account const sponsor("sponsor");
    @@ -1164,7 +1167,7 @@ public:
     
             {
                 // Invalid SponsorshipEnd permission (sponsor object/sponsor account)
    -            Env env{*this, testableAmendments()};
    +            Env env{*this, features};
                 Account const alice("alice");
                 Account const bob("bob");
                 Account const charlie("charlie");
    @@ -1209,7 +1212,7 @@ public:
     
             {
                 // sponsor account
    -            Env env{*this, testableAmendments()};
    +            Env env{*this, features};
                 Account const alice("alice");
                 Account const bob("bob");
                 Account const sponsor1("sponsor1");
    @@ -1340,7 +1343,7 @@ public:
             }
             {
                 // dissolve account sponsorship from sponsor
    -            Env env{*this, testableAmendments()};
    +            Env env{*this, features};
                 Account const alice("alice");
                 Account const bob("bob");
                 Account const sponsor("sponsor");
    @@ -1364,7 +1367,7 @@ public:
     
             {
                 // sponsor object (co-signing)
    -            Env env{*this, testableAmendments()};
    +            Env env{*this, features};
                 Account const alice("alice");
                 Account const bob("bob");
                 Account const sponsor1("sponsor1");
    @@ -1473,10 +1476,20 @@ public:
                 BEAST_EXPECT(sle2->isFieldPresent(sfSponsor));
                 BEAST_EXPECT(sle2->getAccountID(sfSponsor) == sponsor2.id());
     
    -            // dissolve sponsor: ending an object sponsorship succeeds even
    -            // when the sponsee lacks sufficient reserve to reclaim the object.
    +            // dissolve sponsor: ending an object sponsorship now (fixCleanup3_4_0) requires the
    +            // sponsee to be able to self-fund the object's reserve.
                 adjustAccountXRPBalance(env, alice, reserve(env, 1) - drops(1));
     
    +            if (features[fixCleanup3_4_0])
    +            {
    +                // Under-funded: End is rejected until alice can self-fund.
    +                env(sponsor::transfer(alice, tfSponsorshipEnd, checkId),
    +                    Ter(tecINSUFFICIENT_RESERVE));
    +                env.close();
    +
    +                adjustAccountXRPBalance(env, alice, reserve(env, 1));
    +            }
    +
                 env(sponsor::transfer(alice, tfSponsorshipEnd, checkId));
                 env.close();
     
    @@ -1509,7 +1522,7 @@ public:
             }
             {
                 // sponsor object (pre-funded + no ltSponsorship entry)
    -            Env env{*this, testableAmendments()};
    +            Env env{*this, features};
                 Account const alice("alice");
                 Account const bob("bob");
                 Account const sponsor1("sponsor1");
    @@ -1543,7 +1556,7 @@ public:
             }
             {
                 // sponsor object (pre-funded)
    -            Env env{*this, testableAmendments()};
    +            Env env{*this, features};
                 Account const alice("alice");
                 Account const bob("bob");
                 Account const sponsor1("sponsor1");
    @@ -1646,7 +1659,7 @@ public:
     
             {
                 // Dissolve object sponsorship from sponsor(no-ltSponsorship)
    -            Env env{*this, testableAmendments()};
    +            Env env{*this, features};
                 Account const alice("alice");
                 Account const bob("bob");
                 Account const sponsor("sponsor");
    @@ -1686,7 +1699,7 @@ public:
     
             {
                 // Dissolve object sponsorship from sponsor (with ltSponsorship)
    -            Env env{*this, testableAmendments()};
    +            Env env{*this, features};
                 Account const alice("alice");
                 Account const bob("bob");
                 Account const sponsor("sponsor");
    @@ -1744,7 +1757,7 @@ public:
     
                 for (bool const isIssuerHigh : {false, true})
                 {
    -                Env env{*this, testableAmendments()};
    +                Env env{*this, features};
                     env.fund(XRP(10000), alice, bob, sponsor);
                     env.close();
     
    @@ -1788,7 +1801,7 @@ public:
     
             {
                 // invalid transfer
    -            Env env{*this, testableAmendments()};
    +            Env env{*this, features};
                 Account const alice("alice");
                 Account const bob("bob");
                 Account const sponsor("sponsor");
    @@ -1825,7 +1838,7 @@ public:
             {
                 // existing owner objects that are outside the v1 SponsorshipTransfer
                 // object allow-list
    -            Env env{*this, testableAmendments()};
    +            Env env{*this, features};
                 Account const alice("alice");
                 Account const sponsor("sponsor");
                 env.fund(XRP(10000), alice, sponsor);
    @@ -5671,7 +5684,8 @@ protected:
             testPreFundAndCosign();
             testSponsoredFreeTierReserve();
     
    -        testTransferSponsor();
    +        testTransferSponsor(jtx::testableAmendments());
    +        testTransferSponsor(jtx::testableAmendments() - fixCleanup3_4_0);
             testLegacySignerListReserve();
             testSponsorFee();
             testSponsorAccount();
    
    From 4113b105a57483573cb3df165f0ee5d5e0728458 Mon Sep 17 00:00:00 2001
    From: Ayaz Salikhov 
    Date: Tue, 18 Aug 2026 23:34:16 +0000
    Subject: [PATCH 094/102] build: Run nix macos builds in CI; deny nix store
     references (#8023)
    
    ---
     .cspell.config.yaml                           |   7 ++
     .github/actions/setup-nix-env/action.yml      |  69 +++++++++++
     .github/scripts/strategy-matrix/generate.py   |  10 +-
     .github/scripts/strategy-matrix/macos.json    |  13 ++
     .github/workflows/on-pr.yml                   |   5 +
     .github/workflows/on-trigger.yml              |   5 +
     .../workflows/reusable-build-test-config.yml  |  31 +++++
     .github/workflows/reusable-build-test.yml     |   1 +
     .github/workflows/upload-conan-deps.yml       |  11 ++
     bin/check-nix-store-refs.sh                   | 111 ++++++++++++++++++
     docs/build/nix.md                             |  78 +++++++++++-
     docs/build/nix_troubleshooting.md             |  88 ++++++++++++++
     nix/ci-env.nix                                |  74 ++++--------
     nix/darwin.nix                                |  80 +++++++++++++
     nix/devshell.nix                              |  29 +++--
     nix/docker/Dockerfile                         |   2 +-
     nix/{compilers.nix => linux.nix}              |  55 +++++++--
     17 files changed, 591 insertions(+), 78 deletions(-)
     create mode 100644 .github/actions/setup-nix-env/action.yml
     create mode 100755 bin/check-nix-store-refs.sh
     create mode 100644 nix/darwin.nix
     rename nix/{compilers.nix => linux.nix} (75%)
    
    diff --git a/.cspell.config.yaml b/.cspell.config.yaml
    index e194ee21f8..aa64a318fd 100644
    --- a/.cspell.config.yaml
    +++ b/.cspell.config.yaml
    @@ -69,6 +69,7 @@ words:
       - Buildx
       - canonicality
       - canonicalised
    +  - cctools
       - changespq
       - checkme
       - choco
    @@ -110,6 +111,7 @@ words:
       - disablerepo
       - distro
       - doxyfile
    +  - dsymutil
       - dxrpl
       - elgamal
       - enabled
    @@ -168,6 +170,7 @@ words:
       - LOCALGOOD
       - logwstream
       - Lombrozo
    +  - lresolv
       - lseq
       - lsmf
       - ltype
    @@ -221,6 +224,7 @@ words:
       - Nyffenegger
       - onlatest
       - ostr
    +  - otool
       - oxalica
       - pargs
       - partitioner
    @@ -257,6 +261,8 @@ words:
       - rerandomized
       - rerandomizes
       - rerere
    +  - retargeted
    +  - retargets
       - retriable
       - RIPD
       - ripdtop
    @@ -367,6 +373,7 @@ words:
       - wthread
       - xbridge
       - xchain
    +  - xcrun
       - ximinez
       - XMACRO
       - xored
    diff --git a/.github/actions/setup-nix-env/action.yml b/.github/actions/setup-nix-env/action.yml
    new file mode 100644
    index 0000000000..a95053e536
    --- /dev/null
    +++ b/.github/actions/setup-nix-env/action.yml
    @@ -0,0 +1,69 @@
    +name: Setup Nix environment
    +description: "Build the flake's CI environment and put its tools on PATH."
    +
    +# The environment from nix/ci-env.nix, the same one the Linux CI images bake in
    +# (see nix/docker). Exported onto PATH rather than entered with `nix develop`:
    +# the composite actions below run plain `bash` and would escape a dev shell.
    +
    +runs:
    +  using: composite
    +
    +  steps:
    +    - name: Build the CI environment
    +      id: build
    +      shell: bash
    +      env:
    +        # --out-link doubles as a GC root for the length of the job.
    +        OUT_LINK: ${{ runner.temp }}/xrpld-ci-env
    +      run: |
    +        # --extra-experimental-features: flakes may not be on in the runner's nix.conf.
    +        nix --extra-experimental-features "nix-command flakes" \
    +            build .#default --out-link "${OUT_LINK}" --print-build-logs
    +        echo "path=$(readlink -f "${OUT_LINK}")" >>"${GITHUB_OUTPUT}"
    +
    +    - name: Export the environment
    +      shell: bash
    +      env:
    +        ENV_PATH: ${{ steps.build.outputs.path }}
    +      run: |
    +        echo "${ENV_PATH}/bin" >>"${GITHUB_PATH}"
    +
    +        # Already KEY=VALUE per line. See `darwinEnv` in nix/ci-env.nix.
    +        ENV_FILE="${ENV_PATH}/share/xrpld-ci-env/env"
    +        if [ -f "${ENV_FILE}" ]; then
    +            cat "${ENV_FILE}" >>"${GITHUB_ENV}"
    +        fi
    +
    +        # XrplSanity.cmake otherwise rejects a Nix compiler as one that leaked.
    +        echo "XRPL_DEVSHELL=ci-env" >>"${GITHUB_ENV}"
    +
    +        # Unlike the Linux nix images, macOS needs no SSL_CERT_FILE: it has its
    +        # own trust store, and pinning would break TLS to hosts relying on it.
    +
    +        # Workspace-local, so `cleanup-workspace` clears it, but not the
    +        # `.conan2` prepare-runner hands the system toolchain: that Conan is a
    +        # different version, and the two would migrate each other's cache.
    +        echo "CONAN_HOME=${{ github.workspace }}/.conan2-nix" >>"${GITHUB_ENV}"
    +
    +    # Config, profiles and remote, exactly as the dev shell sets them up on
    +    # entry; the `setup-conan` action is skipped for this toolchain.
    +    - name: Setup Conan
    +      shell: bash
    +      run: ./conan/init.sh
    +
    +    # `Check tools` runs later but swallows failures; a bad export would just
    +    # build with the system toolchain.
    +    - name: Verify the toolchain resolves into the Nix store
    +      shell: bash
    +      run: |
    +        for tool in clang clang++ cmake ninja conan; do
    +            path="$(command -v "${tool}" || true)"
    +            echo "${tool} -> ${path:-}"
    +            case "${path}" in
    +                /nix/store/*) ;;
    +                *)
    +                    echo "::error::${tool} does not resolve into the Nix store"
    +                    exit 1
    +                    ;;
    +            esac
    +        done
    diff --git a/.github/scripts/strategy-matrix/generate.py b/.github/scripts/strategy-matrix/generate.py
    index 47c7593892..83f3c67e7f 100755
    --- a/.github/scripts/strategy-matrix/generate.py
    +++ b/.github/scripts/strategy-matrix/generate.py
    @@ -88,6 +88,9 @@ class PlatformConfig:
         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 = ""
    +    # "" is the runner's system compiler, "nix" the flake's CI environment.
    +    # macOS only: Linux always builds in a Nix image, Windows has no Nix.
    +    toolchain: str = ""
     
         def __post_init__(self) -> None:
             if isinstance(self.build_type, str):
    @@ -137,6 +140,7 @@ class MatrixEntry:
         sanitizers: str
         image: str = ""  # container image; empty for macOS/Windows (runs natively)
         compiler: str = ""  # compiler name ("gcc" or "clang"); empty for macOS/Windows
    +    toolchain: str = ""  # "nix" for the flake's CI environment; see PlatformConfig
     
     
     @dataclasses.dataclass
    @@ -253,9 +257,12 @@ def expand_platform_matrix(pf: PlatformFile, minimal: bool) -> list[MatrixEntry]
             if minimal and not cfg.minimal:
                 continue
             for build_type in cfg.build_type:
    +            name = f"{platform_name}-{arch}-{build_type.lower()}"
    +            if cfg.toolchain:
    +                name += f"-{cfg.toolchain}"
                 entries.append(
                     MatrixEntry(
    -                    config_name=f"{platform_name}-{arch}-{build_type.lower()}",
    +                    config_name=name,
                         cmake_args=get_cmake_args(build_type, cfg.extra_cmake_args),
                         cmake_target="install" if is_windows else "all",
                         build_only=cfg.build_only,
    @@ -263,6 +270,7 @@ def expand_platform_matrix(pf: PlatformFile, minimal: bool) -> list[MatrixEntry]
                         build_type=build_type,
                         architecture=Architecture(platform=pf.platform, runner=pf.runner),
                         sanitizers="",
    +                    toolchain=cfg.toolchain,
                     )
                 )
         return entries
    diff --git a/.github/scripts/strategy-matrix/macos.json b/.github/scripts/strategy-matrix/macos.json
    index 98e0f13141..554031009c 100644
    --- a/.github/scripts/strategy-matrix/macos.json
    +++ b/.github/scripts/strategy-matrix/macos.json
    @@ -12,6 +12,19 @@
           "extra_cmake_args": "-DCMAKE_POLICY_VERSION_MINIMUM=3.5",
           "build_only": true,
           "minimal": false
    +    },
    +    {
    +      "build_type": "Release",
    +      "extra_cmake_args": "-DCMAKE_POLICY_VERSION_MINIMUM=3.5",
    +      "toolchain": "nix",
    +      "minimal": false
    +    },
    +    {
    +      "build_type": "Debug",
    +      "extra_cmake_args": "-DCMAKE_POLICY_VERSION_MINIMUM=3.5",
    +      "toolchain": "nix",
    +      "build_only": true,
    +      "minimal": false
         }
       ]
     }
    diff --git a/.github/workflows/on-pr.yml b/.github/workflows/on-pr.yml
    index f14256b9e8..a8209ac16f 100644
    --- a/.github/workflows/on-pr.yml
    +++ b/.github/workflows/on-pr.yml
    @@ -79,6 +79,7 @@ jobs:
                 .github/actions/build-deps/**
                 .github/actions/release-info/**
                 .github/actions/setup-conan/**
    +            .github/actions/setup-nix-env/**
                 .github/scripts/strategy-matrix/**
                 .github/workflows/reusable-build-test-config.yml
                 .github/workflows/reusable-build-test.yml
    @@ -90,6 +91,7 @@ jobs:
                 .github/workflows/reusable-upload-recipe.yml
                 .clang-tidy
                 .codecov.yml
    +            bin/check-nix-store-refs.sh
                 bin/check-tools.sh
                 bin/default-loader-path.sh
                 cfg/**
    @@ -102,6 +104,9 @@ jobs:
                 CMakeLists.txt
                 conanfile.py
                 conan.lock
    +            flake.lock
    +            flake.nix
    +            nix/**
                 LICENSE.md
                 package/**
                 README.md
    diff --git a/.github/workflows/on-trigger.yml b/.github/workflows/on-trigger.yml
    index dcd14b7933..0d679318a9 100644
    --- a/.github/workflows/on-trigger.yml
    +++ b/.github/workflows/on-trigger.yml
    @@ -17,6 +17,7 @@ on:
           - ".github/actions/build-deps/**"
           - ".github/actions/release-info/**"
           - ".github/actions/setup-conan/**"
    +      - ".github/actions/setup-nix-env/**"
           - ".github/scripts/strategy-matrix/**"
           - ".github/workflows/reusable-build-test-config.yml"
           - ".github/workflows/reusable-build-test.yml"
    @@ -28,6 +29,7 @@ on:
           - ".github/workflows/reusable-upload-recipe.yml"
           - ".clang-tidy"
           - ".codecov.yml"
    +      - "bin/check-nix-store-refs.sh"
           - "bin/check-tools.sh"
           - "bin/default-loader-path.sh"
           - "cfg/**"
    @@ -40,6 +42,9 @@ on:
           - "CMakeLists.txt"
           - "conanfile.py"
           - "conan.lock"
    +      - "flake.lock"
    +      - "flake.nix"
    +      - "nix/**"
           - "LICENSE.md"
           - "package/**"
           - "README.md"
    diff --git a/.github/workflows/reusable-build-test-config.yml b/.github/workflows/reusable-build-test-config.yml
    index 7989d2c7f6..94d0706e70 100644
    --- a/.github/workflows/reusable-build-test-config.yml
    +++ b/.github/workflows/reusable-build-test-config.yml
    @@ -69,6 +69,12 @@ on:
             type: string
             default: ""
     
    +      toolchain:
    +        description: 'Where the toolchain comes from ("nix" to build the flake CI environment on the runner, empty for the system one). macOS only: Linux always builds in a Nix image, and Nix has no Windows support.'
    +        required: false
    +        type: string
    +        default: ""
    +
         secrets:
           CODECOV_TOKEN:
             description: "The Codecov token to use for uploading coverage reports."
    @@ -127,6 +133,11 @@ jobs:
             with:
               enable_ccache: ${{ inputs.ccache_enabled }}
     
    +      # Before any step that uses a build tool, composite actions included.
    +      - name: Setup Nix environment
    +        if: ${{ inputs.toolchain == 'nix' }}
    +        uses: ./.github/actions/setup-nix-env
    +
           - name: Set ccache log file
             if: ${{ inputs.ccache_enabled && runner.debug == '1' }}
             run: echo "CCACHE_LOGFILE=${{ runner.temp }}/ccache.log" >>"${GITHUB_ENV}"
    @@ -151,7 +162,9 @@ jobs:
             with:
               compiler: ${{ inputs.compiler }}
     
    +      # `setup-nix-env` already did this for the Nix toolchain.
           - name: Setup Conan
    +        if: ${{ inputs.toolchain != 'nix' }}
             env:
               SANITIZERS: ${{ inputs.sanitizers }}
             uses: ./.github/actions/setup-conan
    @@ -215,6 +228,24 @@ jobs:
                   --target "${CMAKE_TARGET}" \
                   2>&1 | tee "${GITHUB_WORKSPACE}/build.log"
     
    +      # Nothing may reference the store, so whole trees are checked - the Conan
    +      # cache included, since what it holds is what gets uploaded and reused.
    +      - name: Check the build output for Nix store references (Nix toolchain)
    +        if: ${{ inputs.toolchain == 'nix' }}
    +        run: ./bin/check-nix-store-refs.sh "${BUILD_DIR}"
    +
    +      - name: Check the Conan cache for Nix store references (Nix toolchain)
    +        if: ${{ inputs.toolchain == 'nix' }}
    +        run: ./bin/check-nix-store-refs.sh "${CONAN_HOME}"
    +
    +      # Only what PatchNixBinary.cmake retargets: the toolchain in the Linux
    +      # images always references the store. Same condition it uses.
    +      - name: Check for Nix store references (Linux)
    +        if: ${{ runner.os == 'Linux' && env.SANITIZERS_ENABLED == 'false' }}
    +        run: |
    +          ./bin/check-nix-store-refs.sh "${BUILD_DIR}/xrpld"
    +          ./bin/check-nix-store-refs.sh "${BUILD_DIR}/xrpl_tests"
    +
           - name: Show ccache statistics
             if: ${{ inputs.ccache_enabled }}
             run: |
    diff --git a/.github/workflows/reusable-build-test.yml b/.github/workflows/reusable-build-test.yml
    index 5368274a16..7ea106f438 100644
    --- a/.github/workflows/reusable-build-test.yml
    +++ b/.github/workflows/reusable-build-test.yml
    @@ -51,5 +51,6 @@ jobs:
           config_name: ${{ matrix.config_name }}
           sanitizers: ${{ matrix.sanitizers }}
           compiler: ${{ matrix.compiler || '' }}
    +      toolchain: ${{ matrix.toolchain || '' }}
         secrets:
           CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
    diff --git a/.github/workflows/upload-conan-deps.yml b/.github/workflows/upload-conan-deps.yml
    index eb58650bdf..65a3f9c5b6 100644
    --- a/.github/workflows/upload-conan-deps.yml
    +++ b/.github/workflows/upload-conan-deps.yml
    @@ -72,6 +72,11 @@ jobs:
             with:
               enable_ccache: false
     
    +      # Before any step that uses a build tool, composite actions included.
    +      - name: Setup Nix environment
    +        if: ${{ matrix.toolchain == 'nix' }}
    +        uses: ./.github/actions/setup-nix-env
    +
           - name: Print build environment
             uses: XRPLF/actions/print-build-env@59dec886e4afb05a1724443af08baccbc045b574
     
    @@ -87,7 +92,9 @@ jobs:
             with:
               compiler: ${{ matrix.compiler }}
     
    +      # `setup-nix-env` already did this for the Nix toolchain.
           - name: Setup Conan
    +        if: ${{ matrix.toolchain != 'nix' }}
             env:
               SANITIZERS: ${{ matrix.sanitizers }}
             uses: ./.github/actions/setup-conan
    @@ -106,6 +113,10 @@ jobs:
               log_verbosity: ${{ runner.os == 'Windows' && 'quiet' || 'verbose' }}
               sanitizers: ${{ matrix.sanitizers }}
     
    +      - name: Check the Conan cache for Nix store references (Nix toolchain)
    +        if: ${{ matrix.toolchain == 'nix' }}
    +        run: ./bin/check-nix-store-refs.sh "${CONAN_HOME}"
    +
           - name: Log into Conan remote
             if: ${{ github.repository == 'XRPLF/rippled' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }}
             run: conan remote login "${CONAN_REMOTE_NAME}" "${{ secrets.NEXUS_REMOTE_USERNAME }}" --password "${{ secrets.NEXUS_REMOTE_PASSWORD }}"
    diff --git a/bin/check-nix-store-refs.sh b/bin/check-nix-store-refs.sh
    new file mode 100755
    index 0000000000..70413df75e
    --- /dev/null
    +++ b/bin/check-nix-store-refs.sh
    @@ -0,0 +1,111 @@
    +#!/usr/bin/env bash
    +# Fail if a binary under  records a /nix/store path it resolves at run
    +# time. See docs/build/nix.md#prebuilt-packages for why that matters.
    +#
    +#  is a file or a directory. macOS: nothing may reference the store, so
    +# point it at whole trees. Linux: the toolchain always writes the store into
    +# PT_INTERP and RUNPATH, so only at what cmake/PatchNixBinary.cmake retargets.
    +#
    +# Only Mach-O / ELF is inspected. Static archives hold store paths in debug info
    +# alone; the scripts in a Conan cache are all git hook samples and autotools
    +# scratch, 36 false positives to 0 real.
    +#
    +# Usage: bin/check-nix-store-refs.sh 
    +
    +set -euo pipefail
    +
    +if [ "$#" -ne 1 ]; then
    +    echo "usage: $0 " >&2
    +    exit 2
    +fi
    +
    +if [ ! -e "$1" ]; then
    +    echo "$0: no such path: $1" >&2
    +    exit 2
    +fi
    +
    +case "$(uname -s)" in
    +    Darwin)
    +        format=Mach-O
    +        recorded_paths=macho_recorded_paths
    +        tool=otool
    +        ;;
    +    Linux)
    +        format=ELF
    +        recorded_paths=elf_recorded_paths
    +        tool=readelf
    +        ;;
    +    *)
    +        echo "Unsupported OS - skipping the Nix store reference check."
    +        exit 0
    +        ;;
    +esac
    +
    +# `pipefail` would catch this too, but only as a bare nonzero exit.
    +if ! command -v "${tool}" >/dev/null; then
    +    echo "$0: ${tool} not found; cannot inspect binaries" >&2
    +    exit 2
    +fi
    +
    +# Both list what the file records. `ldd` would answer what this machine resolves
    +# now, which is wrong both ways: store paths for a correctly patched binary,
    +# silence for a store RUNPATH that resolves nowhere.
    +
    +# `name` covers LC_ID_DYLIB and LC_LOAD*_DYLIB, `path` covers LC_RPATH.
    +macho_recorded_paths() {
    +    otool -l "$1" | sed -nE 's#^ *(name|path) ([^ ]*).*#\2#p'
    +}
    +
    +# RPATH and RUNPATH are colon-separated.
    +elf_recorded_paths() {
    +    readelf -ldW "$1" |
    +        sed -nE \
    +            -e 's#.*program interpreter: ([^]]*)\].*#\1#p' \
    +            -e 's#.*\((RPATH|RUNPATH|NEEDED)\).*\[([^]]*)\].*#\2#p' |
    +        tr ':' '\n'
    +}
    +
    +checked=0
    +skipped=0
    +leaked=0
    +
    +while IFS= read -r file; do
    +    case "$(file -b "${file}" 2>/dev/null)" in
    +        *"${format}"*) ;;
    +        *)
    +            skipped=$((skipped + 1))
    +            continue
    +            ;;
    +    esac
    +    checked=$((checked + 1))
    +
    +    # Filter after extracting, or a search path starting elsewhere ($ORIGIN)
    +    # hides the rest. `sed` not `grep`: grep calls "no matches" a failure, and
    +    # the `|| true` that would need masks a broken pipeline too.
    +    refs="$("${recorded_paths}" "${file}" | sed -n '\#^/nix/store/#p' | sort -u)"
    +    if [ -n "${refs}" ]; then
    +        leaked=$((leaked + 1))
    +        echo "::error file=${file}::references the Nix store at run time"
    +        echo "${file}"
    +        echo "${refs}" | sed 's/^/    /'
    +    fi
    +done < <(find "$1" -type f \( -perm -u+x -o -name '*.dylib' -o -name '*.so*' \))
    +
    +echo "$1: checked ${checked}, skipped ${skipped}, ${leaked} with Nix store references."
    +
    +if [ "${leaked}" -ne 0 ]; then
    +    cat >&2 <<'EOF'
    +
    +Fixes, in order of preference:
    +  - A Conan package built before this check existed: drop it
    +    (`conan remove '/*'`) and rebuild.
    +  - A binary that should have been retargeted to the system loader: check that
    +    cmake/PatchNixBinary.cmake ran for it.
    +  - Link the macOS system library instead of the Nix one - see
    +    libresolvSystemStub in nix/darwin.nix.
    +  - No system library exists (libstdc++): link it statically.
    +  - None of the above: pin the toolchain into the package ID, following
    +    `user.package:libc_version` in conan/profiles/ci.
    +EOF
    +    exit 1
    +fi
    diff --git a/docs/build/nix.md b/docs/build/nix.md
    index d1e40fcc89..4c082afb28 100644
    --- a/docs/build/nix.md
    +++ b/docs/build/nix.md
    @@ -7,7 +7,7 @@ This guide explains how to use Nix to set up a reproducible development environm
     ## Benefits of Using Nix
     
     - **Reproducible environment**: Everyone gets the same versions of tools and compilers
    -- **Matches CI**: The Linux CI runs in Docker images built from this exact Nix environment
    +- **Matches CI**: The Linux CI runs in Docker images built from this exact Nix environment, and CI builds some macOS configurations in it as well
     - **No system pollution**: Dependencies are isolated and don't affect your system packages
     - **Consistent compilers**: The GCC and Clang shells use the same versions as CI
     - **Quick setup**: Get started with a single command
    @@ -68,7 +68,7 @@ A compiler can be chosen by providing its name with the `.#` prefix, e.g. `nix d
     
     On Linux, `.#gcc` and `.#clang` provide the exact toolchain CI uses:
     the compiler (pinned in [`nix/packages.nix`](../../nix/packages.nix))
    -rebuilt against the pinned custom glibc (see [`nix/compilers.nix`](../../nix/compilers.nix)).
    +rebuilt against the pinned custom glibc (see [`nix/linux.nix`](../../nix/linux.nix)).
     Building that toolchain the first time is slow unless it is fetched from a Nix binary cache.
     If you don't need the custom glibc, the Linux-only `.#gcc-plain` and `.#clang-plain`
     give you the stock nixpkgs compilers of the same versions.
    @@ -142,14 +142,80 @@ environment — CI runs in Docker images that bundle the dev shell's toolchain (
     `-plain` shells do not match that toolchain's glibc, so binaries from the remote
     are not a reliable match there.
     
    -On **macOS**, CI builds with Apple Clang, so the remote holds nothing for the Nix
    -`clang` toolchain and dependencies are compiled locally. We do not publish
    -Nix-built macOS binaries because a Conan package ID records the compiler version
    -but not the nixpkgs revision.
    +On **macOS**, CI also builds in this Nix environment, in Debug and Release (the
    +`macos-arm64-*-nix` configurations — Debug because the profile defaults to it).
    +The Nix build resolves to `compiler=clang`, so it gets its own package IDs,
    +separate from the Apple Clang ones. The
    +[dependency upload](../../.github/workflows/upload-conan-deps.yml) publishes them
    +on pushes to `develop` and on manual runs — its nightly run rebuilds everything
    +from source but uploads nothing — so once a set has been published `nix develop`
    +can reuse it instead of compiling every dependency locally. These configurations
    +run outside the reduced pull-request matrix, so label a PR `Full CI build` when it
    +touches `flake.lock` or `nix/`.
     
     To compile everything from source, add `--build '*'` to the `conan install`
     command.
     
    +### Why the nixpkgs revision is not part of the package ID
    +
    +A Conan package ID records the compiler and its major version, but nothing about
    +the nixpkgs revision the toolchain came from — and `flake.lock` moves far more
    +often than the toolchain meaningfully changes, so folding it in would rebuild
    +every dependency on every bump for nothing.
    +
    +That is safe as long as no cached artifact resolves a `/nix/store` path at run
    +time, because store paths change on every update and the old ones disappear with
    +`nix-collect-garbage`. With the `clang` toolchain macOS CI and the dev shell use,
    +they do not: it links against `/usr/lib/libc++` and `/usr/lib/libSystem`, and
    +store paths reach the `.a` files only through debug info, which nothing resolves
    +at link or run time.
    +
    +> [!WARNING]
    +> This does not hold for `nix develop .#gcc` on macOS. There is no system
    +> libstdc++, so GCC links its own from the store and every binary keeps a
    +> `/nix/store` reference. That shell is fine for tooling, but it is not a build
    +> configuration CI covers, and no dependency binaries are published for it.
    +
    +This is checked rather than assumed.
    +[`bin/check-nix-store-refs.sh`](../../bin/check-nix-store-refs.sh) takes one file
    +or directory and fails if a binary under it resolves a store path at run time.
    +CI runs it over the build output and the Conan cache, and again in the upload job
    +before anything is published. You can run it yourself:
    +
    +```bash
    +bin/check-nix-store-refs.sh build
    +bin/check-nix-store-refs.sh ~/.conan2-nix
    +```
    +
    +It works on Linux too, but asserts something narrower there: the toolchain always
    +writes the store into `PT_INTERP` and `RUNPATH`, and CI builds inside an image
    +whose store is fixed for its lifetime, so that is fine. Only the binaries
    +[`PatchNixBinary.cmake`](../../cmake/PatchNixBinary.cmake) retargets to the
    +system loader have to be clean, and those are what CI checks:
    +
    +```bash
    +bin/check-nix-store-refs.sh build/xrpld
    +```
    +
    +### The libresolv stub
    +
    +This is not hypothetical: `xrpld` used to be caught by it. The c-ares package
    +tells the linker to pass `-lresolv`, and nixpkgs keeps `libresolv` out of the
    +macOS SDK and ships it as an ordinary store dylib — so every Nix-built `xrpld`
    +recorded a `/nix/store/…-libresolv-93/lib/libresolv.9.dylib` load command and
    +stopped running once that path was collected. Nothing in the link uses a single
    +symbol from it.
    +
    +Both environments now put a stub on the linker search path
    +(`libresolvSystemStub` in [`nix/darwin.nix`](../../nix/darwin.nix)): the
    +same library with its install name set to `/usr/lib/libresolv.9.dylib`, which is
    +exactly the load command the Apple Clang build records.
    +
    +Package IDs did not change, so Conan keeps serving anything built before the
    +stub landed. If a binary fails to start with `Library not loaded: /nix/store/…`,
    +see [that entry](./nix_troubleshooting.md#library-not-loaded-nixstore-from-a-binary-that-used-to-work)
    +in the troubleshooting guide.
    +
     ## Automatic Activation with direnv
     
     [direnv](https://direnv.net/) or [nix-direnv](https://github.com/nix-community/nix-direnv) can automatically activate the Nix development shell when you enter the repository directory.
    diff --git a/docs/build/nix_troubleshooting.md b/docs/build/nix_troubleshooting.md
    index fa766c0ee9..49088ab6b4 100644
    --- a/docs/build/nix_troubleshooting.md
    +++ b/docs/build/nix_troubleshooting.md
    @@ -131,3 +131,91 @@ once it picks up that rebuild, then re-run the `grep libgit2` check above to
     confirm it reports `1.9.4` or newer.
     
     Until then, prefer the workarounds above.
    +
    +## `wint_t` / `uint32_t` errors from the Nix libc++ headers
    +
    +A build that mixes the Nix toolchain with the system SDK fails in libc++ itself,
    +with errors that look nothing like your code:
    +
    +```
    +/nix/store/...-libcxx-.../include/c++/v1/cwchar:136:9: error: target of using declaration conflicts with declaration already in scope
    +  136 | using ::wint_t _LIBCPP_USING_IF_EXISTS;
    +/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/sys/_types/_wint_t.h:32:25: note: target of using declaration
    +...
    +error: use of undeclared identifier 'UINT32_C'
    +```
    +
    +The give-away is the second path: Nix's libc++ headers are being combined with
    +the **Xcode Command Line Tools** SDK instead of the Nix one.
    +
    +### Why it happens
    +
    +`SDKROOT` and `DEVELOPER_DIR` are what point the toolchain at the Nix SDK, and
    +they are not baked into the compiler — a dev shell gets them from the
    +`apple-sdk` setup hook. CMake, finding neither, asks `xcrun`, which answers with
    +the system SDK. Nix's `libc++` and Apple's headers then declare the same types
    +twice.
    +
    +### Fix
    +
    +Run the build from inside the dev shell (`nix develop`), or from an environment
    +that exports both variables. To confirm which SDK a configured build is using:
    +
    +```bash
    +grep -o '\-isysroot [^ ]*' build/compile_commands.json | sort -u
    +```
    +
    +It should print a `/nix/store/...-apple-sdk-*` path. If it prints
    +`/Library/Developer/CommandLineTools/...`, re-configure from within the shell —
    +CMake caches the sysroot, so an existing `build/` directory keeps the wrong one.
    +
    +## `Library not loaded: /nix/store/…` from a binary that used to work
    +
    +A binary stops starting after a `nix flake update`, or after
    +`nix-collect-garbage` removes the paths the previous toolchain used:
    +
    +```
    +dyld[57271]: Library not loaded: /nix/store/…-libresolv-93/lib/libresolv.9.dylib
    +```
    +
    +[`bin/check-nix-store-refs.sh`](../../bin/check-nix-store-refs.sh) finds the same
    +thing without having to run anything, and names the file:
    +
    +```
    +$ bin/check-nix-store-refs.sh ~/.conan2-nix
    +::error file=/Users/you/.conan2-nix/p/b/c-area24ded30c388c/p/bin/adig::references the Nix store at run time
    +/Users/you/.conan2-nix/p/b/c-area24ded30c388c/p/bin/adig
    +    /nix/store/p4lp3xq4imd1qzqh08x8vcq2zfhi7rca-libresolv-93/lib/libresolv.9.dylib
    +/Users/you/.conan2-nix: checked 135, skipped 2495, 1 with Nix store references.
    +```
    +
    +Conan's cache folders are named after a truncated package name plus a hash, so
    +ask Conan which package the offending one belongs to — pass the folder holding
    +the hash, not the file itself:
    +
    +```
    +$ conan cache ref ~/.conan2-nix/p/b/c-area24ded30c388c
    +c-ares/1.34.6#545240bb1c40e2cacd4362d6b8967650:dab5992496abe6d219defb7986ecbf367615a5e5#…
    +```
    +
    +### Why it happens
    +
    +The binary records a store path that no longer exists. Nothing we build should:
    +see [Prebuilt packages](./nix.md#prebuilt-packages) for why, and
    +`libresolvSystemStub` in [`nix/darwin.nix`](../../nix/darwin.nix) for the one
    +dependency that needed help to comply.
    +
    +A Conan package ID does not encode the nixpkgs revision, so a package built
    +before that stub existed stays in your local cache and keeps being reused. The
    +dev shell is also what tends to produce one: it is a slightly _less_ isolated
    +build environment than CI's, because `mkShell` puts every tool's headers and
    +libraries on the compiler's search path — which is how c-ares found the Nix
    +`libresolv` in the first place.
    +
    +### Fix
    +
    +Drop that package and let Conan refetch or rebuild it:
    +
    +```bash
    +conan remove 'c-ares/*'
    +```
    diff --git a/nix/ci-env.nix b/nix/ci-env.nix
    index 787b94406e..779b5b7230 100644
    --- a/nix/ci-env.nix
    +++ b/nix/ci-env.nix
    @@ -1,67 +1,39 @@
    +# The environment CI builds in: every tool on PATH, no Nix stdenv setup hooks.
    +# Baked into the `nix-*` Docker images on Linux (see nix/docker), built on the
    +# runner on macOS (see .github/actions/setup-nix-env).
     {
       pkgs,
       customGlibc,
       ...
     }:
     let
    -  inherit (import ./packages.nix { inherit pkgs; })
    -    commonPackages
    -    gccVersion
    -    llvmVersion
    -    mkVersionedToolLinks
    -    ;
    +  inherit (import ./packages.nix { inherit pkgs; }) commonPackages;
     
    -  # Custom-glibc toolchain, shared with the Linux dev shell (see compilers.nix).
    -  inherit (import ./compilers.nix { inherit pkgs customGlibc; })
    -    customGcc
    -    customClang
    -    customBinutils
    -    customGcov
    -    ;
    +  # Each forces something absent on the other platform, so both stay lazy.
    +  linux = import ./linux.nix { inherit pkgs customGlibc; };
    +  darwin = import ./darwin.nix { inherit pkgs; };
     
    -  # Strip the generic cc/c++/cpp symlinks from the clang wrapper so it can
    -  # coexist with the gcc wrapper in buildEnv. gcc remains the default
    -  # compiler (cc/c++/cpp); clang is invoked explicitly as clang/clang++.
    -  customClangForCiEnv = pkgs.symlinkJoin {
    -    name = "clang-wrapper-custom-for-ci-env";
    -    paths = [ customClang ];
    -    postBuild = ''
    -      rm -f $out/bin/cc $out/bin/c++ $out/bin/cpp
    -    '';
    -  };
    +  # What a buildEnv cannot express: environment variables. $GITHUB_ENV format;
    +  # `set -a; . env; set +a` loads it in a shell.
    +  darwinEnv = pkgs.writeTextDir "share/xrpld-ci-env/env" (
    +    pkgs.lib.concatStrings (
    +      pkgs.lib.mapAttrsToList (name: value: "${name}=${value}\n") (darwin.sdkEnv // darwin.libresolvEnv)
    +    )
    +  );
     
    +  toolchain = if pkgs.stdenv.isLinux then linux.toolchain else (darwin.toolchain ++ [ darwinEnv ]);
     in
     {
       default = pkgs.buildEnv {
         name = "xrpld-ci-env";
    -    paths = commonPackages ++ [
    -      customGcc
    -      customGcov
    -      customClangForCiEnv
    -      customBinutils
    -      (mkVersionedToolLinks {
    -        name = "gcc";
    -        package = customGcc;
    -        version = gccVersion;
    -        tools = [
    -          "gcc"
    -          "g++"
    -          "cpp"
    -        ];
    -      })
    -      (mkVersionedToolLinks {
    -        name = "clang";
    -        package = customClang;
    -        version = llvmVersion;
    -        tools = [
    -          "clang"
    -          "clang++"
    -        ];
    -      })
    -      # CA certificate bundle so HTTPS clients (git, curl, conan) can verify
    -      # TLS connections without ca-certificates being installed in the system.
    -      pkgs.cacert
    -    ];
    +    paths =
    +      commonPackages
    +      ++ toolchain
    +      ++ [
    +        # CA certificate bundle so HTTPS clients (git, curl, conan) can verify
    +        # TLS connections without ca-certificates being installed in the system.
    +        pkgs.cacert
    +      ];
         pathsToLink = [
           "/bin"
           "/etc/ssl/certs"
    diff --git a/nix/darwin.nix b/nix/darwin.nix
    new file mode 100644
    index 0000000000..837752fc6a
    --- /dev/null
    +++ b/nix/darwin.nix
    @@ -0,0 +1,80 @@
    +# The darwin toolchain, counterpart to linux.nix. Split by consumer: a dev
    +# shell's stdenv provides the SDK variables, nothing provides libresolv.
    +#
    +# darwin only - `libresolv` does not exist on Linux.
    +{ pkgs }:
    +let
    +  inherit (import ./packages.nix { inherit pkgs; })
    +    llvmVersion
    +    llvmPackages
    +    mkVersionedToolLinks
    +    ;
    +
    +  # nixpkgs keeps libresolv out of the macOS SDK, so neither c-ares' `-lresolv`
    +  # nor grpc's  resolves. Headers can come from nixpkgs; the
    +  # library cannot, or its store path lands in xrpld - hence this copy.
    +  libresolvSystemStub =
    +    pkgs.runCommand "libresolv-system-stub"
    +      {
    +        nativeBuildInputs = [ llvmPackages.bintools ];
    +      }
    +      ''
    +        mkdir -p "$out/lib"
    +        cp ${pkgs.darwin.libresolv}/lib/libresolv.9.dylib "$out/lib/"
    +        chmod +w "$out/lib/libresolv.9.dylib"
    +        llvm-install-name-tool -id /usr/lib/libresolv.9.dylib "$out/lib/libresolv.9.dylib"
    +        ln -s libresolv.9.dylib "$out/lib/libresolv.dylib"
    +      '';
    +in
    +{
    +  # For an environment that only puts binaries on PATH.
    +  toolchain = [
    +    llvmPackages.clang
    +    # The wrappers re-export only part of cctools; a bare env has no stdenv to
    +    # supply the rest, and without `dsymutil` even `clang -g` cannot link. One
    +    # by one, because buildEnv rejects any name a wrapper owns (notably `ld`).
    +    (pkgs.linkFarm "cctools-extra" (
    +      map
    +        (tool: {
    +          name = "bin/${tool}";
    +          path = "${llvmPackages.clang.bintools.bintools}/bin/${tool}";
    +        })
    +        [
    +          "codesign_allocate"
    +          "dsymutil"
    +          "dwarfdump"
    +          "install_name_tool"
    +          "lipo"
    +          "otool"
    +        ]
    +    ))
    +    (mkVersionedToolLinks {
    +      name = "clang";
    +      package = llvmPackages.clang;
    +      version = llvmVersion;
    +      tools = [
    +        "clang"
    +        "clang++"
    +      ];
    +    })
    +  ];
    +
    +  # Without these CMake asks `xcrun` and gets the Command Line Tools SDK, whose
    +  # headers clash with the Nix libc++ ones.
    +  sdkEnv = {
    +    DEVELOPER_DIR = "${pkgs.apple-sdk}";
    +    SDKROOT = "${pkgs.apple-sdk}/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk";
    +  };
    +
    +  # Salted names: the wrappers only read plain NIX_CFLAGS_COMPILE / NIX_LDFLAGS
    +  # through role variables a Nix stdenv would set. The salt is the target
    +  # platform, so this fits the gcc wrapper too.
    +  #
    +  # No space after -isystem: these are written one per line as KEY=VALUE, and a
    +  # shell sourcing that reads the space as the end of the assignment.
    +  libresolvEnv = {
    +    "NIX_CFLAGS_COMPILE_${llvmPackages.clang.suffixSalt}" =
    +      "-isystem${pkgs.darwin.libresolv.dev}/include";
    +    "NIX_LDFLAGS_${llvmPackages.clang.bintools.suffixSalt}" = "-L${libresolvSystemStub}/lib";
    +  };
    +}
    diff --git a/nix/devshell.nix b/nix/devshell.nix
    index ac0b84e169..07f7143c5b 100644
    --- a/nix/devshell.nix
    +++ b/nix/devshell.nix
    @@ -14,21 +14,21 @@ let
       plainGccStdenv = pkgs."gcc${toString gccVersion}Stdenv";
       plainClangStdenv = llvmPackages.stdenv;
     
    -  # Custom-glibc stdenvs, matching the CI environment (see compilers.nix). The
    -  # pinned glibc snapshot only builds on Linux, so on darwin these fall back to
    -  # the plain stdenvs; the `if isLinux` guard keeps `customGlibc` from being
    -  # forced (and erroring) on macOS.
    -  customCompilers = import ./compilers.nix { inherit pkgs customGlibc; };
    -  customGccStdenv = if pkgs.stdenv.isLinux then customCompilers.customStdenv else plainGccStdenv;
    -  customClangStdenv =
    -    if pkgs.stdenv.isLinux then customCompilers.customClangStdenv else plainClangStdenv;
    +  # Each forces something absent on the other platform, so both stay lazy.
    +  linux = import ./linux.nix { inherit pkgs customGlibc; };
    +  darwin = import ./darwin.nix { inherit pkgs; };
    +
    +  # Custom-glibc stdenvs, matching the CI environment. darwin has no custom
    +  # glibc, so there they fall back to the plain nixpkgs stdenvs.
    +  customGccStdenv = if pkgs.stdenv.isLinux then linux.gccStdenv else plainGccStdenv;
    +  customClangStdenv = if pkgs.stdenv.isLinux then linux.clangStdenv else plainClangStdenv;
     
       # gcov matching each gcc shell, so `-Dcoverage=ON` builds work in the shell.
       plainGcov = mkGcov {
         name = "plain";
         cc = gccPackage.cc;
       };
    -  customGccGcov = if pkgs.stdenv.isLinux then customCompilers.customGcov else plainGcov;
    +  customGccGcov = if pkgs.stdenv.isLinux then linux.gcov else plainGcov;
     
       # Whole directory: init.sh locates the profiles relative to itself.
       conanDir = ../conan;
    @@ -49,6 +49,16 @@ let
         unset _xrpl_conan_stamp
       '';
     
    +  # Not sdkEnv: a shell's stdenv already sets that up. Prepended so the stub
    +  # beats the nixpkgs libresolv this shell's tooling drags in.
    +  darwinLibresolvHook = pkgs.lib.optionalString pkgs.stdenv.isDarwin (
    +    pkgs.lib.concatLines (
    +      pkgs.lib.mapAttrsToList (
    +        name: value: ''export ${name}="${value} ''${${name}:-}"''
    +      ) darwin.libresolvEnv
    +    )
    +  );
    +
       # Shown when entering a *-plain shell. These exist only on Linux (see below),
       # where the stock toolchain diverges from CI.
       plainWarningHook = ''
    @@ -106,6 +116,7 @@ let
             shellHook = ''
               echo "Welcome to xrpld development shell";
               ${compilerVersionHook}
    +          ${darwinLibresolvHook}
               ${conanHook}
               ${warningHook}
             '';
    diff --git a/nix/docker/Dockerfile b/nix/docker/Dockerfile
    index 74c630cb61..5506bc3c77 100644
    --- a/nix/docker/Dockerfile
    +++ b/nix/docker/Dockerfile
    @@ -8,7 +8,7 @@ RUN mkdir -p ~/.config/nix && \
     
     # Copy our source and setup our working dir.
     COPY nix/ci-env.nix /tmp/build/nix/ci-env.nix
    -COPY nix/compilers.nix /tmp/build/nix/compilers.nix
    +COPY nix/linux.nix /tmp/build/nix/linux.nix
     COPY nix/packages.nix /tmp/build/nix/packages.nix
     COPY nix/utils.nix /tmp/build/nix/utils.nix
     COPY flake.nix /tmp/build/
    diff --git a/nix/compilers.nix b/nix/linux.nix
    similarity index 75%
    rename from nix/compilers.nix
    rename to nix/linux.nix
    index 90856afacc..ea808fbf50 100644
    --- a/nix/compilers.nix
    +++ b/nix/linux.nix
    @@ -1,7 +1,9 @@
    -# Custom-glibc compiler toolchain shared by the CI environment (ci-env.nix) and
    -# the Linux dev shell (devshell.nix): gcc / clang / binutils rebuilt to target
    -# the pinned custom glibc. Linux only — the pinned glibc snapshot does not build
    -# on darwin, so callers must not evaluate this on macOS.
    +# The Linux toolchain: gcc / clang / binutils rebuilt to target the pinned
    +# custom glibc, shared by the CI environment (ci-env.nix) and the dev shell
    +# (devshell.nix). The counterpart to darwin.nix.
    +#
    +# Linux only — the pinned glibc snapshot does not build on darwin, so callers
    +# must not evaluate this on macOS.
     {
       pkgs,
       customGlibc,
    @@ -9,9 +11,11 @@
     let
       inherit (import ./packages.nix { inherit pkgs; })
         gccPackage
    +    gccVersion
         llvmPackages
         llvmVersion
         mkGcov
    +    mkVersionedToolLinks
         ;
     
       # binutils wrapped to emit binaries that reference the custom glibc
    @@ -103,15 +107,46 @@ let
           echo "-isystem ${customCompilerRt.dev}/include" >> $out/nix-support/cc-cflags
         '';
       };
    +  # Strip the generic cc/c++/cpp symlinks from the clang wrapper so it can
    +  # coexist with the gcc wrapper in buildEnv. gcc remains the default
    +  # compiler (cc/c++/cpp); clang is invoked explicitly as clang/clang++.
    +  customClangForCiEnv = pkgs.symlinkJoin {
    +    name = "clang-wrapper-custom-for-ci-env";
    +    paths = [ customClang ];
    +    postBuild = ''
    +      rm -f $out/bin/cc $out/bin/c++ $out/bin/cpp
    +    '';
    +  };
     in
     {
    -  inherit
    +  # For an environment that only puts binaries on PATH.
    +  toolchain = [
         customGcc
    -    customClang
    -    customBinutils
    -    customStdenv
         customGcov
    -    ;
    +    customClangForCiEnv
    +    customBinutils
    +    (mkVersionedToolLinks {
    +      name = "gcc";
    +      package = customGcc;
    +      version = gccVersion;
    +      tools = [
    +        "gcc"
    +        "g++"
    +        "cpp"
    +      ];
    +    })
    +    (mkVersionedToolLinks {
    +      name = "clang";
    +      package = customClang;
    +      version = llvmVersion;
    +      tools = [
    +        "clang"
    +        "clang++"
    +      ];
    +    })
    +  ];
     
    -  customClangStdenv = pkgs.stdenvAdapters.overrideCC pkgs.stdenv customClang;
    +  gccStdenv = customStdenv;
    +  clangStdenv = pkgs.stdenvAdapters.overrideCC pkgs.stdenv customClang;
    +  gcov = customGcov;
     }
    
    From a6983f8bf3ff37e8d379963bfb8702bb21d94ed4 Mon Sep 17 00:00:00 2001
    From: Ayaz Salikhov 
    Date: Wed, 19 Aug 2026 00:25:46 +0000
    Subject: [PATCH 095/102] build: Use AlmaLinux for the RHEL packaging image
     (#8045)
    
    ---
     .github/scripts/strategy-matrix/generate.py  | 2 +-
     .github/workflows/build-packaging-images.yml | 3 ++-
     package/install-packaging-tools.sh           | 2 ++
     3 files changed, 5 insertions(+), 2 deletions(-)
    
    diff --git a/.github/scripts/strategy-matrix/generate.py b/.github/scripts/strategy-matrix/generate.py
    index 83f3c67e7f..fb37fb7691 100755
    --- a/.github/scripts/strategy-matrix/generate.py
    +++ b/.github/scripts/strategy-matrix/generate.py
    @@ -219,7 +219,7 @@ def expand_linux_matrix(linux: LinuxFile, minimal: bool) -> list[MatrixEntry]:
     def expand_linux_packaging(linux: LinuxFile) -> list[PackagingEntry]:
         """Generate the packaging matrix from a LinuxFile's package_configs section.
     
    -    Packaging uses vanilla distro images (debian:bookworm, ubi9, …) instead of
    +    Packaging uses vanilla distro images (debian:bookworm, almalinux:9) instead of
         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'.
    diff --git a/.github/workflows/build-packaging-images.yml b/.github/workflows/build-packaging-images.yml
    index 43b276bdf1..fbabc25ac3 100644
    --- a/.github/workflows/build-packaging-images.yml
    +++ b/.github/workflows/build-packaging-images.yml
    @@ -36,8 +36,9 @@ jobs:
             distro:
               - name: debian
                 base_image: debian:bookworm
    +          # AlmaLinux rather than UBI9, which does not ship rpm-sign.
               - name: rhel
    -            base_image: registry.access.redhat.com/ubi9/ubi:latest
    +            base_image: almalinux:9
         uses: XRPLF/actions/.github/workflows/build-multiarch-image.yml@9e7e4e80af9e684c116b38369add8eea64451f32
         with:
           image_name: xrpld/packaging-${{ matrix.distro.name }}
    diff --git a/package/install-packaging-tools.sh b/package/install-packaging-tools.sh
    index 06ab44ac93..2326d8f2ac 100755
    --- a/package/install-packaging-tools.sh
    +++ b/package/install-packaging-tools.sh
    @@ -28,6 +28,7 @@ esac
     #   - debhelper and dpkg-dev build the DEB
     #   - rpm-build builds the RPM, with systemd-rpm-macros and redhat-rpm-config
     #     supplying the systemd and find-debuginfo macros the spec uses
    +#   - rpm-sign signs the built RPM
     #   - git gives build_pkg.sh a real history to read SOURCE_DATE_EPOCH from;
     #     without one the timestamp falls back to the wall clock
     #   - curl uploads the finished packages in publish_pkg.sh
    @@ -50,6 +51,7 @@ function install() {
                     curl-minimal \
                     git \
                     rpm-build \
    +                rpm-sign \
                     redhat-rpm-config \
                     systemd-rpm-macros
                 ;;
    
    From 3adf2d40b560b9e979c7d540b835bd86a2df37d1 Mon Sep 17 00:00:00 2001
    From: Vito Tumas <5780819+Tapanito@users.noreply.github.com>
    Date: Wed, 19 Aug 2026 13:09:38 +0000
    Subject: [PATCH 096/102] fix: Reject VaultWithdraw fixed-share amounts that
     round to zero (#7950)
    
    ---
     include/xrpl/ledger/helpers/VaultHelpers.h    |  34 +++
     src/libxrpl/ledger/helpers/VaultHelpers.cpp   |  25 +-
     src/libxrpl/tx/invariants/VaultInvariant.cpp  | 146 ++++++----
     .../tx/transactors/vault/VaultClawback.cpp    |  14 +
     .../tx/transactors/vault/VaultWithdraw.cpp    |  47 +++-
     src/test/app/lending/LoanRounding_test.cpp    | 257 ++++++++++++++++++
     src/test/app/vault/VaultBugs_test.cpp         |  97 +++++++
     7 files changed, 547 insertions(+), 73 deletions(-)
    
    diff --git a/include/xrpl/ledger/helpers/VaultHelpers.h b/include/xrpl/ledger/helpers/VaultHelpers.h
    index acbf2c3ac0..c898e9e148 100644
    --- a/include/xrpl/ledger/helpers/VaultHelpers.h
    +++ b/include/xrpl/ledger/helpers/VaultHelpers.h
    @@ -1,7 +1,9 @@
     #pragma once
     
    +#include 
     #include 
     #include 
    +#include 
     #include 
     #include 
     #include 
    @@ -55,6 +57,38 @@ enum class TruncateShares : bool { No = false, Yes = true };
      */
     enum class WaiveUnrealizedLoss : bool { No = false, Yes = true };
     
    +/**
    + * Returns the effective total of assets backing outstanding shares for the
    + * purposes of a withdrawal, i.e. sfAssetsTotal, discounted by sfLossUnrealized
    + * unless waived. This is the numerator used by both withdraw conversion
    + * helpers (assetsToSharesWithdraw and sharesToAssetsWithdraw) to compute the
    + * share/asset exchange rate.
    + *
    + * @param vault The vault SLE.
    + * @param waive Whether to waive (i.e. not subtract) the vault's unrealized
    + *              loss.
    + */
    +[[nodiscard]] Number
    +assetsTotalForWithdrawal(SLE::const_ref vault, WaiveUnrealizedLoss waive);
    +
    +/**
    + * Returns whether debiting `amount` from `total` — the current value of a
    + * vault's sfAssetsTotal or sfAssetsAvailable field — would canonicalize back
    + * to the exact same STAmount value it started at. This happens when a
    + * genuinely non-zero debit is dust relative to a `total` large enough to
    + * exceed STAmount's significant-digit precision: the shares still move, but
    + * the stored total doesn't change, which otherwise trips the ValidVault
    + * invariant after the fact instead of failing cleanly upfront.
    + *
    + * @param asset The vault's underlying asset, used to canonicalize both sides
    + *              the same way the ledger will when the field is stored.
    + * @param total The field's current value.
    + * @param amount The amount to debit. A value of zero always returns false;
    + *               that case is rejected separately and unconditionally.
    + */
    +[[nodiscard]] bool
    +debitIsNonZeroDust(Asset const& asset, Number const& total, Number const& amount);
    +
     /**
      * From the perspective of a vault, return the number of shares to demand from
      * the depositor when they ask to withdraw a fixed amount of assets. Since
    diff --git a/src/libxrpl/ledger/helpers/VaultHelpers.cpp b/src/libxrpl/ledger/helpers/VaultHelpers.cpp
    index 67e0262e14..b0d835a423 100644
    --- a/src/libxrpl/ledger/helpers/VaultHelpers.cpp
    +++ b/src/libxrpl/ledger/helpers/VaultHelpers.cpp
    @@ -67,6 +67,23 @@ sharesToAssetsDeposit(SLE::const_ref vault, SLE::const_ref issuance, STAmount co
         return assets;
     }
     
    +[[nodiscard]] Number
    +assetsTotalForWithdrawal(SLE::const_ref vault, WaiveUnrealizedLoss waive)
    +{
    +    Number assetTotal = vault->at(sfAssetsTotal);
    +    if (waive == WaiveUnrealizedLoss::No)
    +        assetTotal -= vault->at(sfLossUnrealized);
    +    return assetTotal;
    +}
    +
    +[[nodiscard]] bool
    +debitIsNonZeroDust(Asset const& asset, Number const& total, Number const& amount)
    +{
    +    if (amount == 0)
    +        return false;
    +    return STAmount{asset, total - amount} == STAmount{asset, total};
    +}
    +
     [[nodiscard]] std::optional
     assetsToSharesWithdraw(
         SLE::const_ref vault,
    @@ -82,9 +99,7 @@ assetsToSharesWithdraw(
         if (assets.negative() || assets.asset() != vault->at(sfAsset))
             return std::nullopt;  // LCOV_EXCL_LINE
     
    -    Number assetTotal = vault->at(sfAssetsTotal);
    -    if (waive == WaiveUnrealizedLoss::No)
    -        assetTotal -= vault->at(sfLossUnrealized);
    +    Number const assetTotal = assetsTotalForWithdrawal(vault, waive);
         STAmount shares{vault->at(sfShareMPTID)};
         if (assetTotal == 0)
             return shares;
    @@ -110,9 +125,7 @@ sharesToAssetsWithdraw(
         if (shares.negative() || shares.asset() != vault->at(sfShareMPTID))
             return std::nullopt;  // LCOV_EXCL_LINE
     
    -    Number assetTotal = vault->at(sfAssetsTotal);
    -    if (waive == WaiveUnrealizedLoss::No)
    -        assetTotal -= vault->at(sfLossUnrealized);
    +    Number const assetTotal = assetsTotalForWithdrawal(vault, waive);
         STAmount assets{vault->at(sfAsset)};
         if (assetTotal == 0)
             return assets;
    diff --git a/src/libxrpl/tx/invariants/VaultInvariant.cpp b/src/libxrpl/tx/invariants/VaultInvariant.cpp
    index dc6021beb5..5c25a22987 100644
    --- a/src/libxrpl/tx/invariants/VaultInvariant.cpp
    +++ b/src/libxrpl/tx/invariants/VaultInvariant.cpp
    @@ -908,19 +908,36 @@ ValidVault::finalize(
                     }
     
                     auto const maybeVaultDeltaAssets = deltaAssets(afterVault.pseudoId);
    -                if (!maybeVaultDeltaAssets)
    +
    +                // Post-fixCleanup3_4_0: a withdrawal that redeems shares from a
    +                // pool with no effective value left to back them (e.g. fully
    +                // impaired/insolvent) legitimately moves zero assets on both
    +                // sides — VaultWithdraw::doApply does not touch either
    +                // balance-holding entry for a zero-value transfer, so no delta
    +                // is recorded. VaultWithdraw::doApply separately rejects
    +                // (tecPRECISION_LOSS) the case where a *positive* per-share
    +                // value merely rounds down to zero, so a missing delta while
    +                // the pool still held positive effective value indicates a
    +                // real accounting bug, not this exception.
    +                bool const zeroDeltaIsLegitimate = view.rules().enabled(fixCleanup3_4_0) &&
    +                    !maybeVaultDeltaAssets && beforeVault.assetsTotal == beforeVault.lossUnrealized;
    +
    +                if (!maybeVaultDeltaAssets && !zeroDeltaIsLegitimate)
                     {
                         JLOG(j.fatal()) << "Invariant failed: withdrawal must change vault balance";
                         return false;  // That's all we can do
                     }
     
    +                DeltaInfo const vaultDeltaAssets = maybeVaultDeltaAssets.value_or(
    +                    DeltaInfo{.delta = kNumZero, .scale = std::nullopt});
    +
                     // Get the posterior scale to round calculations to
    -                auto const minScale = computeVaultMinScale(*maybeVaultDeltaAssets, view.rules());
    +                auto const minScale = computeVaultMinScale(vaultDeltaAssets, view.rules());
     
                     auto const vaultPseudoDeltaAssets =
    -                    roundToAsset(vaultAsset, maybeVaultDeltaAssets->delta, minScale);
    +                    roundToAsset(vaultAsset, vaultDeltaAssets.delta, minScale);
     
    -                if (vaultPseudoDeltaAssets >= kZero)
    +                if (!zeroDeltaIsLegitimate && vaultPseudoDeltaAssets >= kZero)
                     {
                         JLOG(j.fatal()) << "Invariant failed: withdrawal must decrease vault balance";
                         result = false;
    @@ -947,63 +964,76 @@ ValidVault::finalize(
     
                         if (maybeAccDelta.has_value() == maybeOtherAccDelta.has_value())
                         {
    -                        JLOG(j.fatal()) <<  //
    -                            "Invariant failed: withdrawal must change one destination balance";
    -                        return false;
    +                        // Both changed is always a bug. Neither changed is
    +                        // consistent only with a legitimate zero-value
    +                        // withdrawal, which moves nothing on either side —
    +                        // there is nothing left to cross-check.
    +                        if (!zeroDeltaIsLegitimate || maybeAccDelta.has_value())
    +                        {
    +                            JLOG(j.fatal()) <<  //
    +                                "Invariant failed: withdrawal must change one destination balance";
    +                            return false;
    +                        }
                         }
    -
    -                    auto const destinationDelta =  //
    -                        maybeAccDelta ? *maybeAccDelta : *maybeOtherAccDelta;
    -
    -                    // the scale of destinationDelta can be coarser than
    -                    // minScale, so we take that into account when rounding
    -                    auto const destinationScale = computeCoarsestScale({destinationDelta});
    -                    auto const localMinScale = std::max(minScale, destinationScale);
    -
    -                    auto const roundedDestinationDelta =
    -                        roundToAsset(vaultAsset, destinationDelta.delta, localMinScale);
    -
    -                    // Post-fixCleanup3_2_0: Tolerate zero-rounded destination deltas for IOUs only.
    -                    // If the receiver's trust line sits at a coarser scale, the inflow may
    -                    // safely round down to zero.
    -                    //
    -                    // XRP and MPT remain strict. Because they are integer-exact, a zero
    -                    // destination delta indicates a true accounting bug, not a rounding artifact.
    -                    bool const tolerateZeroDelta =
    -                        view.rules().enabled(fixCleanup3_2_0) && !vaultAsset.integral();
    -                    auto const invalidBalanceChange = tolerateZeroDelta
    -                        ? roundedDestinationDelta < kZero
    -                        : roundedDestinationDelta <= kZero;
    -                    if (invalidBalanceChange)
    +                    else
                         {
    -                        JLOG(j.fatal()) <<  //
    -                            "Invariant failed: withdrawal must increase destination balance";
    -                        result = false;
    -                    }
    +                        // A one-sided change is cross-checked even for a
    +                        // legitimate zero vault delta: the destination must
    +                        // then have moved by (rounded) zero as well.
    +                        auto const destinationDelta =
    +                            *maybeAccDelta.or_else([&] { return maybeOtherAccDelta; });
     
    -                    auto const localPseudoDeltaAssets =
    -                        roundToAsset(vaultAsset, vaultPseudoDeltaAssets, localMinScale);
    -                    // For IOU assets near a precision boundary the destination's STAmount
    -                    // exponent can shift, making part of the sent value unrepresentable at the
    -                    // receiver's new scale — that portion is irreversibly absorbed by the IOU
    -                    // rail.  Tolerate the mismatch only when the destroyed amount (vault outflow
    -                    // minus destination inflow, in Number space) is itself sub-ULP at the
    -                    // destination's scale.  Floor rounding is used so that values exactly at the
    -                    // step boundary are not mistakenly dismissed.  Any representable discrepancy
    -                    // indicates a real accounting bug and must be caught.
    -                    auto const destroyedIsSubUlp = tolerateZeroDelta &&
    -                        roundToAsset(
    -                            vaultAsset,
    -                            maybeVaultDeltaAssets->delta * -1 - destinationDelta.delta,
    -                            destinationScale,
    -                            Number::RoundingMode::Downward) == kZero;
    -                    if (!destroyedIsSubUlp &&
    -                        localPseudoDeltaAssets * -1 != roundedDestinationDelta)
    -                    {
    -                        JLOG(j.fatal()) << "Invariant failed: " <<  //
    -                            "withdrawal must change vault and destination balance by equal "
    -                            "amount";
    -                        result = false;
    +                        // the scale of destinationDelta can be coarser than
    +                        // minScale, so we take that into account when rounding
    +                        auto const destinationScale = computeCoarsestScale({destinationDelta});
    +                        auto const localMinScale = std::max(minScale, destinationScale);
    +
    +                        auto const roundedDestinationDelta =
    +                            roundToAsset(vaultAsset, destinationDelta.delta, localMinScale);
    +
    +                        // Post-fixCleanup3_2_0: Tolerate zero-rounded destination deltas for IOUs
    +                        // only. If the receiver's trust line sits at a coarser scale, the inflow
    +                        // may safely round down to zero.
    +                        //
    +                        // XRP and MPT remain strict. Because they are integer-exact, a zero
    +                        // destination delta indicates a true accounting bug, not a rounding
    +                        // artifact.
    +                        bool const tolerateZeroDelta =
    +                            view.rules().enabled(fixCleanup3_2_0) && !vaultAsset.integral();
    +                        auto const invalidBalanceChange = tolerateZeroDelta
    +                            ? roundedDestinationDelta < kZero
    +                            : roundedDestinationDelta <= kZero;
    +                        if (invalidBalanceChange)
    +                        {
    +                            JLOG(j.fatal()) <<  //
    +                                "Invariant failed: withdrawal must increase destination balance";
    +                            result = false;
    +                        }
    +
    +                        auto const localPseudoDeltaAssets =
    +                            roundToAsset(vaultAsset, vaultPseudoDeltaAssets, localMinScale);
    +                        // For IOU assets near a precision boundary the destination's STAmount
    +                        // exponent can shift, making part of the sent value unrepresentable at
    +                        // the receiver's new scale — that portion is irreversibly absorbed by the
    +                        // IOU rail.  Tolerate the mismatch only when the destroyed amount (vault
    +                        // outflow minus destination inflow, in Number space) is itself sub-ULP at
    +                        // the destination's scale.  Floor rounding is used so that values exactly
    +                        // at the step boundary are not mistakenly dismissed.  Any representable
    +                        // discrepancy indicates a real accounting bug and must be caught.
    +                        auto const destroyedIsSubUlp = tolerateZeroDelta &&
    +                            roundToAsset(
    +                                vaultAsset,
    +                                vaultDeltaAssets.delta * -1 - destinationDelta.delta,
    +                                destinationScale,
    +                                Number::RoundingMode::Downward) == kZero;
    +                        if (!destroyedIsSubUlp &&
    +                            localPseudoDeltaAssets * -1 != roundedDestinationDelta)
    +                        {
    +                            JLOG(j.fatal()) << "Invariant failed: " <<  //
    +                                "withdrawal must change vault and destination balance by equal "
    +                                "amount";
    +                            result = false;
    +                        }
                         }
                     }
     
    diff --git a/src/libxrpl/tx/transactors/vault/VaultClawback.cpp b/src/libxrpl/tx/transactors/vault/VaultClawback.cpp
    index d77286b667..d0eeaed071 100644
    --- a/src/libxrpl/tx/transactors/vault/VaultClawback.cpp
    +++ b/src/libxrpl/tx/transactors/vault/VaultClawback.cpp
    @@ -383,6 +383,20 @@ VaultClawback::doApply()
         if (sharesDestroyed == beast::kZero)
             return tecPRECISION_LOSS;
     
    +    // A recovered amount can be genuinely non-zero yet still be dust relative to a
    +    // sfAssetsTotal/sfAssetsAvailable large enough to exceed STAmount's significant-digit
    +    // precision: subtracting it below rounds the stored total right back to where it started.
    +    // The shares still move, so ValidVault would fail after the fact with "clawback must
    +    // decrease vault balance" instead of a clean upfront rejection.
    +    if (view().rules().enabled(fixCleanup3_4_0) &&
    +        (debitIsNonZeroDust(vaultAsset, assetsTotal, assetsRecovered) ||
    +         debitIsNonZeroDust(vaultAsset, assetsAvailable, assetsRecovered)))
    +    {
    +        JLOG(j_.debug()) << "VaultClawback: clawback amount too small to change stored vault"
    +                            " balance";
    +        return tecPRECISION_LOSS;
    +    }
    +
         assetsTotal -= assetsRecovered;
         assetsAvailable -= assetsRecovered;
         view().update(vault);
    diff --git a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp
    index 7b5bb1ea94..7e32e720d6 100644
    --- a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp
    +++ b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp
    @@ -283,6 +283,44 @@ VaultWithdraw::doApply()
             return tecPATH_DRY;
         }
     
    +    // The "final withdrawal" rule below handles its own zero-value case using
    +    // sfAssetsAvailable directly, so it is exempt from the checks below.
    +    bool const isFinalWithdrawal =
    +        sharesRedeemed == STAmount{share, sleIssuance->at(sfOutstandingAmount)};
    +
    +    auto assetsAvailable = vault->at(sfAssetsAvailable);
    +    auto assetsTotal = vault->at(sfAssetsTotal);
    +    auto const lossUnrealized = vault->at(sfLossUnrealized);
    +    XRPL_ASSERT(
    +        lossUnrealized <= (assetsTotal - assetsAvailable),
    +        "xrpl::VaultWithdraw::doApply : loss and assets do balance");
    +
    +    if (view().rules().enabled(fixCleanup3_4_0) && !isFinalWithdrawal)
    +    {
    +        // A withdrawal for a fixed share amount (variable assets) has no requested-asset
    +        // amount to check for rounding, unlike the fixed-assets branch above: a small enough
    +        // share amount can round down to an exact zero even though the vault still holds
    +        // positive effective value backing outstanding shares.
    +        if (amount.asset() == share && assetsWithdrawn == beast::kZero &&
    +            assetsTotalForWithdrawal(vault, waiveUnrealizedLoss) != beast::kZero)
    +        {
    +            JLOG(j_.debug()) << "VaultWithdraw: fixed-share withdrawal rounds to zero assets";
    +            return tecPRECISION_LOSS;
    +        }
    +
    +        // assetsWithdrawn can also be genuinely non-zero and still too small to move
    +        // sfAssetsTotal or sfAssetsAvailable once canonicalized to STAmount's precision. Either
    +        // way the shares still move, so ValidVault would otherwise fail after the fact instead
    +        // of a clean upfront rejection.
    +        if (debitIsNonZeroDust(vaultAsset, assetsTotal, assetsWithdrawn) ||
    +            debitIsNonZeroDust(vaultAsset, assetsAvailable, assetsWithdrawn))
    +        {
    +            JLOG(j_.debug()) << "VaultWithdraw: withdrawal amount too small to change stored"
    +                                " vault balance";
    +            return tecPRECISION_LOSS;
    +        }
    +    }
    +
         // Post-fixCleanup3_3_0: preclaim already validated all freeze conditions
         // (checkWithdrawFreeze), so IgnoreFreeze avoids a redundant check that
         // would incorrectly return zero for vault pseudo-accounts whose shares
    @@ -297,13 +335,6 @@ VaultWithdraw::doApply()
             return tecINSUFFICIENT_FUNDS;
         }
     
    -    auto assetsAvailable = vault->at(sfAssetsAvailable);
    -    auto assetsTotal = vault->at(sfAssetsTotal);
    -    auto const lossUnrealized = vault->at(sfLossUnrealized);
    -    XRPL_ASSERT(
    -        lossUnrealized <= (assetsTotal - assetsAvailable),
    -        "xrpl::VaultWithdraw::doApply : loss and assets do balance");
    -
         // The vault must have enough assets on hand.
         if (*assetsAvailable < assetsWithdrawn)
         {
    @@ -319,8 +350,6 @@ VaultWithdraw::doApply()
         // When the rule applies, the payout is the remaining sfAssetsAvailable; in a clean vault
         // the helper result should already equal that value, and any mismatch is a rounding artifact
         // worth logging.
    -    bool const isFinalWithdrawal =
    -        sharesRedeemed == STAmount{share, sleIssuance->at(sfOutstandingAmount)};
         if (view().rules().enabled(fixCleanup3_2_0) && isFinalWithdrawal)
         {
             // Unreachable: a final withdrawal with lossUnrealized > 0 has
    diff --git a/src/test/app/lending/LoanRounding_test.cpp b/src/test/app/lending/LoanRounding_test.cpp
    index 5e69c9f79e..b666281fee 100644
    --- a/src/test/app/lending/LoanRounding_test.cpp
    +++ b/src/test/app/lending/LoanRounding_test.cpp
    @@ -889,6 +889,259 @@ private:
             env.close();
         }
     
    +    // Pre-fixCleanup3_4_0 bug: VaultWithdraw for a fixed *share* amount that
    +    // rounds to zero assets trips tecINVARIANT_FAILED instead of failing
    +    // cleanly or succeeding, depending on why it's zero. The fixed-shares
    +    // branch had no zero guard, unlike the fixed-assets branch.
    +    // XRP case: pool value is nonzero (2,000,000) but 1 share's worth (0.5
    +    // drops) truncates to zero drops -> real precision loss -> tecPRECISION_LOSS.
    +    // IOU case: loan drew 100% of the vault and is fully impaired, so
    +    // AssetsTotal == LossUnrealized exactly -> pool value is genuinely zero
    +    // -> legitimate zero-value withdrawal -> tesSUCCESS.
    +    void
    +    testBugVaultWithdrawFixedSharesRoundsToZero(FeatureBitset features)
    +    {
    +        testcase("bug: VaultWithdraw fixed shares round down to zero assets");
    +
    +        using namespace jtx;
    +        using namespace loan;
    +
    +        bool const fixed = features[fixCleanup3_4_0];
    +
    +        Env env(*this, features);
    +
    +        Account const lender{"lender"};
    +        Account const depositorB{"depositorB"};
    +        Account const borrower{"borrower"};
    +
    +        env.fund(XRP(10'000'000), lender, depositorB, borrower);
    +        env.close();
    +
    +        // asset(n) == n drops.
    +        PrettyAsset const xrpAsset{xrpIssue(), 1};
    +
    +        auto const broker = createVaultAndBroker(
    +            env,
    +            xrpAsset,
    +            lender,
    +            {.vaultDeposit = 1'000'000, .debtMax = 3'000'000, .coverDeposit = 1'000'000});
    +
    +        Vault const v{env};
    +        env(v.deposit(
    +            {.depositor = depositorB,
    +             .id = broker.vaultKeylet().key,
    +             .amount = xrpAsset(3'000'000)}));
    +        env.close();
    +
    +        auto const brokerSle = env.le(broker.brokerKeylet());
    +        if (!BEAST_EXPECT(brokerSle))
    +            return;
    +        auto const loanKeylet =
    +            keylet::loan(broker.brokerID, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence)));
    +
    +        env(set(borrower, broker.brokerID, Number{2'000'000}),
    +            Sig(sfCounterpartySignature, lender),
    +            kPaymentTotal(2),
    +            kPaymentInterval(600),
    +            Fee(env.current()->fees().base * 2),
    +            Ter(tesSUCCESS));
    +        env.close();
    +
    +        // Impair the loan so LossUnrealized > 0.
    +        env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
    +        env.close();
    +
    +        auto const vaultSle = env.le(broker.vaultKeylet());
    +        if (!BEAST_EXPECT(vaultSle))
    +            return;
    +        BEAST_EXPECT(vaultSle->at(sfLossUnrealized) > beast::kZero);
    +
    +        // (AssetsTotal 4M - LossUnrealized 2M) * 1 share / 4M shares = 0.5,
    +        // rounds down to zero drops.
    +        auto const shareAsset = vaultSle->at(sfShareMPTID);
    +        STAmount const oneShare{MPTIssue{shareAsset}, Number(1)};
    +
    +        env(v.withdraw({.depositor = lender, .id = broker.vaultKeylet().key, .amount = oneShare}),
    +            Ter(fixed ? tecPRECISION_LOSS : tecINVARIANT_FAILED));
    +        env.close();
    +
    +        // Same bug, IOU asset. Needs a 2nd, minimal depositor: a sole
    +        // shareholder would waive the loss subtraction (fixCleanup3_2_0),
    +        // returning full value instead of zero.
    +        {
    +            Account const issuer{"issuer"};
    +            Account const iouLender{"iouLender"};
    +            Account const iouDepositorB{"iouDepositorB"};
    +            Account const iouBorrower{"iouBorrower"};
    +
    +            env.fund(XRP(10'000'000), issuer, iouLender, iouDepositorB, iouBorrower);
    +            env.close();
    +
    +            PrettyAsset const iouAsset = issuer[iouCurrency_];
    +            env(trust(iouLender, iouAsset(10'000'000)));
    +            env(trust(iouDepositorB, iouAsset(10'000'000)));
    +            env(trust(iouBorrower, iouAsset(10'000'000)));
    +            // iouLender funds the vault deposit and the broker's cover deposit.
    +            env(pay(issuer, iouLender, iouAsset(9'000'000)));
    +            env(pay(issuer, iouDepositorB, iouAsset(1)));
    +            env.close();
    +
    +            // No management fee -> LossUnrealized ends up == AssetsTotal.
    +            auto const iouBroker = createVaultAndBroker(
    +                env,
    +                iouAsset,
    +                iouLender,
    +                {.vaultDeposit = 3'999'999,
    +                 .debtMax = 4'000'000,
    +                 .coverDeposit = 4'000'000,
    +                 .managementFeeRate = TenthBips16{0}});
    +
    +            env(v.deposit(
    +                {.depositor = iouDepositorB,
    +                 .id = iouBroker.vaultKeylet().key,
    +                 .amount = iouAsset(1)}));
    +            env.close();
    +
    +            auto const iouBrokerSle = env.le(iouBroker.brokerKeylet());
    +            if (!BEAST_EXPECT(iouBrokerSle))
    +                return;
    +            auto const iouLoanKeylet = keylet::loan(
    +                iouBroker.brokerID, SeqProxy::rawSequence(iouBrokerSle->at(sfLoanSequence)));
    +
    +            // Draw the entire vault out as a single loan.
    +            env(set(iouBorrower, iouBroker.brokerID, Number{4'000'000}),
    +                Sig(sfCounterpartySignature, iouLender),
    +                kPaymentTotal(2),
    +                kPaymentInterval(600),
    +                Fee(env.current()->fees().base * 2),
    +                Ter(tesSUCCESS));
    +            env.close();
    +
    +            env(manage(iouLender, iouLoanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
    +            env.close();
    +
    +            auto const iouVaultSle = env.le(iouBroker.vaultKeylet());
    +            if (!BEAST_EXPECT(iouVaultSle))
    +                return;
    +            BEAST_EXPECT(iouVaultSle->at(sfLossUnrealized) == iouVaultSle->at(sfAssetsTotal));
    +
    +            auto const iouShareAsset = iouVaultSle->at(sfShareMPTID);
    +            STAmount const oneIouShare{MPTIssue{iouShareAsset}, Number(1)};
    +
    +            auto const iouLenderBalanceBefore = env.balance(iouLender, iouAsset);
    +            auto const iouVaultAvailableBefore = iouVaultSle->at(sfAssetsAvailable);
    +            // Env::balance can't be used for shares: it resolves the issuer
    +            // name, and the share issuer is the vault pseudo-account, which
    +            // Env doesn't know.
    +            auto const lenderShares = [&]() -> std::uint64_t {
    +                auto const sle = env.le(keylet::mptoken(iouShareAsset, iouLender.id()));
    +                return sle ? sle->at(sfMPTAmount) : 0;
    +            };
    +            auto const iouLenderSharesBefore = lenderShares();
    +            auto const iouIssuanceBefore = env.le(keylet::mptokenIssuance(iouShareAsset));
    +            if (!BEAST_EXPECT(iouIssuanceBefore))
    +                return;
    +            auto const iouSharesOutstandingBefore = iouIssuanceBefore->at(sfOutstandingAmount);
    +            env(v.withdraw(
    +                    {.depositor = iouLender,
    +                     .id = iouBroker.vaultKeylet().key,
    +                     .amount = oneIouShare}),
    +                fixed ? Ter(tesSUCCESS) : Ter(tecINVARIANT_FAILED));
    +            env.close();
    +
    +            if (fixed)
    +            {
    +                // Confirm this was a true zero-value transfer: balances
    +                // unchanged even though a share was burned.
    +                BEAST_EXPECT(env.balance(iouLender, iouAsset) == iouLenderBalanceBefore);
    +                BEAST_EXPECT(lenderShares() == iouLenderSharesBefore - 1);
    +                auto const iouIssuanceAfter = env.le(keylet::mptokenIssuance(iouShareAsset));
    +                if (BEAST_EXPECT(iouIssuanceAfter))
    +                {
    +                    BEAST_EXPECT(
    +                        iouIssuanceAfter->at(sfOutstandingAmount) ==
    +                        iouSharesOutstandingBefore - 1);
    +                }
    +                auto const iouVaultAfter = env.le(iouBroker.vaultKeylet());
    +                if (BEAST_EXPECT(iouVaultAfter))
    +                {
    +                    BEAST_EXPECT(iouVaultAfter->at(sfAssetsAvailable) == iouVaultAvailableBefore);
    +                }
    +            }
    +        }
    +    }
    +
    +    // Companion to the Vault_test dust-debit tests, which use a single
    +    // depositor so AssetsTotal == AssetsAvailable and both debitIsNonZeroDust
    +    // operands in VaultWithdraw::doApply trip together. Here a loan draws
    +    // almost the entire vault, leaving AssetsTotal (1e7) far above
    +    // AssetsAvailable (100): redeeming 1 share moves 1e-10 assets, which is
    +    // dust against AssetsTotal but representable against AssetsAvailable, so
    +    // the AssetsTotal operand alone carries the rejection.
    +    void
    +    testBugVaultWithdrawDustVsAssetsTotal(FeatureBitset features)
    +    {
    +        testcase("bug: VaultWithdraw dust debit vs AssetsTotal only");
    +
    +        using namespace jtx;
    +        using namespace loan;
    +
    +        bool const fixed = features[fixCleanup3_4_0];
    +
    +        Env env(*this, features);
    +
    +        Account const issuer{"issuer"};
    +        Account const lender{"lender"};
    +        Account const borrower{"borrower"};
    +
    +        env.fund(XRP(10'000'000), issuer, lender, borrower);
    +        env.close();
    +
    +        PrettyAsset const iouAsset = issuer[iouCurrency_];
    +        env(trust(lender, iouAsset(100'000'000)));
    +        env(trust(borrower, iouAsset(100'000'000)));
    +        env(pay(issuer, lender, iouAsset(20'000'000)));
    +        env.close();
    +
    +        // Scale 10 so 1 share is worth 1e-10 assets against the 1e7 pool.
    +        auto const broker = createVaultAndBroker(
    +            env,
    +            iouAsset,
    +            lender,
    +            {.vaultDeposit = 10'000'000,
    +             .debtMax = 10'000'000,
    +             .coverDeposit = 1'000'000,
    +             .vaultScale = 10});
    +
    +        // Draw all but 100 units: AssetsAvailable drops to 100 while
    +        // AssetsTotal stays at 1e7 (the loan is still an asset of the vault).
    +        env(set(borrower, broker.brokerID, Number{9'999'900}),
    +            Sig(sfCounterpartySignature, lender),
    +            kPaymentTotal(2),
    +            kPaymentInterval(600),
    +            Fee(env.current()->fees().base * 2),
    +            Ter(tesSUCCESS));
    +        env.close();
    +
    +        auto const vaultSle = env.le(broker.vaultKeylet());
    +        if (!BEAST_EXPECT(vaultSle))
    +            return;
    +        BEAST_EXPECT(vaultSle->at(sfAssetsTotal) == Number{10'000'000});
    +        BEAST_EXPECT(vaultSle->at(sfAssetsAvailable) == Number{100});
    +
    +        // 1 share redeems 1e7 * 1 / 1e17 = 1e-10 assets. Subtracting that
    +        // from AssetsTotal needs 18 significant digits and canonicalizes
    +        // straight back to 1e7 (no-op), while AssetsAvailable would become
    +        // 99.9999999999 — perfectly representable.
    +        auto const shareAsset = vaultSle->at(sfShareMPTID);
    +        STAmount const oneShare{MPTIssue{shareAsset}, Number(1)};
    +
    +        Vault const v{env};
    +        env(v.withdraw({.depositor = lender, .id = broker.vaultKeylet().key, .amount = oneShare}),
    +            Ter(fixed ? tecPRECISION_LOSS : tecINVARIANT_FAILED));
    +        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
    @@ -966,6 +1219,10 @@ private:
                 testYieldTheftRounding(flags);
             testBugOverpaymentPrincipalChange();
             testBugOverpayUnroundedAmount();
    +        testBugVaultWithdrawFixedSharesRoundsToZero(all_ - fixCleanup3_4_0);
    +        testBugVaultWithdrawFixedSharesRoundsToZero(all_);
    +        testBugVaultWithdrawDustVsAssetsTotal(all_ - fixCleanup3_4_0);
    +        testBugVaultWithdrawDustVsAssetsTotal(all_);
             testBugInterestDueDeltaCrash();
         }
     
    diff --git a/src/test/app/vault/VaultBugs_test.cpp b/src/test/app/vault/VaultBugs_test.cpp
    index a7071f3767..2dbd20f855 100644
    --- a/src/test/app/vault/VaultBugs_test.cpp
    +++ b/src/test/app/vault/VaultBugs_test.cpp
    @@ -521,6 +521,102 @@ private:
             }
         }
     
    +    // Bug: a debit can be genuinely non-zero yet still be dust relative to a
    +    // sfAssetsTotal/sfAssetsAvailable large enough to exceed STAmount's precision, e.g.
    +    // AssetsTotal 2e12 minus a 1e-6 debit needs 19 significant digits and rounds straight
    +    // back to 2e12. The shares still move, so ValidVault later fails with "must decrease
    +    // vault balance" instead of a clean upfront rejection.
    +    //
    +    // Fix (fixCleanup3_4_0): reject upfront with tecPRECISION_LOSS if the debit would
    +    // canonicalize back to the prior stored value.
    +    //
    +    // With a single depositor AssetsTotal == AssetsAvailable, so both
    +    // debitIsNonZeroDust operands trip together here. LoanRounding_test's
    +    // "dust debit vs AssetsTotal only" case isolates the AssetsTotal operand
    +    // via a heavily-loaned vault.
    +    void
    +    testBugVaultDustDebitCanonicalizesToNoOp()
    +    {
    +        using namespace test::jtx;
    +
    +        // Fund a single depositor and have them deposit `total` USD in one shot (default
    +        // scale 6, so shares mint at exactly total*1e6).
    +        auto const seedVault = [](Env& env, Number const& total) {
    +            Account const issuer{"issuer"};
    +            Account const owner{"owner"};
    +            Account const holder{"holder"};
    +
    +            env.fund(XRP(1'000'000), issuer, owner, holder);
    +            env.close();
    +            env(fset(issuer, asfAllowTrustLineClawback));
    +            env.close();
    +
    +            PrettyAsset const usd{issuer["USD"]};
    +            env(trust(holder, usd(100'000'000'000'000LL)));
    +            env.close();
    +            env(pay(issuer, holder, usd(total)));
    +            env.close();
    +
    +            Vault const vault{env};
    +            auto const [tx, keylet] = vault.create({.owner = owner, .asset = usd.raw()});
    +            env(tx);
    +            env.close();
    +            env(vault.deposit({.depositor = holder, .id = keylet.key, .amount = usd(total)}),
    +                Ter(tesSUCCESS));
    +            env.close();
    +
    +            return keylet;
    +        };
    +
    +        {
    +            auto runScenario = [&](FeatureBitset features, TER expected) {
    +                Env env(*this, features);
    +                Number const total{2, 12};
    +                auto const keylet = seedVault(env, total);
    +
    +                Account const issuer{"issuer"};
    +                PrettyAsset const usd{issuer["USD"]};
    +
    +                // 1 share's worth of assets: 1e-6, below AssetsTotal's storage precision.
    +                env(Vault::clawback(
    +                        {.issuer = issuer,
    +                         .id = keylet.key,
    +                         .holder = Account{"holder"},
    +                         .amount = usd(Number{1, -6}).value()}),
    +                    Ter(expected));
    +                env.close();
    +            };
    +
    +            testcase("bug: VaultClawback dust debit fires invariant (pre-fixCleanup3_4_0)");
    +            runScenario(all_ - fixCleanup3_4_0, tecINVARIANT_FAILED);
    +            testcase("bug: VaultClawback dust debit rejected cleanly (post-fixCleanup3_4_0)");
    +            runScenario(all_, tecPRECISION_LOSS);
    +        }
    +
    +        {
    +            auto runScenario = [&](FeatureBitset features, TER expected) {
    +                Env env(*this, features);
    +                Number const total{2, 12};
    +                auto const keylet = seedVault(env, total);
    +
    +                MPTIssue const share{env.le(keylet)->at(sfShareMPTID)};
    +
    +                // Redeem 1 share, worth 1e-6 assets, below AssetsTotal's storage precision.
    +                env(Vault::withdraw(
    +                        {.depositor = Account{"holder"},
    +                         .id = keylet.key,
    +                         .amount = STAmount{share, 1}}),
    +                    Ter(expected));
    +                env.close();
    +            };
    +
    +            testcase("bug: VaultWithdraw dust debit fires invariant (pre-fixCleanup3_4_0)");
    +            runScenario(all_ - fixCleanup3_4_0, tecINVARIANT_FAILED);
    +            testcase("bug: VaultWithdraw dust debit rejected cleanly (post-fixCleanup3_4_0)");
    +            runScenario(all_, tecPRECISION_LOSS);
    +        }
    +    }
    +
         // VaultDeposit::preclaim uses accountHolds(..., SpendableHandling::
         // shFULL_BALANCE), which for an IOU asset adds the counterparty's
         // LowLimit/HighLimit to the depositor's raw balance (TokenHelpers.cpp:
    @@ -706,6 +802,7 @@ public:
             testBugMakeDeltaAnteriorScale();
             testVaultDepositCanonicalizeToZero();
             testVaultWithdrawCanonicalizeToZero();
    +        testBugVaultDustDebitCanonicalizesToNoOp();
             testVaultDepositNegativeBalanceFromOppositeLimit();
             testBug6LimitBypassWithShares();
         }
    
    From 368ff1afce195cef00debf64d34aa82d72fe707c Mon Sep 17 00:00:00 2001
    From: Timur Yalymov <36795566+tyalymov@users.noreply.github.com>
    Date: Wed, 19 Aug 2026 13:43:40 +0000
    Subject: [PATCH 097/102] fix: Exempt loan default from asset freeze (#7932)
    
    Co-authored-by: Cursor 
    Co-authored-by: Vito Tumas <5780819+Tapanito@users.noreply.github.com>
    Co-authored-by: Ayaz Salikhov 
    ---
     include/xrpl/ledger/helpers/LendingHelpers.h  |  38 ++++
     include/xrpl/tx/invariants/FreezeInvariant.h  |   8 +-
     src/libxrpl/ledger/helpers/LendingHelpers.cpp |  38 ++++
     src/libxrpl/tx/invariants/FreezeInvariant.cpp |  63 +++++-
     src/libxrpl/tx/invariants/MPTInvariant.cpp    |  25 ++-
     src/test/app/lending/LendingHelpers_test.cpp  |  98 ++++++++++
     .../app/lending/LoanCoverFreezeAuth_test.cpp  | 185 ++++++++++++++++++
     7 files changed, 448 insertions(+), 7 deletions(-)
    
    diff --git a/include/xrpl/ledger/helpers/LendingHelpers.h b/include/xrpl/ledger/helpers/LendingHelpers.h
    index c69efff964..4aa89ea672 100644
    --- a/include/xrpl/ledger/helpers/LendingHelpers.h
    +++ b/include/xrpl/ledger/helpers/LendingHelpers.h
    @@ -7,6 +7,7 @@
     #include 
     #include 
     #include 
    +#include 
     #include 
     #include   // IWYU pragma: keep
     #include 
    @@ -21,6 +22,7 @@
     
     #include 
     #include 
    +#include 
     #include 
     #include 
     
    @@ -58,6 +60,42 @@ canApplyToBrokerCover(
     bool
     checkLendingProtocolDependencies(Rules const& rules, STTx const& tx);
     
    +/**
    + * The accounts and asset that LoanManage::defaultLoan's fixCleanup3_4_0
    + * freeze/lock exemption applies to.
    + *
    + * `defaultLoan` moves funds from the LoanBroker pseudo-account to the Vault
    + * pseudo-account via `accountSend`. Since neither is the vault asset's
    + * issuer, this is a third-party transfer that transits through the issuer in
    + * two hops (broker -> issuer, issuer -> vault; see
    + * `directSendNoLimitIOU`/`directSendNoLimitMPT`), so the exemption must cover
    + * both the issuer/broker and issuer/vault pairs, not a direct broker/vault
    + * pair. `asset` scopes it further to the vault's own currency/MPT issuance,
    + * so an unrelated one the same accounts happen to hold is still protected.
    + */
    +struct LoanDefaultFreezeExemptAccounts
    +{
    +    AccountID issuer;
    +    AccountID broker;
    +    AccountID vault;
    +    Asset asset;
    +};
    +
    +/**
    + * Resolves the accounts and asset a LoanManage default transaction is
    + * exempt from freeze/lock for.
    + *
    + * @param view Ledger view used to resolve the Loan -> LoanBroker -> Vault
    + * chain.
    + * @param tx The transaction under invariant review.
    + * @return The exempt accounts and asset if `tx` is a `ttLOAN_MANAGE`
    + * transaction with the `tfLoanDefault` flag set, `fixCleanup3_4_0` is
    + * enabled, and the loan/broker/vault objects it references can all be
    + * resolved; `std::nullopt` otherwise.
    + */
    +[[nodiscard]] std::optional
    +getLoanDefaultFreezeExemptAccounts(ReadView const& view, STTx const& tx);
    +
     static constexpr std::uint32_t kSecondsInYear = 365 * 24 * 60 * 60;
     
     Number
    diff --git a/include/xrpl/tx/invariants/FreezeInvariant.h b/include/xrpl/tx/invariants/FreezeInvariant.h
    index c66e002872..301e464daf 100644
    --- a/include/xrpl/tx/invariants/FreezeInvariant.h
    +++ b/include/xrpl/tx/invariants/FreezeInvariant.h
    @@ -2,6 +2,7 @@
     
     #include 
     #include 
    +#include 
     #include 
     #include 
     #include 
    @@ -11,6 +12,7 @@
     #include 
     
     #include 
    +#include 
     #include 
     
     namespace xrpl {
    @@ -70,7 +72,8 @@ private:
             STTx const& tx,
             beast::Journal const& j,
             bool enforce,
    -        bool fixOverrideFreeze);
    +        bool fixOverrideFreeze,
    +        std::optional const& loanDefaultAccounts);
     
         static bool
         validateFrozenState(
    @@ -80,7 +83,8 @@ private:
             beast::Journal const& j,
             bool enforce,
             bool globalFreeze,
    -        bool fixOverrideFreeze);
    +        bool fixOverrideFreeze,
    +        std::optional const& loanDefaultAccounts);
     };
     
     }  // namespace xrpl
    diff --git a/src/libxrpl/ledger/helpers/LendingHelpers.cpp b/src/libxrpl/ledger/helpers/LendingHelpers.cpp
    index 89b03a03a7..cf1bd4915f 100644
    --- a/src/libxrpl/ledger/helpers/LendingHelpers.cpp
    +++ b/src/libxrpl/ledger/helpers/LendingHelpers.cpp
    @@ -12,6 +12,7 @@
     #include 
     #include 
     #include 
    +#include 
     #include 
     #include 
     #include 
    @@ -20,12 +21,15 @@
     #include 
     #include 
     #include 
    +#include 
    +#include 
     #include 
     
     #include 
     #include 
     #include 
     #include 
    +#include 
     #include 
     #include 
     
    @@ -77,6 +81,40 @@ checkLendingProtocolDependencies(Rules const& rules, STTx const& tx)
         return true;
     }
     
    +std::optional
    +getLoanDefaultFreezeExemptAccounts(ReadView const& view, STTx const& tx)
    +{
    +    if (tx.getTxnType() != ttLOAN_MANAGE || !tx.isFlag(tfLoanDefault) ||
    +        !view.rules().enabled(fixCleanup3_4_0))
    +        return std::nullopt;
    +
    +    // Unlike the broker/vault lookups below, the submitter picks the LoanID,
    +    // so a nonexistent Loan is an ordinary (if unusual) input, not a
    +    // structural impossibility -- exercised directly in LendingHelpers_test.
    +    auto const loanSle = view.read(keylet::loan(tx[sfLoanID]));
    +    if (!loanSle)
    +        return std::nullopt;
    +
    +    // A Loan can't outlive its LoanBroker (LoanBrokerDelete's preclaim
    +    // rejects deletion while DebtTotal != 0), and a LoanBroker can't outlive
    +    // its Vault (VaultDelete's preclaim has the equivalent guard) -- so these
    +    // two lookups are structurally guaranteed to succeed here.
    +    auto const brokerSle = view.read(keylet::loanBroker(loanSle->at(sfLoanBrokerID)));
    +    if (!brokerSle)
    +        return std::nullopt;  // LCOV_EXCL_LINE
    +
    +    auto const vaultSle = view.read(keylet::vault(brokerSle->at(sfVaultID)));
    +    if (!vaultSle)
    +        return std::nullopt;  // LCOV_EXCL_LINE
    +
    +    Asset const vaultAsset = vaultSle->at(sfAsset);
    +    return LoanDefaultFreezeExemptAccounts{
    +        .issuer = vaultAsset.getIssuer(),
    +        .broker = brokerSle->at(sfAccount),
    +        .vault = vaultSle->at(sfAccount),
    +        .asset = vaultAsset};
    +}
    +
     LoanPaymentParts&
     LoanPaymentParts::operator+=(LoanPaymentParts const& other)
     {
    diff --git a/src/libxrpl/tx/invariants/FreezeInvariant.cpp b/src/libxrpl/tx/invariants/FreezeInvariant.cpp
    index c4340b9aec..d6039eabd8 100644
    --- a/src/libxrpl/tx/invariants/FreezeInvariant.cpp
    +++ b/src/libxrpl/tx/invariants/FreezeInvariant.cpp
    @@ -4,7 +4,9 @@
     #include 
     #include 
     #include 
    +#include 
     #include 
    +#include 
     #include 
     #include 
     #include 
    @@ -17,6 +19,7 @@
     #include 
     
     #include 
    +#include 
     #include 
     
     namespace xrpl {
    @@ -75,6 +78,20 @@ TransfersNotFrozen::finalize(
         [[maybe_unused]] bool const enforce = view.rules().enabled(featureDeepFreeze);
         bool const fixOverrideFreeze = view.rules().enabled(fixCleanup3_4_0);
     
    +    /*
    +     * XLS-0066: a broker must be able to default an already-late loan
    +     * regardless of the vault asset's freeze state. LoanManage::defaultLoan
    +     * moves First-Loss Capital from the broker to the vault pseudo-account via
    +     * accountSend, which transits through the issuer in two hops (see
    +     * getLoanDefaultFreezeExemptAccounts), so a frozen issuer would otherwise
    +     * trip this invariant on either hop. Gated behind fixCleanup3_4_0, and
    +     * scoped to exactly the issuer/broker and issuer/vault lines involved for
    +     * the vault's own currency, so ledgers without the amendment (or an
    +     * unrelated frozen currency/line touched by the same transaction) keep
    +     * the current (blocking) behavior.
    +     */
    +    auto const loanDefaultAccounts = getLoanDefaultFreezeExemptAccounts(view, tx);
    +
         return std::ranges::all_of(balanceChanges_, [&](auto const& entry) {
             auto const& [issue, changes] = entry;
             auto const issuerSle = findIssuer(issue.account, view);
    @@ -91,7 +108,8 @@ TransfersNotFrozen::finalize(
                 return !enforce;
             }
     
    -        return validateIssuerChanges(issuerSle, changes, tx, j, enforce, fixOverrideFreeze);
    +        return validateIssuerChanges(
    +            issuerSle, changes, tx, j, enforce, fixOverrideFreeze, loanDefaultAccounts);
         });
     }
     
    @@ -201,7 +219,8 @@ TransfersNotFrozen::validateIssuerChanges(
         STTx const& tx,
         beast::Journal const& j,
         bool enforce,
    -    bool fixOverrideFreeze)
    +    bool fixOverrideFreeze,
    +    std::optional const& loanDefaultAccounts)
     {
         if (!issuer)
         {
    @@ -227,7 +246,15 @@ TransfersNotFrozen::validateIssuerChanges(
             {
                 bool const high = change.line->at(sfLowLimit).getIssuer() == issuer->at(sfAccount);
     
    -            if (!validateFrozenState(change, high, tx, j, enforce, globalFreeze, fixOverrideFreeze))
    +            if (!validateFrozenState(
    +                    change,
    +                    high,
    +                    tx,
    +                    j,
    +                    enforce,
    +                    globalFreeze,
    +                    fixOverrideFreeze,
    +                    loanDefaultAccounts))
                 {
                     return false;
                 }
    @@ -244,7 +271,8 @@ TransfersNotFrozen::validateFrozenState(
         beast::Journal const& j,
         bool enforce,
         bool globalFreeze,
    -    bool fixOverrideFreeze)
    +    bool fixOverrideFreeze,
    +    std::optional const& loanDefaultAccounts)
     {
         bool const freeze =
             change.balanceChangeSign < 0 && change.line->isFlag(high ? lsfLowFreeze : lsfHighFreeze);
    @@ -269,6 +297,33 @@ TransfersNotFrozen::validateFrozenState(
             return true;
         }
     
    +    // XLS-0066: LoanManage::defaultLoan's transfer is exempt from freeze (see
    +    // finalize()). Since neither the broker nor vault pseudo-account is the
    +    // asset's issuer, accountSend routes it as two hops through the issuer
    +    // (broker -> issuer, issuer -> vault), so both the issuer/broker and
    +    // issuer/vault lines are exempt -- but only for the vault's own currency,
    +    // so an unrelated frozen line (a different currency, or one touched by
    +    // the same transaction for some other reason) is still caught.
    +    if (loanDefaultAccounts && loanDefaultAccounts->asset.holds() &&
    +        loanDefaultAccounts->asset.get().currency ==
    +            change.line->at(sfBalance).get().currency)
    +    {
    +        AccountID const lowAcct = change.line->at(sfLowLimit).getIssuer();
    +        AccountID const highAcct = change.line->at(sfHighLimit).getIssuer();
    +        auto const& accts = *loanDefaultAccounts;
    +        auto const isPair = [&](AccountID const& a, AccountID const& b) {
    +            return (lowAcct == a && highAcct == b) || (lowAcct == b && highAcct == a);
    +        };
    +        if (isPair(accts.issuer, accts.broker) || isPair(accts.issuer, accts.vault))
    +        {
    +            JLOG(j.debug()) << "Invariant check allowing funds to be moved "
    +                            << (change.balanceChangeSign > 0 ? "to" : "from")
    +                            << " a frozen trustline for LoanManage default "
    +                            << tx.getTransactionID();
    +            return true;
    +        }
    +    }
    +
         JLOG(j.fatal()) << "Invariant failed: Attempting to move frozen funds for "
                         << tx.getTransactionID();
         // The comment above starting with "assert(enforce)" explains this assert.
    diff --git a/src/libxrpl/tx/invariants/MPTInvariant.cpp b/src/libxrpl/tx/invariants/MPTInvariant.cpp
    index d323718bd2..9a7e96e44f 100644
    --- a/src/libxrpl/tx/invariants/MPTInvariant.cpp
    +++ b/src/libxrpl/tx/invariants/MPTInvariant.cpp
    @@ -7,6 +7,7 @@
     #include 
     #include 
     #include 
    +#include 
     #include 
     #include 
     #include 
    @@ -840,6 +841,14 @@ ValidMPTTransfer::finalize(
         if (hasPrivilege(tx, OverrideFreeze))
             return true;
     
    +    // XLS-0066: a broker must be able to default an already-late loan
    +    // regardless of the vault asset's lock state. Gated behind
    +    // fixCleanup3_4_0, and scoped below to exactly the broker/vault
    +    // pseudo-accounts and the vault's own MPT issuance -- see
    +    // FreezeInvariant.cpp's TransfersNotFrozen::finalize for the IOU-side
    +    // equivalent and rationale.
    +    auto const loanDefaultAccounts = getLoanDefaultFreezeExemptAccounts(view, tx);
    +
         // DEX transactions (AMM[Create,Deposit], cross-currency payments, offer creates) are
         // subject to the MPTCanTrade flag in addition to the standard transfer rules.
         // A payment is only DEX if it is a cross-currency payment.
    @@ -881,6 +890,13 @@ ValidMPTTransfer::finalize(
             auto const canTrade = sleIssuance->isFlag(lsfMPTCanTrade);
             auto const reqAuth = sleIssuance->isFlag(lsfMPTRequireAuth);
     
    +        // This issuance is the LoanManage default's own vault asset, so the
    +        // broker/vault freeze exemption applies to it -- an unrelated MPT
    +        // issuance the same accounts happen to hold is still caught.
    +        bool const isLoanDefaultAsset = loanDefaultAccounts &&
    +            loanDefaultAccounts->asset.holds() &&
    +            loanDefaultAccounts->asset.get().getMptID() == mptID;
    +
             for (auto const& [account, value] : values)
             {
                 // Classify each account as a sender or receiver based on whether their MPTAmount
    @@ -899,8 +915,15 @@ ValidMPTTransfer::finalize(
     
                     // Check once: if any involved account is frozen, the whole issuance transfer is
                     // considered frozen. Only need to check for frozen if there is a transfer of funds.
    +                //
    +                // The LoanManage default exemption only waives the frozen check, and only for
    +                // the specific broker/vault pseudo-accounts identified above -- authorization is
    +                // still enforced for them, and both checks still apply to every other account.
    +                bool const exemptFromFreeze = isLoanDefaultAsset && loanDefaultAccounts &&
    +                    (account == loanDefaultAccounts->broker ||
    +                     account == loanDefaultAccounts->vault);
                     if (!invalidTransfer &&
    -                    (isFrozen(view, account, *sleIssuance) ||
    +                    ((!exemptFromFreeze && isFrozen(view, account, *sleIssuance)) ||
                          !isAuthorized(view, mptID, account, reqAuth)))
                     {
                         invalidTransfer = true;
    diff --git a/src/test/app/lending/LendingHelpers_test.cpp b/src/test/app/lending/LendingHelpers_test.cpp
    index 909b617980..32c49feb02 100644
    --- a/src/test/app/lending/LendingHelpers_test.cpp
    +++ b/src/test/app/lending/LendingHelpers_test.cpp
    @@ -2,18 +2,27 @@
     // DO NOT REMOVE
     #include 
     #include 
    +#include 
     #include 
    +#include 
    +#include 
    +#include 
    +#include 
     
     #include 
     #include 
     #include 
     #include 
    +#include 
    +#include 
     #include 
     #include 
     #include 
     #include 
     #include 
    +#include 
     #include 
    +#include 
     #include 
     
     #include 
    @@ -1871,6 +1880,93 @@ public:
             }
         }
     
    +    // Targeted unit test for getLoanDefaultFreezeExemptAccounts(): builds a real
    +    // (XRP, so no trust lines needed) Vault/LoanBroker/Loan chain, then calls
    +    // the function directly against hand-picked, unsubmitted transactions
    +    // (via env.jt(), which never touches the ledger) to exercise every early
    +    // return and the success path precisely.
    +    void
    +    testLoanDefaultFreezeExemptAccounts()
    +    {
    +        using namespace jtx;
    +        using namespace loan;
    +
    +        testcase("getLoanDefaultFreezeExemptAccounts");
    +
    +        Account const lender{"lender"};
    +        Account const borrower{"borrower"};
    +
    +        Env env{*this};
    +        Vault const vault{env};
    +        env.fund(XRP(10'000), lender, borrower);
    +        env.close();
    +
    +        auto [vaultTx, vaultKeylet] = vault.create({.owner = lender, .asset = xrpIssue()});
    +        env(vaultTx);
    +        env.close();
    +        env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = XRP(1'000)}));
    +        env.close();
    +
    +        auto const brokerKeylet =
    +            keylet::loanBroker(lender.id(), SeqProxy::rawSequence(env.seq(lender)));
    +        env(loan_broker::set(lender, vaultKeylet.key));
    +        env.close();
    +
    +        env(set(borrower, brokerKeylet.key, Number{200'000}),
    +            Sig(sfCounterpartySignature, lender),
    +            Fee(env.current()->fees().base * 2));
    +        env.close();
    +
    +        auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1));
    +
    +        // Not a LoanManage transaction at all.
    +        {
    +            auto const jt = env.jt(jtx::pay(lender, borrower, XRP(1)));
    +            BEAST_EXPECT(!getLoanDefaultFreezeExemptAccounts(*env.current(), *jt.stx));
    +        }
    +
    +        // LoanManage, but not the tfLoanDefault flag.
    +        {
    +            auto const jt = env.jt(manage(lender, loanKeylet.key, tfLoanImpair));
    +            BEAST_EXPECT(!getLoanDefaultFreezeExemptAccounts(*env.current(), *jt.stx));
    +        }
    +
    +        // tfLoanDefault, but fixCleanup3_4_0 is disabled.
    +        {
    +            env.disableFeature(fixCleanup3_4_0);
    +            auto const jt = env.jt(manage(lender, loanKeylet.key, tfLoanDefault));
    +            BEAST_EXPECT(!getLoanDefaultFreezeExemptAccounts(*env.current(), *jt.stx));
    +            env.enableFeature(fixCleanup3_4_0);
    +        }
    +
    +        // tfLoanDefault, amendment enabled, but the referenced Loan doesn't
    +        // exist (reusing the broker's own ID as a bogus LoanID, same trick
    +        // testInvalidLoanManage-style tests use elsewhere in this suite).
    +        {
    +            auto const jt = env.jt(manage(lender, brokerKeylet.key, tfLoanDefault));
    +            BEAST_EXPECT(!getLoanDefaultFreezeExemptAccounts(*env.current(), *jt.stx));
    +        }
    +
    +        // tfLoanDefault, amendment enabled, Loan/LoanBroker/Vault all exist:
    +        // resolves the issuer, broker, vault accounts, and the vault's asset.
    +        {
    +            auto const jt = env.jt(manage(lender, loanKeylet.key, tfLoanDefault));
    +            auto const result = getLoanDefaultFreezeExemptAccounts(*env.current(), *jt.stx);
    +            auto const brokerSle = env.le(brokerKeylet);
    +            auto const vaultSle = env.le(vaultKeylet);
    +            BEAST_EXPECT(result);
    +            BEAST_EXPECT(brokerSle);
    +            BEAST_EXPECT(vaultSle);
    +            if (result && brokerSle && vaultSle)
    +            {
    +                BEAST_EXPECT(result->issuer == vaultSle->at(sfAsset).getIssuer());
    +                BEAST_EXPECT(result->broker == brokerSle->at(sfAccount));
    +                BEAST_EXPECT(result->vault == vaultSle->at(sfAccount));
    +                BEAST_EXPECT(result->asset == vaultSle->at(sfAsset));
    +            }
    +        }
    +    }
    +
         void
         run() override
         {
    @@ -1906,6 +2002,8 @@ public:
             testLoanOriginationExceedsVaultMaximumDispatcher();
             testLoanVaultExposureDispatcher();
             testLoanPaymentDeltasDispatcher();
    +
    +        testLoanDefaultFreezeExemptAccounts();
         }
     };
     
    diff --git a/src/test/app/lending/LoanCoverFreezeAuth_test.cpp b/src/test/app/lending/LoanCoverFreezeAuth_test.cpp
    index a9b3542c4e..b0c43190c5 100644
    --- a/src/test/app/lending/LoanCoverFreezeAuth_test.cpp
    +++ b/src/test/app/lending/LoanCoverFreezeAuth_test.cpp
    @@ -5,6 +5,7 @@
     #include 
     #include 
     #include 
    +#include 
     #include 
     #include 
     #include 
    @@ -13,6 +14,7 @@
     #include 
     
     #include 
    +#include 
     #include 
     #include 
     #include 
    @@ -368,6 +370,186 @@ private:
             };
         }
     
    +    void
    +    testLoanDefaultBypassesFreeze()
    +    {
    +        testcase("LoanManage: default bypasses asset freeze");
    +        using namespace jtx;
    +        using namespace loan;
    +        Account const lender{"lender"};
    +        Account const issuer{"issuer"};
    +        Account const borrower{"borrower"};
    +        auto const iou = issuer["IOU"];
    +
    +        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 const 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();
    +
    +        auto const loanKeylet = keylet::loan(brokerInfo.brokerID, SeqProxy::rawSequence(1));
    +
    +        using tp = NetClock::time_point;
    +        using d = NetClock::duration;
    +
    +        // Get past the grace period so the loan is defaultable.
    +        if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan))
    +        {
    +            env.close(tp{d{loan->at(sfNextPaymentDueDate) + loan->at(sfGracePeriod) + 1}});
    +        }
    +
    +        // Global freeze trips the post-apply TransfersNotFrozen invariant.
    +        env(fset(issuer, asfGlobalFreeze));
    +        env.close();
    +
    +        // Pre-fixCleanup3_4_0, the invariant blocks the default.
    +        env.disableFeature(fixCleanup3_4_0);
    +        env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecINVARIANT_FAILED));
    +        env.close();
    +
    +        // Per XLS-0066, a default must succeed despite the freeze.
    +        env.enableFeature(fixCleanup3_4_0);
    +        env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS));
    +    }
    +
    +    // A default must bypass an MPT global lock the same way it bypasses IOU
    +    // freeze, including when the loan was already impaired beforehand
    +    // (a different defaultLoan() accounting branch than the un-impaired
    +    // path exercised above) and after an ordinary LoanPay was correctly
    +    // blocked by the same lock.
    +    void
    +    testLoanDefaultBypassesMptLockAfterImpair()
    +    {
    +        testcase("LoanManage: default bypasses MPT lock after impairment");
    +        using namespace jtx;
    +        using namespace loan;
    +
    +        Account const issuer{"issuer"};
    +        Account const lender{"lender"};
    +        Account const borrower{"borrower"};
    +
    +        Env env(*this);
    +        env.fund(XRP(1'000'000), issuer, lender, borrower);
    +        env.close();
    +
    +        MPTTester mptt(
    +            {.env = env,
    +             .issuer = issuer,
    +             .holders = {lender, borrower},
    +             .flags = tfMPTCanTransfer | tfMPTCanLock});
    +        PrettyAsset const asset = mptt.issuanceID();
    +        env(pay(issuer, lender, asset(10'000'000)));
    +        env.close();
    +
    +        BrokerInfo const brokerInfo{createVaultAndBroker(env, asset, 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();
    +
    +        auto const loanKeylet = keylet::loan(brokerInfo.brokerID, SeqProxy::rawSequence(1));
    +
    +        // Realize a loss via impairment before locking.
    +        env(manage(lender, loanKeylet.key, tfLoanImpair));
    +        env.close();
    +
    +        // Issuer applies a global lock.
    +        mptt.set({.account = issuer, .flags = tfMPTLock});
    +        env.close();
    +
    +        // An ordinary payment is correctly blocked by the lock.
    +        env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecLOCKED));
    +        env.close();
    +
    +        using tp = NetClock::time_point;
    +        using d = NetClock::duration;
    +        if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan))
    +        {
    +            env.close(tp{d{loan->at(sfNextPaymentDueDate) + loan->at(sfGracePeriod) + 1}});
    +        }
    +
    +        // Pre-fixCleanup3_4_0 the ValidMPTTransfer invariant blocks the
    +        // default, mirroring the IOU path above.
    +        env.disableFeature(fixCleanup3_4_0);
    +        env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecINVARIANT_FAILED));
    +        env.close();
    +
    +        // The default itself must succeed despite the lock.
    +        env.enableFeature(fixCleanup3_4_0);
    +        env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS));
    +    }
    +
    +    // The exemption must hold for an individually deep-frozen trust line, not
    +    // just a global freeze: deep freeze is what the original report ran into,
    +    // and it takes a different path through validateFrozenState (the frozen
    +    // flag comes off the line rather than off the issuer).
    +    void
    +    testLoanDefaultBypassesDeepFreeze()
    +    {
    +        testcase("LoanManage: default bypasses asset deep freeze");
    +        using namespace jtx;
    +        using namespace loan;
    +        Account const lender{"lender"};
    +        Account const issuer{"issuer"};
    +        Account const borrower{"borrower"};
    +        auto const iou = issuer["IOU"];
    +
    +        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 const 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();
    +
    +        auto const loanKeylet = keylet::loan(brokerInfo.brokerID, SeqProxy::rawSequence(1));
    +
    +        using tp = NetClock::time_point;
    +        using d = NetClock::duration;
    +
    +        // Get past the grace period so the loan is defaultable.
    +        if (auto loan = env.le(loanKeylet); BEAST_EXPECT(loan))
    +        {
    +            env.close(tp{d{loan->at(sfNextPaymentDueDate) + loan->at(sfGracePeriod) + 1}});
    +        }
    +
    +        // The default moves First-Loss Capital off the broker pseudo-account,
    +        // so that is the line to freeze.
    +        auto const brokerSle = env.le(brokerInfo.brokerKeylet());
    +        if (!BEAST_EXPECT(brokerSle))
    +            return;
    +        Account const brokerPseudo{"brokerPseudo", brokerSle->at(sfAccount)};
    +
    +        env(trust(issuer, brokerPseudo["IOU"](0), tfSetFreeze | tfSetDeepFreeze));
    +        env.close();
    +
    +        // Pre-fixCleanup3_4_0, the invariant blocks the default.
    +        env.disableFeature(fixCleanup3_4_0);
    +        env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecINVARIANT_FAILED));
    +        env.close();
    +
    +        // Per XLS-0066, a default must succeed despite the deep freeze.
    +        env.enableFeature(fixCleanup3_4_0);
    +        env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS));
    +    }
    +
         void
         testLoanPayBrokerOwnerMissingTrustline(FeatureBitset features)
         {
    @@ -694,6 +876,9 @@ private:
         runAmendmentIndependent()
         {
             testServiceFeeOnBrokerDeepFreeze();
    +        testLoanDefaultBypassesFreeze();
    +        testLoanDefaultBypassesDeepFreeze();
    +        testLoanDefaultBypassesMptLockAfterImpair();
         }
     
         // Tests run under each entry in amendmentCombinations().
    
    From 1be48688755dc41e7f8c52e608bff9a61c684ffe Mon Sep 17 00:00:00 2001
    From: Ayaz Salikhov 
    Date: Wed, 19 Aug 2026 13:46:11 +0000
    Subject: [PATCH 098/102] build: Sign RPM packages (#8046)
    
    ---
     .github/scripts/strategy-matrix/linux.json |  4 +-
     .github/workflows/on-tag.yml               |  1 +
     .github/workflows/on-trigger.yml           |  1 +
     .github/workflows/reusable-package.yml     | 11 ++++
     package/README.md                          | 20 +++++--
     package/sign_rpm.sh                        | 65 ++++++++++++++++++++++
     6 files changed, 94 insertions(+), 8 deletions(-)
     create mode 100755 package/sign_rpm.sh
    
    diff --git a/.github/scripts/strategy-matrix/linux.json b/.github/scripts/strategy-matrix/linux.json
    index bd3446f599..e739a42d5a 100644
    --- a/.github/scripts/strategy-matrix/linux.json
    +++ b/.github/scripts/strategy-matrix/linux.json
    @@ -92,7 +92,7 @@
             "build_type": ["Release"],
             "arch": ["amd64"],
             "minimal": false,
    -        "image": "ghcr.io/xrplf/xrpld/packaging-debian:sha-028ccea"
    +        "image": "ghcr.io/xrplf/xrpld/packaging-debian:sha-a6983f8"
           }
         ],
     
    @@ -102,7 +102,7 @@
             "build_type": ["Release"],
             "arch": ["amd64"],
             "minimal": false,
    -        "image": "ghcr.io/xrplf/xrpld/packaging-rhel:sha-028ccea"
    +        "image": "ghcr.io/xrplf/xrpld/packaging-rhel:sha-a6983f8"
           }
         ]
       }
    diff --git a/.github/workflows/on-tag.yml b/.github/workflows/on-tag.yml
    index 1c9fb414f2..d8a9a5113e 100644
    --- a/.github/workflows/on-tag.yml
    +++ b/.github/workflows/on-tag.yml
    @@ -49,3 +49,4 @@ jobs:
         secrets:
           remote_username: ${{ secrets.NEXUS_REMOTE_USERNAME }}
           remote_password: ${{ secrets.NEXUS_REMOTE_PASSWORD }}
    +      signing_key: ${{ secrets.NEXUS_PACKAGES_PRIVATE_KEY }}
    diff --git a/.github/workflows/on-trigger.yml b/.github/workflows/on-trigger.yml
    index 0d679318a9..1da0f47bc4 100644
    --- a/.github/workflows/on-trigger.yml
    +++ b/.github/workflows/on-trigger.yml
    @@ -120,3 +120,4 @@ jobs:
         secrets:
           remote_username: ${{ secrets.NEXUS_REMOTE_USERNAME }}
           remote_password: ${{ secrets.NEXUS_REMOTE_PASSWORD }}
    +      signing_key: ${{ secrets.NEXUS_PACKAGES_PRIVATE_KEY }}
    diff --git a/.github/workflows/reusable-package.yml b/.github/workflows/reusable-package.yml
    index 430072b627..cfae706ee1 100644
    --- a/.github/workflows/reusable-package.yml
    +++ b/.github/workflows/reusable-package.yml
    @@ -29,6 +29,9 @@ on:
           remote_password:
             description: "The password or token for that Nexus account."
             required: false
    +      signing_key:
    +        description: "Armoured PGP private key used to sign the RPMs. Required when publishing."
    +        required: false
     
     defaults:
       run:
    @@ -98,6 +101,14 @@ jobs:
               PKG_CHANNEL: ${{ steps.release_info.outputs.channel }}
             run: ./package/build_pkg.sh
     
    +      # Before the upload, so the artifact and the published package are the
    +      # same bytes. DEBs are not signed, so the key is never set on that job.
    +      - name: Sign RPM
    +        if: ${{ inputs.publish && matrix.distro == 'rhel' }}
    +        env:
    +          PKG_SIGNING_KEY: ${{ secrets.signing_key }}
    +        run: ./package/sign_rpm.sh "${BUILD_DIR}"
    +
           - name: Upload package artifact
             uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
             with:
    diff --git a/package/README.md b/package/README.md
    index 4899ee203e..516dcf9d9b 100644
    --- a/package/README.md
    +++ b/package/README.md
    @@ -9,6 +9,7 @@ a build configured with `-Dvalidator_keys=ON`.
     ```
     package/
       build_pkg.sh        Staging and build script (called by the CMake `package` target and CI)
    +  sign_rpm.sh         Signs the built RPMs (called by CI when publishing)
       publish_pkg.sh      Uploads built packages to the XRPLF Nexus repositories (called by CI)
       rpm/
         xrpld.spec      RPM spec
    @@ -32,7 +33,7 @@ package manager (`apt-get` -> deb, `dnf`/`yum` -> rpm).
     
     | Package type | Image (`package_configs.[].image` in `linux.json`) | Tools required                                      |
     | ------------ | ---------------------------------------------------------- | --------------------------------------------------- |
    -| RPM          | `ghcr.io/xrplf/xrpld/packaging-rhel:sha-`             | `rpmbuild`                                          |
    +| RPM          | `ghcr.io/xrplf/xrpld/packaging-rhel:sha-`             | `rpmbuild`, `rpmsign`                               |
     | DEB          | `ghcr.io/xrplf/xrpld/packaging-debian:sha-`           | `dpkg-buildpackage`, debhelper with compat level 13 |
     
     To print the full packaging matrix (artifact names and images) for the current
    @@ -152,11 +153,14 @@ any `XRPLF` repository, `on-pr.yml` never. Both authenticate with the
     `NEXUS_REMOTE_USERNAME` / `NEXUS_REMOTE_PASSWORD` secrets already used for the
     Conan remote.
     
    -Nexus owns the repository metadata; nothing here signs or indexes anything. Worth
    -knowing:
    +Nexus owns the repository metadata; nothing here indexes anything. Worth knowing:
     
     - Each apt-hosted repository needs a distribution and a PGP signing keypair
    -  configured in Nexus, which rejects one created without a keypair.
    +  configured in Nexus, which rejects one created without a keypair. Nexus signs
    +  the apt metadata with it, never the packages.
    +- Hosted yum repositories cannot be signed by Nexus at all, so `sign_rpm.sh`
    +  signs the RPMs before they are uploaded, and rpm clients verify with
    +  `gpgcheck=1` rather than `repo_gpgcheck=1`.
     - yum metadata is rebuilt asynchronously, so a successful publish is not
       immediately installable.
     - Each job uploads only what it built, and uploads are not transactional, so a
    @@ -212,8 +216,12 @@ fail early.
     Flags are for explicit invocation; environment variables are intended for
     CMake/CI integration. The CI workflow and the CMake `package` target both invoke
     `build_pkg.sh` with no flags; CMake supplies `SRC_DIR`, `BUILD_DIR`, and
    -`PKG_RELEASE` via env, while CI supplies `BUILD_DIR` and `PKG_RELEASE` via env
    -and lets the script use defaults for the rest.
    +`PKG_RELEASE` via env, while CI supplies `BUILD_DIR`, `PKG_RELEASE` and
    +`PKG_CHANNEL` via env and lets the script use defaults for the rest.
    +
    +Signing is not part of this script. `sign_rpm.sh` does it in a separate CI step
    +that only runs when publishing, so a published RPM is always signed and a local
    +build never needs a key.
     
     It resolves `SRC_DIR` and `BUILD_DIR` to absolute paths, then calls
     `stage_common()` to copy the `xrpld` and `validator-keys` binaries, config files,
    diff --git a/package/sign_rpm.sh b/package/sign_rpm.sh
    new file mode 100755
    index 0000000000..7a1d6f00e3
    --- /dev/null
    +++ b/package/sign_rpm.sh
    @@ -0,0 +1,65 @@
    +#!/usr/bin/env bash
    +set -euo pipefail
    +
    +# Sign the RPMs built by build_pkg.sh. Nexus cannot sign hosted yum metadata, so
    +# the packages carry the signature themselves and rpm clients verify them with
    +# gpgcheck=1.
    +#
    +# Usage: sign_rpm.sh [package-dir]
    +#
    +#   package-dir  searched recursively for *.rpm ('build' by default)
    +#
    +# PKG_SIGNING_KEY must hold an armoured PGP private key. It has no flag, to keep
    +# the key out of the process list.
    +#
    +# There is no DEB equivalent: apt trusts the repository metadata, which Nexus
    +# signs, rather than the packages themselves.
    +
    +pkg_dir="${1:-build}"
    +
    +mapfile -d '' rpms < <(find "${pkg_dir}" -type f -name '*.rpm' -print0)
    +
    +# Signing nothing would otherwise look like a successful signing.
    +if [[ ${#rpms[@]} -eq 0 ]]; then
    +    echo "sign_rpm.sh: no RPMs found in ${pkg_dir}." >&2
    +    exit 1
    +fi
    +
    +: "${PKG_SIGNING_KEY:?is required}"
    +
    +# Global, and expanded by the trap when it fires: the keyring holds an
    +# unencrypted private key, so it must go even if signing fails.
    +signing_home="$(mktemp -d)"
    +trap 'rm -rf "${signing_home}"' EXIT
    +export GNUPGHOME="${signing_home}"
    +
    +printf '%s' "${PKG_SIGNING_KEY}" | gpg --batch --quiet --import
    +
    +# Exactly one secret key, so that picking the first below is not a guess between
    +# several.
    +secrets="$(gpg --list-secret-keys --with-colons | grep -c '^sec:' || true)"
    +if [[ "${secrets}" -ne 1 ]]; then
    +    echo "sign_rpm.sh: PKG_SIGNING_KEY must hold exactly one secret key, found ${secrets}." >&2
    +    exit 1
    +fi
    +
    +key="$(gpg --list-secret-keys --with-colons | awk -F: '/^fpr:/ { print $10; exit }')"
    +echo "Signing ${#rpms[@]} RPM(s) with ${key}."
    +
    +# Loopback pinentry: the key is unattended, so there is no tty to prompt on.
    +rpmsign \
    +    --define "_gpg_name ${key}" \
    +    --define "_gpg_sign_cmd_extra_args --pinentry-mode loopback --batch --yes" \
    +    --addsign "${rpms[@]}"
    +
    +# rpmsign can exit 0 having attached nothing, and an unsigned package is only
    +# rejected later, on the installing machine. Both header tags are checked
    +# because an RSA signature lands in RSAHEADER and a DSA or EdDSA one in
    +# DSAHEADER.
    +for pkg in "${rpms[@]}"; do
    +    signature="$(rpm --query --queryformat '%{RSAHEADER:pgpsig}%{DSAHEADER:pgpsig}' --package "${pkg}")"
    +    if [[ "${signature}" == "(none)(none)" ]]; then
    +        echo "sign_rpm.sh: ${pkg} is unsigned after rpmsign." >&2
    +        exit 1
    +    fi
    +done
    
    From 563986371564252dbf735d959407e357d107029b Mon Sep 17 00:00:00 2001
    From: Ayaz Salikhov 
    Date: Wed, 19 Aug 2026 14:02:42 +0000
    Subject: [PATCH 099/102] docs: Rewrite the install guide (#8048)
    
    ---
     .github/scripts/rename/binary.sh             |   2 +-
     .github/scripts/rename/docs.sh               |   4 +-
     docs/{build/install.md => install-legacy.md} |  21 ++-
     docs/install.md                              | 144 +++++++++++++++++++
     package/shared/xrpld.service                 |   4 -
     5 files changed, 161 insertions(+), 14 deletions(-)
     rename docs/{build/install.md => install-legacy.md} (87%)
     create mode 100644 docs/install.md
    
    diff --git a/.github/scripts/rename/binary.sh b/.github/scripts/rename/binary.sh
    index 89d884538c..4a3e86675a 100755
    --- a/.github/scripts/rename/binary.sh
    +++ b/.github/scripts/rename/binary.sh
    @@ -49,7 +49,7 @@ ${SED_COMMAND} -i -E 's@ripple/xrpld@XRPLF/rippled@g' BUILD.md
     ${SED_COMMAND} -i -E 's@XRPLF/xrpld@XRPLF/rippled@g' BUILD.md
     ${SED_COMMAND} -i -E 's@xrpld \(`xrpld`\)@xrpld@g' BUILD.md
     ${SED_COMMAND} -i -E 's@XRPLF/xrpld@XRPLF/rippled@g' CONTRIBUTING.md
    -${SED_COMMAND} -i -E 's@XRPLF/xrpld@XRPLF/rippled@g' docs/build/install.md
    +${SED_COMMAND} -i -E 's@XRPLF/xrpld@XRPLF/rippled@g' docs/install.md
     
     popd
     echo "Processing complete."
    diff --git a/.github/scripts/rename/docs.sh b/.github/scripts/rename/docs.sh
    index 9f080b06e5..9d7be209a3 100755
    --- a/.github/scripts/rename/docs.sh
    +++ b/.github/scripts/rename/docs.sh
    @@ -77,8 +77,8 @@ ${SED_COMMAND} -i 's/Ripple integrators/XRPL developers/' README.md
     ${SED_COMMAND} -i 's/sanitizer-configuration-for-rippled/sanitizer-configuration-for-xrpld/' docs/build/sanitizers.md
     ${SED_COMMAND} -i 's/rippled/xrpld/g' .github/scripts/levelization/README.md
     ${SED_COMMAND} -i 's/rippled/xrpld/g' .github/scripts/strategy-matrix/generate.py
    -${SED_COMMAND} -i 's@/rippled@/xrpld@g' docs/build/install.md
    -${SED_COMMAND} -i 's@github.com/XRPLF/xrpld@github.com/XRPLF/rippled@g' docs/build/install.md
    +${SED_COMMAND} -i 's@/rippled@/xrpld@g' docs/install.md
    +${SED_COMMAND} -i 's@github.com/XRPLF/xrpld@github.com/XRPLF/rippled@g' docs/install.md
     ${SED_COMMAND} -i 's/rippled/xrpld/g' docs/Doxyfile
     ${SED_COMMAND} -i 's/ripple_basics/basics/' include/xrpl/basics/CountedObject.h
     ${SED_COMMAND} -i 's/ [!IMPORTANT]
    +> These instructions apply to xrpld 3.3.0 and earlier, published to
    +> repos.ripple.com.
    +> For later releases see [install.md](./install.md).
    +
     This document contains instructions for installing xrpld.
     The APT package manager is common on Debian-based Linux distributions like
     Ubuntu,
    @@ -52,7 +59,7 @@ The default [prefix][1] is typically `/usr/local` on Linux and macOS and
     
     5.  Add the appropriate XRPL repository for your operating system version:
     
    -        echo "deb [signed-by=/usr/local/share/keyrings/ripple-key.gpg] https://repos.ripple.com/repos/xrpld-deb focal stable" | \
    +        echo "deb [signed-by=/usr/local/share/keyrings/ripple-key.gpg] https://repos.ripple.com/repos/rippled-deb focal stable" | \
                 sudo tee -a /etc/apt/sources.list.d/ripple.list
     
         The above example is appropriate for **Ubuntu 20.04 Focal Fossa**. For other operating systems, replace the word `focal` with one of the following:
    @@ -106,8 +113,8 @@ The default [prefix][1] is typically `/usr/local` on Linux and macOS and
             enabled=1
             gpgcheck=0
             repo_gpgcheck=1
    -        baseurl=https://repos.ripple.com/repos/xrpld-rpm/stable/
    -        gpgkey=https://repos.ripple.com/repos/xrpld-rpm/stable/repodata/repomd.xml.key
    +        baseurl=https://repos.ripple.com/repos/rippled-rpm/stable/
    +        gpgkey=https://repos.ripple.com/repos/rippled-rpm/stable/repodata/repomd.xml.key
             REPOFILE
     
         _Unstable_
    @@ -118,8 +125,8 @@ The default [prefix][1] is typically `/usr/local` on Linux and macOS and
             enabled=1
             gpgcheck=0
             repo_gpgcheck=1
    -        baseurl=https://repos.ripple.com/repos/xrpld-rpm/unstable/
    -        gpgkey=https://repos.ripple.com/repos/xrpld-rpm/unstable/repodata/repomd.xml.key
    +        baseurl=https://repos.ripple.com/repos/rippled-rpm/unstable/
    +        gpgkey=https://repos.ripple.com/repos/rippled-rpm/unstable/repodata/repomd.xml.key
             REPOFILE
     
         _Nightly_
    @@ -130,8 +137,8 @@ The default [prefix][1] is typically `/usr/local` on Linux and macOS and
             enabled=1
             gpgcheck=0
             repo_gpgcheck=1
    -        baseurl=https://repos.ripple.com/repos/xrpld-rpm/nightly/
    -        gpgkey=https://repos.ripple.com/repos/xrpld-rpm/nightly/repodata/repomd.xml.key
    +        baseurl=https://repos.ripple.com/repos/rippled-rpm/nightly/
    +        gpgkey=https://repos.ripple.com/repos/rippled-rpm/nightly/repodata/repomd.xml.key
             REPOFILE
     
     2.  Fetch the latest repo updates:
    diff --git a/docs/install.md b/docs/install.md
    new file mode 100644
    index 0000000000..9699150fdb
    --- /dev/null
    +++ b/docs/install.md
    @@ -0,0 +1,144 @@
    +# Installing xrpld
    +
    +> [!NOTE]
    +> These instructions apply to packages published from 2026-08-19 onwards.
    +> For xrpld 3.3.0 and earlier see [install-legacy.md](./install-legacy.md).
    +
    +`xrpld` is published as DEB and RPM packages for 64-bit x86 Linux.
    +Use APT on Debian-based distributions such as Debian and Ubuntu,
    +and YUM on Red Hat-based distributions such as RHEL, AlmaLinux, and Rocky Linux.
    +To build from source instead, see [BUILD.md](../BUILD.md).
    +
    +## Release channels
    +
    +Packages are published to four channels:
    +
    +- `stable` - the latest production release
    +- `unstable` - release candidates
    +- `experimental` - beta builds
    +- `develop` - every push to the [`develop` branch](https://github.com/XRPLF/rippled/tree/develop)
    +
    +See [Publishing packages](../package/README.md#publishing-packages) for how channels are produced.
    +
    +The instructions below use `stable`.
    +To follow another channel, replace `stable` with its name
    +wherever it appears in the repository configuration.
    +
    +> [!WARNING]
    +> Channels other than `stable` may be broken at any time.
    +> Do not use them for production servers.
    +
    +## Install the xrpld package
    +
    +### With the APT package manager
    +
    +1.  Install utilities:
    +
    +    ```bash
    +    sudo apt update -y
    +    sudo apt install -y apt-transport-https ca-certificates curl gnupg
    +    ```
    +
    +2.  Add the XRPL Foundation package-signing key to your list of trusted keys:
    +
    +    ```bash
    +    sudo install -d -m 0755 /etc/apt/keyrings
    +    sudo curl -fsS https://packages.xrplf.org/xrplf.asc -o /etc/apt/keyrings/xrplf.asc
    +    ```
    +
    +3.  Check the fingerprint of the newly-added key:
    +
    +    ```bash
    +    gpg --show-keys /etc/apt/keyrings/xrplf.asc
    +    ```
    +
    +    The output should be:
    +
    +    ```text
    +    pub   rsa4096 2026-08-18 [SC]
    +          B655416741221F780FBCFBC9AA84D41A11D29FA9
    +    uid                      XRPLF Packages 
    +    ```
    +
    +    In particular, make sure that the fingerprint matches.
    +
    +4.  Add the repository, using the channel you picked in [Release channels](#release-channels):
    +
    +    ```bash
    +    echo "deb [signed-by=/etc/apt/keyrings/xrplf.asc] https://packages.xrplf.org/repository/deb-stable focal main" | \
    +        sudo tee /etc/apt/sources.list.d/xrplf.list
    +    ```
    +
    +5.  Fetch the repository:
    +
    +    ```bash
    +    sudo apt -y update
    +    ```
    +
    +6.  Install the `xrpld` software package:
    +
    +    ```bash
    +    sudo apt -y install xrpld
    +    ```
    +
    +### With the YUM package manager
    +
    +1.  Add the XRPL Foundation package-signing key:
    +
    +    ```bash
    +    sudo rpm --import https://packages.xrplf.org/xrplf.asc
    +    ```
    +
    +2.  Add the repository, using the channel you picked in [Release channels](#release-channels):
    +
    +    ```bash
    +    cat << REPOFILE | sudo tee /etc/yum.repos.d/xrplf.repo
    +    [xrplf-stable]
    +    name=XRP Ledger Packages
    +    enabled=1
    +    baseurl=https://packages.xrplf.org/repository/rpm-stable/
    +    gpgcheck=1
    +    repo_gpgcheck=0
    +    gpgkey=https://packages.xrplf.org/xrplf.asc
    +    REPOFILE
    +    ```
    +
    +    `gpgcheck=1` verifies each package against the key above.
    +    `repo_gpgcheck` is off because the repository metadata is generated by the server and is not signed.
    +
    +3.  Install the `xrpld` package:
    +
    +    ```bash
    +    sudo yum install -y xrpld
    +    ```
    +
    +## The xrpld service
    +
    +Both package managers install a systemd unit and enable it, so `xrpld` starts on boot.
    +Check whether it is already running:
    +
    +```bash
    +systemctl status xrpld.service
    +```
    +
    +The APT packages start it immediately as well; the YUM packages do not, so start it yourself:
    +
    +```bash
    +sudo systemctl start xrpld.service
    +```
    +
    +### Optional: binding to privileged ports
    +
    +To serve incoming API requests on port 80 or 443, grant the service the capability to bind them.
    +You must also update the config file's port settings.
    +
    +```bash
    +sudo install -d -m 0755 /etc/systemd/system/xrpld.service.d
    +sudo tee /etc/systemd/system/xrpld.service.d/privileged-ports.conf >/dev/null <<'EOF'
    +[Service]
    +CapabilityBoundingSet=CAP_NET_BIND_SERVICE
    +AmbientCapabilities=CAP_NET_BIND_SERVICE
    +EOF
    +sudo systemctl daemon-reload
    +sudo systemctl restart xrpld.service
    +```
    diff --git a/package/shared/xrpld.service b/package/shared/xrpld.service
    index f54e47aa14..22e6359ef0 100644
    --- a/package/shared/xrpld.service
    +++ b/package/shared/xrpld.service
    @@ -24,9 +24,5 @@ LogsDirectoryMode=0750
     LimitNOFILE=65536
     SystemCallArchitectures=native
     
    -# Uncomment both lines to allow xrpld to bind to privileged ports (<1024)
    -#CapabilityBoundingSet=CAP_NET_BIND_SERVICE
    -#AmbientCapabilities=CAP_NET_BIND_SERVICE
    -
     [Install]
     WantedBy=multi-user.target
    
    From d1dc7a6ccf7541212ee7fc15298ab9fa91aea27c Mon Sep 17 00:00:00 2001
    From: Vito Tumas <5780819+Tapanito@users.noreply.github.com>
    Date: Wed, 19 Aug 2026 14:10:11 +0000
    Subject: [PATCH 100/102] refactor: Extract invariant invocation into free
     checkInvariants runner (#7404)
    
    Co-authored-by: Cursor 
    ---
     include/xrpl/tx/ApplyContext.h                |  18 ---
     include/xrpl/tx/Transactor.h                  |  79 +++++++---
     include/xrpl/tx/invariants/InvariantRunner.h  | 140 ++++++++++++++++++
     src/libxrpl/tx/ApplyContext.cpp               |  79 ----------
     src/libxrpl/tx/Transactor.cpp                 |  74 +++------
     src/libxrpl/tx/invariants/InvariantRunner.cpp | 110 ++++++++++++++
     src/test/app/Invariants_test.cpp              | 135 ++++++++++++++++-
     src/test/app/NFTokenBurn_test.cpp             |   5 +-
     8 files changed, 465 insertions(+), 175 deletions(-)
     create mode 100644 include/xrpl/tx/invariants/InvariantRunner.h
     create mode 100644 src/libxrpl/tx/invariants/InvariantRunner.cpp
    
    diff --git a/include/xrpl/tx/ApplyContext.h b/include/xrpl/tx/ApplyContext.h
    index 472afdf624..e827e69f01 100644
    --- a/include/xrpl/tx/ApplyContext.h
    +++ b/include/xrpl/tx/ApplyContext.h
    @@ -17,7 +17,6 @@
     #include 
     #include 
     #include 
    -#include 
     
     namespace xrpl {
     
    @@ -130,16 +129,6 @@ public:
             view_->rawDestroyXRP(fee);
         }
     
    -    /**
    -     * Applies all invariant checkers one by one.
    -     *
    -     * @param result the result generated by processing this transaction.
    -     * @param fee the fee charged for this transaction
    -     * @return the result code that should be returned for this transaction.
    -     */
    -    TER
    -    checkInvariants(TER const result, XRPAmount const fee);
    -
         ApplyViewContext
         getApplyViewContext()
         {
    @@ -150,13 +139,6 @@ public:
         }
     
     private:
    -    static TER
    -    failInvariantCheck(TER const result);
    -
    -    template 
    -    TER
    -    checkInvariantsHelper(TER const result, XRPAmount const fee, std::index_sequence);
    -
         OpenView& base_;
         ApplyFlags flags_;
         std::optional view_;
    diff --git a/include/xrpl/tx/Transactor.h b/include/xrpl/tx/Transactor.h
    index a71285f70e..96ad7e00bc 100644
    --- a/include/xrpl/tx/Transactor.h
    +++ b/include/xrpl/tx/Transactor.h
    @@ -20,6 +20,7 @@
     #include 
     #include 
     #include 
    +#include 
     
     #include 
     #include 
    @@ -147,7 +148,7 @@ struct FeePayer
         FeePayerType type{FeePayerType::Account};
     };
     
    -class Transactor
    +class Transactor : public TxInvariantCheck
     {
     protected:
         ApplyContext& ctx_;
    @@ -158,7 +159,7 @@ protected:
         XRPAmount preFeeBalance_{};  // Balance before fees.
     
     public:
    -    virtual ~Transactor() = default;
    +    ~Transactor() override = default;
         Transactor(Transactor const&) = delete;
         Transactor&
         operator=(Transactor const&) = delete;
    @@ -183,20 +184,50 @@ public:
             return ctx_.view();
         }
     
    +    /**
    +     * Which invariant layers to check.
    +     *
    +     * Full runs the protocol invariants plus the transaction-specific
    +     * check.  This is always the scope of the initial pass, even when the
    +     * tentative TER is a tec: a bug or exploit could still mutate ledger
    +     * state, so transaction-specific invariants must run for failed
    +     * transactions too.
    +     *
    +     * ProtocolOnly runs only the protocol invariants and is used
    +     * exclusively for the second invariant pass that follows a
    +     * fee-claim reset — specifically, the reset that
    +     * Transactor::operator() performs when the initial invariant pass
    +     * returns tecINVARIANT_FAILED, rolling the transaction's effects back
    +     * to a fee-claim-only state.  In that reduced state the
    +     * transaction-specific post-conditions no longer apply, but the
    +     * protocol invariants must still hold against the fee claim itself.
    +     * ProtocolOnly is not intended for other context discards (e.g. the
    +     * reset used to handle tecOVERSIZE/tecKILLED/etc. in
    +     * processPersistentChanges, or the ctx_.discard() done under
    +     * TapFailHard); those paths do not re-run invariants at all.
    +     */
    +    enum class InvariantScope { Full, ProtocolOnly };
    +
         /**
          * Check all invariants for the current transaction.
          *
    -     * Runs transaction-specific invariants first (visitInvariantEntry +
    -     * finalizeInvariants), then protocol-level invariants.  Both layers
    -     * always run; the worst failure code is returned.
    +     * Delegates to the free xrpl::checkInvariants runner.  When @p scope is
    +     * InvariantScope::Full, this transactor is passed so both layers
    +     * share a single walk of the modified ledger entries.  A failure in
    +     * either layer fails the transaction the same way: tecINVARIANT_FAILED
    +     * on the first pass, which the caller may respond to by rolling the
    +     * transaction back to a fee-claim state and re-invoking this with
    +     * InvariantScope::ProtocolOnly; a failure on that post-reset pass
    +     * escalates to tefINVARIANT_FAILED.
          *
          * @param result  the tentative TER from transaction processing.
          * @param fee     the fee consumed by the transaction.
    +     * @param scope   which invariant layers to check.
          *
          * @return the final TER after all invariant checks.
          */
         [[nodiscard]] TER
    -    checkInvariants(TER result, XRPAmount fee);
    +    checkInvariants(TER result, XRPAmount fee, InvariantScope scope);
     
         /////////////////////////////////////////////////////
         /*
    @@ -538,20 +569,30 @@ private:
         preflightUniversal(PreflightContext const& ctx);
     
         /**
    -     * Check transaction-specific invariants only.
    -     *
    -     * Walks every modified ledger entry via visitInvariantEntry, then
    -     * calls finalizeInvariants on the derived transactor.  Returns
    -     * tecINVARIANT_FAILED if any transaction invariant is violated.
    -     *
    -     * @param result  the tentative TER from transaction processing.
    -     * @param fee     the fee consumed by the transaction.
    -     *
    -     * @return the original result if all invariants pass, or
    -     *         tecINVARIANT_FAILED otherwise.
    +     * Bridges the two-phase TxInvariantCheck interface to this transactor's
    +     * visitInvariantEntry/finalizeInvariants hooks.  Declared private (rather
    +     * than protected, like the hooks they forward to) so that neither this
    +     * transactor nor any subclass can call them directly through a
    +     * Transactor& — only through the TxInvariantCheck& that the free
    +     * xrpl::checkInvariants runner holds, which is where the two-phase
    +     * ordering is enforced.
          */
    -    [[nodiscard]] TER
    -    checkTransactionInvariants(TER result, XRPAmount fee);
    +    void
    +    visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) final
    +    {
    +        visitInvariantEntry(isDelete, before, after);
    +    }
    +
    +    [[nodiscard]] bool
    +    finalize(
    +        STTx const& tx,
    +        TER result,
    +        XRPAmount fee,
    +        ReadView const& view,
    +        beast::Journal const& j) final
    +    {
    +        return finalizeInvariants(tx, result, fee, view, j);
    +    }
     };
     
     inline bool
    diff --git a/include/xrpl/tx/invariants/InvariantRunner.h b/include/xrpl/tx/invariants/InvariantRunner.h
    new file mode 100644
    index 0000000000..29a9dc09b2
    --- /dev/null
    +++ b/include/xrpl/tx/invariants/InvariantRunner.h
    @@ -0,0 +1,140 @@
    +#pragma once
    +
    +#include 
    +#include 
    +#include 
    +#include 
    +#include 
    +#include 
    +#include 
    +
    +#include 
    +#include 
    +
    +namespace xrpl {
    +
    +/**
    + * @brief Runtime interface for a transaction-specific invariant check.
    + *
    + * The free checkInvariants runner drives two layers of checks over a single
    + * walk of the modified ledger entries:
    + *
    + *  - Protocol checks are the concrete types in InvariantChecks, held in a
    + *    std::tuple and dispatched statically by a compile-time fold (no
    + *    virtual calls).  They are duck-typed against the two-phase contract
    + *    described below; see InvariantChecker_PROTOTYPE in InvariantCheck.h.
    + *  - The transaction-specific check is injected at runtime through this
    + *    interface, so the runner can call it without depending on the concrete
    + *    transactor type.  Transactor implements this interface directly (see
    + *    Transactor.h) so that the interface's access can stay narrower than
    + *    Transactor's own public surface: calling through a TxInvariantCheck&
    + *    (all the runner ever holds) is public, but calling through a
    + *    Transactor& is not, since Transactor overrides these as private
    + *    (forwarding to its own protected visitInvariantEntry/finalizeInvariants).
    + *
    + * Both layers honour the same two-phase protocol:
    + *
    + * Phase 1 — state collection (visitEntry).  Called once for each ledger
    + * entry created, modified, or deleted by the transaction.  Implementations
    + * accumulate whatever state they need to evaluate their post-conditions.
    + * Must not throw.
    + *
    + * Phase 2 — condition evaluation (finalize).  Called once after every
    + * modified entry has been visited.  Returns true if all post-conditions
    + * hold, false to fail the transaction.
    + *
    + * Rule: invariants must run regardless of transaction result.  finalize
    + * MUST perform meaningful checks even when the transaction has failed
    + * (when result is not tesSUCCESS).  A bug or exploit could cause a failed
    + * transaction to mutate ledger state in unexpected ways; invariants are the
    + * last line of defense.
    + *
    + * The typical pattern: an invariant that expects a domain-specific state
    + * change (e.g. a Vault being created) should expect that change only when
    + * the transaction succeeded.  A failed VaultCreate must not have created a
    + * Vault.
    + *
    + * Rule: privilege-gated checks apply to failed transactions too.  Failed
    + * transactions carry no privileges.  Any privilege-gated assertion must
    + * therefore also be enforced for failed transactions.
    + */
    +class TxInvariantCheck
    +{
    +public:
    +    virtual ~TxInvariantCheck() = default;
    +
    +    /**
    +     * @brief Called for each ledger entry modified by the transaction.
    +     *
    +     * @param isDelete true if the SLE is being deleted.
    +     * @param before   the entry's state before the transaction (nullptr for
    +     *                 newly created entries).
    +     * @param after    the entry's state after the transaction.  For deletions
    +     *                 this is the SLE being erased; use @p isDelete rather than
    +     *                 a null @p after to detect deletions.  @p after is
    +     *                 never null.
    +     */
    +    virtual void
    +    visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) = 0;
    +
    +    /**
    +     * @brief Called after all entries have been visited.
    +     *
    +     * @param tx     the transaction being applied.
    +     * @param result the tentative TER result of the transaction.
    +     * @param fee    the fee consumed by the transaction.
    +     * @param view   read-only view of the ledger after the transaction.
    +     * @param j      journal for logging invariant failures.
    +     * @return true if all invariants hold; false to fail with
    +     *         tecINVARIANT_FAILED / tefINVARIANT_FAILED.
    +     */
    +    [[nodiscard]] virtual bool
    +    finalize(
    +        STTx const& tx,
    +        TER result,
    +        XRPAmount fee,
    +        ReadView const& view,
    +        beast::Journal const& j) = 0;
    +};
    +
    +/**
    + * @brief Run all protocol invariant checks plus the transaction-specific check
    + * in a single pass over the modified entries.
    + *
    + * Both layers share one walk of the modified-entry set: @p txCheck's
    + * visitEntry accumulates state on the same traversal that drives the
    + * protocol checkers, then both layers' finalize run on the complete state.
    + *
    + * Any failure (a finalize returning false or an exception anywhere in the
    + * check) returns failInvariantCheck(result).  On the first pass that yields
    + * tecINVARIANT_FAILED, which the transactor treats as a signal to roll the
    + * transaction's effects back to a fee-claim-only state and re-run this
    + * runner against the reduced state (see Transactor::InvariantScope).  If
    + * that second pass also fails, the result escalates to tefINVARIANT_FAILED,
    + * which excludes the transaction from the ledger entirely.
    + *
    + * The whole traversal — both layers' visitEntry calls and both layers'
    + * finalize calls — runs under a single try/catch.  There is no per-layer
    + * isolation: an exception anywhere aborts the remaining traversal and
    + * finalize calls and fails the transaction.
    + *
    + * @param ctx     the apply context for the current transaction.
    + * @param result  the tentative TER from transaction processing.
    + * @param fee     the fee consumed by the transaction.
    + * @param txCheck the transaction-specific invariant check.
    + * @return the final TER after all invariant checks.
    + */
    +[[nodiscard]] TER
    +checkInvariants(
    +    ApplyContext& ctx,
    +    TER result,
    +    XRPAmount fee,
    +    std::optional> txCheck);
    +
    +[[nodiscard]] inline TER
    +checkInvariants(ApplyContext& ctx, TER result, XRPAmount fee)
    +{
    +    return checkInvariants(ctx, result, fee, std::nullopt);
    +}
    +
    +}  // namespace xrpl
    diff --git a/src/libxrpl/tx/ApplyContext.cpp b/src/libxrpl/tx/ApplyContext.cpp
    index 5e5ab90441..50f46fceef 100644
    --- a/src/libxrpl/tx/ApplyContext.cpp
    +++ b/src/libxrpl/tx/ApplyContext.cpp
    @@ -1,27 +1,19 @@
     #include 
     
    -#include 
     #include 
     #include 
     #include 
     #include 
    -#include 
     #include 
     #include 
     #include 
     #include 
     #include 
     #include 
    -#include 
     
    -#include 
    -#include 
     #include 
    -#include 
     #include 
     #include 
    -#include 
    -#include 
     
     namespace xrpl {
     
    @@ -75,75 +67,4 @@ ApplyContext::visit(
         view_->visit(base_, func);  // NOLINT(bugprone-unchecked-optional-access)
     }
     
    -TER
    -ApplyContext::failInvariantCheck(TER const result)
    -{
    -    // If we already failed invariant checks before and we are now attempting to
    -    // only charge a fee, and even that fails the invariant checks something is
    -    // very wrong. We switch to tefINVARIANT_FAILED, which does NOT get included
    -    // in a ledger.
    -
    -    return (result == tecINVARIANT_FAILED || result == tefINVARIANT_FAILED)
    -        ? TER{tefINVARIANT_FAILED}
    -        : TER{tecINVARIANT_FAILED};
    -}
    -
    -template 
    -TER
    -ApplyContext::checkInvariantsHelper(
    -    TER const result,
    -    XRPAmount const fee,
    -    std::index_sequence)
    -{
    -    try
    -    {
    -        auto checkers = getInvariantChecks();
    -
    -        // call each check's per-entry method
    -        visit(
    -            [&checkers](
    -                uint256 const& index, bool isDelete, SLE::const_ref before, SLE::const_ref after) {
    -                (..., std::get(checkers).visitEntry(isDelete, before, after));
    -            });
    -
    -        // Note: do not replace this logic with a `...&&` fold expression.
    -        // The fold expression will only run until the first check fails (it
    -        // short-circuits). While the logic is still correct, the log
    -        // message won't be. Every failed invariant should write to the log,
    -        // not just the first one.
    -        std::array const finalizers{{std::get(checkers).finalize(
    -            tx, result, fee, *view_, journal)...}};  // NOLINT(bugprone-unchecked-optional-access)
    -
    -        // call each check's finalizer to see that it passes
    -        if (!std::ranges::all_of(finalizers, [](auto const& b) { return b; }))
    -        {
    -            JLOG(journal.fatal()) << "Transaction has failed one or more global invariants: "
    -                                  << to_string(tx.getJson(JsonOptions::Values::None));
    -
    -            return failInvariantCheck(result);
    -        }
    -    }
    -    catch (std::exception const& ex)
    -    {
    -        JLOG(journal.fatal()) << "Transaction caused an exception in a global invariant"
    -                              << ", ex: " << ex.what()
    -                              << ", tx: " << to_string(tx.getJson(JsonOptions::Values::None));
    -
    -        return failInvariantCheck(result);
    -    }
    -
    -    return result;
    -}
    -
    -TER
    -ApplyContext::checkInvariants(TER const result, XRPAmount const fee)
    -{
    -    XRPL_ASSERT(
    -        isTesSuccess(result) || isTecClaim(result),
    -        "xrpl::ApplyContext::checkInvariants : is tesSUCCESS or tecCLAIM");
    -
    -    return checkInvariantsHelper(
    -        result, fee, std::make_index_sequence>{});
    -}
    -
     }  // namespace xrpl
    diff --git a/src/libxrpl/tx/Transactor.cpp b/src/libxrpl/tx/Transactor.cpp
    index 594aa24940..6bf99e567d 100644
    --- a/src/libxrpl/tx/Transactor.cpp
    +++ b/src/libxrpl/tx/Transactor.cpp
    @@ -41,11 +41,11 @@
     #include 
     #include 
     #include 
    +#include 
     
     #include 
     #include 
     #include 
    -#include 
     #include 
     #include 
     #include 
    @@ -1540,53 +1540,12 @@ Transactor::processPersistentChanges(TER result, XRPAmount fee)
     }
     
     [[nodiscard]] TER
    -Transactor::checkTransactionInvariants(TER result, XRPAmount fee)
    +Transactor::checkInvariants(TER result, XRPAmount fee, InvariantScope scope)
     {
    -    try
    -    {
    -        // Phase 1: visit modified entries
    -        ctx_.visit(
    -            [this](uint256 const&, bool isDelete, SLE::const_ref before, SLE::const_ref after) {
    -                this->visitInvariantEntry(isDelete, before, after);
    -            });
    +    if (scope == InvariantScope::Full)
    +        return xrpl::checkInvariants(ctx_, result, fee, *this);
     
    -        // Phase 2: finalize
    -        if (!this->finalizeInvariants(ctx_.tx, result, fee, ctx_.view(), ctx_.journal))
    -        {
    -            JLOG(ctx_.journal.fatal()) <<                                             //
    -                "Transaction has failed one or more transaction invariants, tx: " <<  //
    -                to_string(ctx_.tx.getJson(JsonOptions::Values::None));
    -            return tecINVARIANT_FAILED;
    -        }
    -    }
    -    catch (std::exception const& ex)
    -    {
    -        JLOG(ctx_.journal.fatal()) <<                               //
    -            "Exception while checking transaction invariants: " <<  //
    -            ex.what() <<                                            //
    -            ", tx: " <<                                             //
    -            to_string(ctx_.tx.getJson(JsonOptions::Values::None));
    -
    -        return tecINVARIANT_FAILED;
    -    }
    -
    -    return result;
    -}
    -
    -[[nodiscard]] TER
    -Transactor::checkInvariants(TER result, XRPAmount fee)
    -{
    -    /*
    -     * DISABLED for 3.2.0 — Must be re-introduced for 3.3.0
    -     *
    -     * Transaction invariants are disabled due to a performance regression:
    -     * the two-pass design (transaction-specific invariants + protocol invariants)
    -     * iterates over modified ledger entries twice per transaction.
    -     *
    -     * Until resolved, only protocol invariants are checked (delegated to ctx_).
    -     * This is safe because all transaction invariants in 3.2.0 are  no-ops.
    -     */
    -    return ctx_.checkInvariants(result, fee);
    +    return xrpl::checkInvariants(ctx_, result, fee);
     }
     
     //------------------------------------------------------------------------------
    @@ -1674,24 +1633,29 @@ Transactor::operator()()
         if (!canApply)
             return logger(result, canApply);
     
    -    // Check invariants: if `tecINVARIANT_FAILED` is not returned, we can
    -    // proceed to apply the tx
    -    result = checkInvariants(result, fee);
    +    // First invariant pass: both protocol and transaction-specific
    +    // checks run against the transaction's tentative outcome. If it
    +    // does not return tecINVARIANT_FAILED, we can proceed to apply the
    +    // tx.
    +    result = checkInvariants(result, fee, InvariantScope::Full);
         if (result == tecINVARIANT_FAILED)
         {
    -        // Reset to fee-claim only
    +        // Fee-claim reset: roll the transaction's effects back so that
    +        // only the fee deduction remains. This is the reset referenced
    +        // by InvariantScope::ProtocolOnly.
             auto const resetResult = reset(fee);
             if (!isTesSuccess(resetResult.first))
                 result = resetResult.first;
     
             fee = resetResult.second;
     
    -        // Check invariants again to ensure the fee claiming doesn't violate
    -        // invariants. After reset, only protocol invariants are re-checked.
    -        // Transaction invariants are not meaningful here — the transaction's
    -        // effects have been rolled back.
    +        // Re-check invariants against the post-reset (fee-claim only)
    +        // state. The transaction's effects are gone, so the
    +        // transaction-specific invariants no longer apply and only the
    +        // protocol invariants are re-run. A failure here escalates to
    +        // tefINVARIANT_FAILED and excludes the tx from the ledger.
             if (isTesSuccess(result) || isTecClaim(result))
    -            result = ctx_.checkInvariants(result, fee);
    +            result = checkInvariants(result, fee, InvariantScope::ProtocolOnly);
         }
     
         // We ran through the invariant checker, which can, in some cases,
    diff --git a/src/libxrpl/tx/invariants/InvariantRunner.cpp b/src/libxrpl/tx/invariants/InvariantRunner.cpp
    new file mode 100644
    index 0000000000..55bff2d693
    --- /dev/null
    +++ b/src/libxrpl/tx/invariants/InvariantRunner.cpp
    @@ -0,0 +1,110 @@
    +#include 
    +
    +#include 
    +#include 
    +#include 
    +#include 
    +#include 
    +#include   // IWYU pragma: keep
    +#include 
    +#include 
    +#include 
    +#include 
    +
    +#include 
    +#include 
    +#include 
    +#include 
    +#include 
    +#include 
    +#include 
    +#include 
    +
    +namespace xrpl {
    +
    +namespace {
    +
    +TER
    +failInvariantCheck(TER const result)
    +{
    +    return (result == tecINVARIANT_FAILED || result == tefINVARIANT_FAILED)
    +        ? TER{tefINVARIANT_FAILED}
    +        : TER{tecINVARIANT_FAILED};
    +}
    +
    +template 
    +TER
    +checkInvariantsHelper(
    +    ApplyContext& ctx,
    +    TER const result,
    +    XRPAmount const fee,
    +    std::optional> txCheck,
    +    std::index_sequence)
    +{
    +    bool allOk = true;
    +
    +    try
    +    {
    +        auto checkers = getInvariantChecks();
    +
    +        ctx.visit([&](uint256 const&, bool isDelete, SLE::const_ref before, SLE::const_ref after) {
    +            if (txCheck)
    +                txCheck->get().visitEntry(isDelete, before, after);
    +            (..., std::get(checkers).visitEntry(isDelete, before, after));
    +        });
    +
    +        if (txCheck)
    +        {
    +            if (!txCheck->get().finalize(ctx.tx, result, fee, ctx.view(), ctx.journal))
    +            {
    +                JLOG(ctx.journal.fatal())
    +                    << "Transaction has failed one or more transaction invariants: "
    +                    << to_string(ctx.tx.getJson(JsonOptions::Values::None));
    +                allOk = false;
    +            }
    +        }
    +
    +        // Note: do not replace this logic with a `...&&` fold expression.
    +        // The fold expression will only run until the first check fails (it
    +        // short-circuits). While the logic is still correct, the log
    +        // message won't be. Every failed invariant should write to the log,
    +        // not just the first one.
    +        std::array const finalizers{
    +            {std::get(checkers).finalize(ctx.tx, result, fee, ctx.view(), ctx.journal)...}};
    +
    +        if (!std::all_of(finalizers.cbegin(), finalizers.cend(), [](auto const& b) { return b; }))
    +        {
    +            JLOG(ctx.journal.fatal()) << "Transaction has failed one or more global invariants: "
    +                                      << to_string(ctx.tx.getJson(JsonOptions::Values::None));
    +            allOk = false;
    +        }
    +    }
    +    catch (std::exception const& ex)
    +    {
    +        JLOG(ctx.journal.fatal()) << "Transaction caused an exception during invariant checks"
    +                                  << ", ex: " << ex.what() << ", tx: "
    +                                  << to_string(ctx.tx.getJson(JsonOptions::Values::None));
    +        return failInvariantCheck(result);
    +    }
    +
    +    return allOk ? result : failInvariantCheck(result);
    +}
    +
    +}  // namespace
    +
    +TER
    +checkInvariants(
    +    ApplyContext& ctx,
    +    TER const result,
    +    XRPAmount const fee,
    +    std::optional> txCheck)
    +{
    +    XRPL_ASSERT(
    +        isTesSuccess(result) || isTecClaim(result),
    +        "xrpl::checkInvariants : is tesSUCCESS or tecCLAIM");
    +
    +    return checkInvariantsHelper(
    +        ctx, result, fee, txCheck, std::make_index_sequence>{});
    +}
    +
    +}  // namespace xrpl
    diff --git a/src/test/app/Invariants_test.cpp b/src/test/app/Invariants_test.cpp
    index 70eaadbe17..ced2dea9bb 100644
    --- a/src/test/app/Invariants_test.cpp
    +++ b/src/test/app/Invariants_test.cpp
    @@ -22,6 +22,7 @@
     #include 
     #include 
     #include 
    +#include 
     #include 
     #include 
     #include 
    @@ -56,6 +57,7 @@
     #include 
     #include 
     #include 
    +#include 
     #include 
     #include 
     
    @@ -68,6 +70,7 @@
     #include 
     #include 
     #include 
    +#include 
     #include 
     #include 
     #include 
    @@ -217,7 +220,8 @@ class Invariants_test : public beast::unit_test::Suite
             TER terActual = tesSUCCESS;
             for (TER const& terExpect : ters)
             {
    -            terActual = transactor->checkInvariants(terActual, fee);
    +            terActual =
    +                transactor->checkInvariants(terActual, fee, Transactor::InvariantScope::Full);
                 expect(
                     terExpect == terActual,
                     "expected: " + transToken(terExpect) + " got: " + transToken(terActual),
    @@ -6379,12 +6383,137 @@ class Invariants_test : public beast::unit_test::Suite
                 auto transactor = makeTransactor(ac);
                 if (!BEAST_EXPECT(transactor))
                     return;
    -            TER const result = transactor->checkInvariants(tesSUCCESS, XRPAmount{});
    +            TER const result = transactor->checkInvariants(
    +                tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full);
                 BEAST_EXPECT(result == tecINVARIANT_FAILED);
                 BEAST_EXPECT(sink.messages().str().contains("is missing pseudo-account field"));
             }
         }
     
    +    void
    +    testTxCheckException()
    +    {
    +        testcase << "txCheck exception";
    +        using namespace jtx;
    +
    +        // A TxInvariantCheck that throws from the requested hook, so we can
    +        // exercise checkInvariantsHelper's catch block via the
    +        // transaction-specific layer (as opposed to the protocol layer,
    +        // which testObjectHasPseudoAccount's last case already covers via a
    +        // real Transactor's finalizeInvariants).
    +        enum class ThrowFrom { VisitEntry, Finalize };
    +
    +        struct ThrowingTxInvariantCheck : TxInvariantCheck
    +        {
    +            ThrowFrom const throwFrom;
    +
    +            explicit ThrowingTxInvariantCheck(ThrowFrom throwFrom) : throwFrom(throwFrom)
    +            {
    +            }
    +
    +            void
    +            visitEntry(bool, SLE::const_ref, SLE::const_ref) override
    +            {
    +                if (throwFrom == ThrowFrom::VisitEntry)
    +                    throw std::runtime_error("test-injected visitEntry exception");
    +            }
    +
    +            [[nodiscard]] bool
    +            finalize(STTx const&, TER, XRPAmount, ReadView const&, beast::Journal const&) override
    +            {
    +                if (throwFrom == ThrowFrom::Finalize)
    +                    throw std::runtime_error("test-injected finalize exception");
    +                return true;
    +            }
    +        };
    +
    +        for (auto const throwFrom : {ThrowFrom::VisitEntry, ThrowFrom::Finalize})
    +        {
    +            Env env{*this};
    +            Account const alice{"alice"};
    +            env.fund(XRP(1000), alice);
    +            env.close();
    +
    +            OpenView ov{*env.current()};
    +            STTx const tx{ttACCOUNT_SET, [](STObject&) {}};
    +            test::StreamSink sink{beast::Severity::Warning};
    +            beast::Journal const jlog{sink};
    +            ApplyContext ac{
    +                env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
    +            CurrentTransactionRulesGuard const rulesGuard(ov.rules());
    +
    +            // visitEntry only runs for entries the transaction touched, so
    +            // make a modification for the traversal to report.
    +            auto sle = ac.view().peek(keylet::account(alice.id()));
    +            if (!BEAST_EXPECT(sle))
    +                return;
    +            sle->at(sfSequence) = sle->at(sfSequence) + 1;
    +            ac.view().update(sle);
    +
    +            ThrowingTxInvariantCheck throwing{throwFrom};
    +            TER terActual = tesSUCCESS;
    +            for (TER const& terExpect : {TER(tecINVARIANT_FAILED), TER(tefINVARIANT_FAILED)})
    +            {
    +                terActual = checkInvariants(ac, terActual, XRPAmount{}, throwing);
    +                BEAST_EXPECT(terExpect == terActual);
    +                BEAST_EXPECT(sink.messages().str().contains(
    +                    "Transaction caused an exception during invariant checks"));
    +            }
    +        }
    +    }
    +
    +    void
    +    testTxCheckFinalizeFalse()
    +    {
    +        testcase << "txCheck finalize returns false";
    +        using namespace jtx;
    +
    +        // A TxInvariantCheck whose finalize returns false, so we can exercise
    +        // the "Transaction has failed one or more transaction invariants"
    +        // log path in checkInvariantsHelper independently of any real
    +        // transactor. This is the transaction-layer analogue of the
    +        // protocol-layer coverage in testObjectHasPseudoAccount / others.
    +        struct FailingTxInvariantCheck : TxInvariantCheck
    +        {
    +            void
    +            visitEntry(bool, SLE::const_ref, SLE::const_ref) override
    +            {
    +            }
    +
    +            [[nodiscard]] bool
    +            finalize(STTx const&, TER, XRPAmount, ReadView const&, beast::Journal const&) override
    +            {
    +                return false;
    +            }
    +        };
    +
    +        Env env{*this};
    +        Account const alice{"alice"};
    +        env.fund(XRP(1000), alice);
    +        env.close();
    +
    +        OpenView ov{*env.current()};
    +        STTx const tx{ttACCOUNT_SET, [](STObject&) {}};
    +        test::StreamSink sink{beast::Severity::Warning};
    +        beast::Journal const jlog{sink};
    +        ApplyContext ac{env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog};
    +        CurrentTransactionRulesGuard const rulesGuard(ov.rules());
    +
    +        FailingTxInvariantCheck failing;
    +        TER terActual = tesSUCCESS;
    +        for (TER const& terExpect : {TER(tecINVARIANT_FAILED), TER(tefINVARIANT_FAILED)})
    +        {
    +            terActual = checkInvariants(ac, terActual, XRPAmount{}, failing);
    +            BEAST_EXPECT(terExpect == terActual);
    +            BEAST_EXPECT(sink.messages().str().contains(
    +                "Transaction has failed one or more transaction invariants"));
    +            // The protocol-layer log must not appear: only the tx-layer
    +            // finalize failed here.
    +            BEAST_EXPECT(!sink.messages().str().contains(
    +                "Transaction has failed one or more global invariants"));
    +        }
    +    }
    +
         void
         testConfidentialMPTTransfer()
         {
    @@ -6670,6 +6799,8 @@ public:
             testAMM();
             testObjectHasPseudoAccount();
             testSponsorship();
    +        testTxCheckException();
    +        testTxCheckFinalizeFalse();
         }
     };
     
    diff --git a/src/test/app/NFTokenBurn_test.cpp b/src/test/app/NFTokenBurn_test.cpp
    index 52565432a9..ae1d557bb9 100644
    --- a/src/test/app/NFTokenBurn_test.cpp
    +++ b/src/test/app/NFTokenBurn_test.cpp
    @@ -32,6 +32,7 @@
     #include 
     #include 
     #include 
    +#include 
     
     #include 
     #include 
    @@ -794,7 +795,7 @@ class NFTokenBurn_test : public beast::unit_test::Suite
                     TER terActual = tesSUCCESS;
                     for (TER const& terExpect : {TER(tecINVARIANT_FAILED), TER(tefINVARIANT_FAILED)})
                     {
    -                    terActual = ac.checkInvariants(terActual, XRPAmount{});
    +                    terActual = xrpl::checkInvariants(ac, terActual, XRPAmount{});
                         BEAST_EXPECT(terExpect == terActual);
                         BEAST_EXPECT(sink.messages().str().starts_with("Invariant failed:"));
                         // uncomment to log the invariant failure message
    @@ -830,7 +831,7 @@ class NFTokenBurn_test : public beast::unit_test::Suite
                     TER terActual = tesSUCCESS;
                     for (TER const& terExpect : {TER(tecINVARIANT_FAILED), TER(tefINVARIANT_FAILED)})
                     {
    -                    terActual = ac.checkInvariants(terActual, XRPAmount{});
    +                    terActual = xrpl::checkInvariants(ac, terActual, XRPAmount{});
                         BEAST_EXPECT(terExpect == terActual);
                         BEAST_EXPECT(sink.messages().str().starts_with("Invariant failed:"));
                         // uncomment to log the invariant failure message
    
    From f370289733cbfae71933465ab6b87ce060b67802 Mon Sep 17 00:00:00 2001
    From: Sergey Kuznetsov 
    Date: Wed, 19 Aug 2026 14:30:06 +0000
    Subject: [PATCH 101/102] chore: Rust-C++ cmake and CI integration (#7034)
    
    ---
     .codecov.yml                                  |  24 +-
     .cspell.config.yaml                           |   1 +
     .github/dependabot.yml                        |  16 +
     .github/scripts/strategy-matrix/generate.py   |   8 +-
     .github/workflows/cargo-audit.yml             |  80 +++++
     .github/workflows/check-tools.yml             |   2 +-
     .github/workflows/on-pr.yml                   |  10 +
     .github/workflows/on-trigger.yml              |   7 +
     .github/workflows/publish-docs.yml            |   2 +-
     .../workflows/reusable-build-test-config.yml  |  21 +-
     .github/workflows/reusable-clang-tidy.yml     |  15 +-
     .github/workflows/reusable-rust.yml           |  86 +++++
     .gitignore                                    |   3 +
     .pre-commit-config.yaml                       |   9 +
     BUILD.md                                      |  25 ++
     CMakeLists.txt                                |   6 +
     CONTRIBUTING.md                               |  12 +-
     README.md                                     |   1 +
     cmake/XrplCore.cmake                          |   2 +
     cmake/XrplSettings.cmake                      |   5 +
     conan.lock                                    |   1 +
     conanfile.py                                  |   1 +
     crates/.cargo/config.toml                     |  17 +
     crates/CMakeLists.txt                         | 104 ++++++
     crates/Cargo.lock                             | 301 ++++++++++++++++++
     crates/Cargo.toml                             |  15 +
     crates/generated.clang-tidy                   |  10 +
     crates/hello_world/Cargo.toml                 |  10 +
     crates/hello_world/src/lib.rs                 |  10 +
     docs/build/environment.md                     |  22 ++
     docs/build/nix.md                             |   6 +
     src/tests/libxrpl/CMakeLists.txt              |   9 +
     src/tests/libxrpl/basics/RustInterop.cpp      |   9 +
     33 files changed, 838 insertions(+), 12 deletions(-)
     create mode 100644 .github/workflows/cargo-audit.yml
     create mode 100644 .github/workflows/reusable-rust.yml
     create mode 100644 crates/.cargo/config.toml
     create mode 100644 crates/CMakeLists.txt
     create mode 100644 crates/Cargo.lock
     create mode 100644 crates/Cargo.toml
     create mode 100644 crates/generated.clang-tidy
     create mode 100644 crates/hello_world/Cargo.toml
     create mode 100644 crates/hello_world/src/lib.rs
     create mode 100644 src/tests/libxrpl/basics/RustInterop.cpp
    
    diff --git a/.codecov.yml b/.codecov.yml
    index cd52e2604d..4268758e44 100644
    --- a/.codecov.yml
    +++ b/.codecov.yml
    @@ -1,10 +1,32 @@
     codecov:
       require_ci_to_pass: true
    +  # The C++ and Rust uploads land minutes apart; without this gate Codecov
    +  # publishes a near-zero total from whichever one arrives first.
    +  notify:
    +    after_n_builds: 2
    +    wait_for_ci: true
     
     comment:
       behavior: default
       layout: reach,diff,flags,tree,reach
    -  show_carryforward_flags: false
    +  show_carryforward_flags: true
    +  after_n_builds: 2
    +
    +# C++ and Rust coverage upload from independent workflows under the `cpp` and
    +# `rust` flags; carryforward keeps one language's total when only the other reran.
    +flag_management:
    +  default_rules:
    +    carryforward: true
    +  individual_flags:
    +    - name: cpp
    +      carryforward: true
    +      paths:
    +        - include/
    +        - src/
    +    - name: rust
    +      carryforward: true
    +      paths:
    +        - crates/
     
     coverage:
       range: "70..85"
    diff --git a/.cspell.config.yaml b/.cspell.config.yaml
    index aa64a318fd..6220cfd60e 100644
    --- a/.cspell.config.yaml
    +++ b/.cspell.config.yaml
    @@ -318,6 +318,7 @@ words:
       - summands
       - superpeer
       - superpeers
    +  - Swatinem
       - takergets
       - takerpays
       - ters
    diff --git a/.github/dependabot.yml b/.github/dependabot.yml
    index 1ccbd61102..da37f79007 100644
    --- a/.github/dependabot.yml
    +++ b/.github/dependabot.yml
    @@ -19,3 +19,19 @@ updates:
           github-actions:
             patterns:
               - "*"
    +
    +  - package-ecosystem: cargo
    +    directory: /crates
    +    schedule:
    +      interval: weekly
    +      day: monday
    +      time: "04:00"
    +      timezone: Etc/GMT
    +    commit-message:
    +      prefix: "chore: [DEPENDABOT] "
    +    target-branch: develop
    +    open-pull-requests-limit: 10
    +    groups:
    +      rust-dependencies:
    +        patterns:
    +          - "*"
    diff --git a/.github/scripts/strategy-matrix/generate.py b/.github/scripts/strategy-matrix/generate.py
    index fb37fb7691..7fef6643ff 100755
    --- a/.github/scripts/strategy-matrix/generate.py
    +++ b/.github/scripts/strategy-matrix/generate.py
    @@ -7,7 +7,13 @@ from pathlib import Path
     
     THIS_DIR = Path(__file__).parent.resolve()
     
    -_BASE_CMAKE_ARGS = ["-Dtests=ON", "-Dwerr=ON", "-Dxrpld=ON", "-Dwextra=ON"]
    +_BASE_CMAKE_ARGS = [
    +    "-Dtests=ON",
    +    "-Dwerr=ON",
    +    "-Dxrpld=ON",
    +    "-Dwextra=ON",
    +    "-Drust=ON",
    +]
     
     # Maps sanitizer names (as used in cmake) to short config-name suffixes.
     _SANITIZER_SUFFIX: dict[str, str] = {
    diff --git a/.github/workflows/cargo-audit.yml b/.github/workflows/cargo-audit.yml
    new file mode 100644
    index 0000000000..d167e52e61
    --- /dev/null
    +++ b/.github/workflows/cargo-audit.yml
    @@ -0,0 +1,80 @@
    +name: Cargo audit
    +
    +on:
    +  schedule:
    +    # 06:32 UTC every Monday.
    +    - cron: "32 6 * * 1"
    +  push:
    +    branches:
    +      - "develop"
    +      - "release/*"
    +    paths:
    +      - "crates/**/Cargo.toml"
    +      - "crates/Cargo.lock"
    +      - ".github/workflows/cargo-audit.yml"
    +  pull_request:
    +    paths:
    +      - "crates/**/Cargo.toml"
    +      - "crates/Cargo.lock"
    +      - ".github/workflows/cargo-audit.yml"
    +  workflow_dispatch:
    +
    +concurrency:
    +  group: ${{ github.workflow }}-${{ github.ref }}
    +  cancel-in-progress: true
    +
    +defaults:
    +  run:
    +    shell: bash
    +    working-directory: crates
    +
    +permissions:
    +  contents: read
    +
    +jobs:
    +  audit:
    +    runs-on: ubuntu-latest
    +    container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-a0074f8
    +    permissions:
    +      contents: read
    +      # Needed to open an issue on scheduled failures.
    +      issues: write
    +    steps:
    +      - name: Checkout repository
    +        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
    +
    +      - name: Run cargo audit
    +        id: audit
    +        continue-on-error: true
    +        run: |
    +          set -o pipefail
    +          cargo audit | tee /tmp/cargo-audit.txt
    +
    +      - name: Prepare issue body
    +        if: ${{ steps.audit.outcome != 'success' && github.event_name == 'schedule' }}
    +        run: |
    +          {
    +              echo "## \`cargo audit\` found advisories"
    +              echo
    +              echo '```'
    +              cat /tmp/cargo-audit.txt
    +              echo '```'
    +              echo
    +              echo "---"
    +              echo "*This issue was automatically created by the cargo-audit workflow.*"
    +          } >/tmp/cargo-audit-issue.md
    +
    +      - name: Create issue
    +        if: ${{ steps.audit.outcome != 'success' && github.event_name == 'schedule' }}
    +        uses: XRPLF/actions/create-issue@2b8bc36af85b88bca0dd7bfac2e2dc05f94ad712
    +        with:
    +          title: "cargo audit found vulnerabilities"
    +          body_file: /tmp/cargo-audit-issue.md
    +          labels: "Bug,Security"
    +
    +      - name: Fail if advisories were found
    +        if: ${{ steps.audit.outcome != 'success' }}
    +        run: |
    +          echo "cargo audit found advisories!"
    +          cat /tmp/cargo-audit.txt
    +          exit 1
    diff --git a/.github/workflows/check-tools.yml b/.github/workflows/check-tools.yml
    index 99dddd7d96..c7a00e8b49 100644
    --- a/.github/workflows/check-tools.yml
    +++ b/.github/workflows/check-tools.yml
    @@ -79,7 +79,7 @@ jobs:
             uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
     
           - name: Prepare runner
    -        uses: XRPLF/actions/prepare-runner@c00c22ada3bd6bcda48fcb0d62fbbab49fec8a0f
    +        uses: XRPLF/actions/prepare-runner@51af40f99ea91a08c3528ddf16d98132dcc7e63c
             with:
               enable_ccache: false
     
    diff --git a/.github/workflows/on-pr.yml b/.github/workflows/on-pr.yml
    index a8209ac16f..933c7b8a54 100644
    --- a/.github/workflows/on-pr.yml
    +++ b/.github/workflows/on-pr.yml
    @@ -86,6 +86,7 @@ jobs:
                 .github/workflows/reusable-check-autogen.yml
                 .github/workflows/reusable-clang-tidy.yml
                 .github/workflows/reusable-package.yml
    +            .github/workflows/reusable-rust.yml
                 .github/workflows/reusable-strategy-matrix.yml
                 .github/workflows/reusable-test.yml
                 .github/workflows/reusable-upload-recipe.yml
    @@ -97,6 +98,7 @@ jobs:
                 cfg/**
                 cmake/**
                 conan/**
    +            crates/**
                 external/**
                 include/**
                 src/**
    @@ -173,6 +175,13 @@ jobs:
         secrets:
           CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
     
    +  rust:
    +    needs: should-run
    +    if: ${{ needs.should-run.outputs.go == 'true' }}
    +    uses: ./.github/workflows/reusable-rust.yml
    +    secrets:
    +      CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
    +
       package:
         needs: [should-run, build-test]
         # Packaging consumes the debian/rhel release binaries, which are only built
    @@ -216,6 +225,7 @@ jobs:
           - check-rename
           - clang-tidy
           - build-test
    +      - rust
           - package
           - upload-recipe
           - notify-clio
    diff --git a/.github/workflows/on-trigger.yml b/.github/workflows/on-trigger.yml
    index 1da0f47bc4..2099f5f739 100644
    --- a/.github/workflows/on-trigger.yml
    +++ b/.github/workflows/on-trigger.yml
    @@ -24,6 +24,7 @@ on:
           - ".github/workflows/reusable-check-autogen.yml"
           - ".github/workflows/reusable-clang-tidy.yml"
           - ".github/workflows/reusable-package.yml"
    +      - ".github/workflows/reusable-rust.yml"
           - ".github/workflows/reusable-strategy-matrix.yml"
           - ".github/workflows/reusable-test.yml"
           - ".github/workflows/reusable-upload-recipe.yml"
    @@ -35,6 +36,7 @@ on:
           - "cfg/**"
           - "cmake/**"
           - "conan/**"
    +      - "crates/**"
           - "external/**"
           - "include/**"
           - "src/**"
    @@ -101,6 +103,11 @@ jobs:
         secrets:
           CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
     
    +  rust:
    +    uses: ./.github/workflows/reusable-rust.yml
    +    secrets:
    +      CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
    +
       upload-recipe:
         needs: build-test
         # Only run when pushing to the develop branch.
    diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml
    index 6e973a251d..3b863f2b33 100644
    --- a/.github/workflows/publish-docs.yml
    +++ b/.github/workflows/publish-docs.yml
    @@ -47,7 +47,7 @@ jobs:
             uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
     
           - name: Prepare runner
    -        uses: XRPLF/actions/prepare-runner@c00c22ada3bd6bcda48fcb0d62fbbab49fec8a0f
    +        uses: XRPLF/actions/prepare-runner@51af40f99ea91a08c3528ddf16d98132dcc7e63c
             with:
               enable_ccache: false
     
    diff --git a/.github/workflows/reusable-build-test-config.yml b/.github/workflows/reusable-build-test-config.yml
    index 94d0706e70..89bfc7463b 100644
    --- a/.github/workflows/reusable-build-test-config.yml
    +++ b/.github/workflows/reusable-build-test-config.yml
    @@ -129,7 +129,7 @@ jobs:
             uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
     
           - name: Prepare runner
    -        uses: XRPLF/actions/prepare-runner@c00c22ada3bd6bcda48fcb0d62fbbab49fec8a0f
    +        uses: XRPLF/actions/prepare-runner@51af40f99ea91a08c3528ddf16d98132dcc7e63c
             with:
               enable_ccache: ${{ inputs.ccache_enabled }}
     
    @@ -162,6 +162,19 @@ jobs:
             with:
               compiler: ${{ inputs.compiler }}
     
    +      - name: Use cargo artifacts cache
    +        uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
    +        with:
    +          cache-directories: ${{ env.BUILD_DIR }}/corrosion
    +          key: ${{ inputs.config_name }}
    +          save-if: ${{ github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/heads/release') }}
    +          # two workspaces here because build artifacts are located in 2 places:
    +          # - crates/target when cargo is called directly
    +          # - build/cargo when cargo is called by cmake
    +          workspaces: |
    +            crates
    +            crates -> ${{ runner.os == 'Windows' && format('../{0}/x64/{1}/cargo', env.BUILD_DIR, inputs.build_type) || format('../{0}/cargo', env.BUILD_DIR) }}
    +
           # `setup-nix-env` already did this for the Nix toolchain.
           - name: Setup Conan
             if: ${{ inputs.toolchain != 'nix' }}
    @@ -357,6 +370,11 @@ jobs:
     
               LD_PRELOAD="$PRELOAD" ./xrpld --unittest --unittest-jobs "${BUILD_NPROC}" 2>&1 | tee "${GITHUB_WORKSPACE}/unittest.log"
     
    +      - name: Run Rust tests
    +        if: ${{ !inputs.build_only }}
    +        working-directory: crates
    +        run: cargo nextest run --workspace --all-features --locked --no-tests=warn
    +
           # 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 there is nothing to gain from repeating it
    @@ -428,6 +446,7 @@ jobs:
               disable_telem: true
               fail_ci_if_error: true
               files: ${{ env.BUILD_DIR }}/coverage.xml
    +          flags: cpp
               plugins: noop
               token: ${{ secrets.CODECOV_TOKEN }}
               verbose: true
    diff --git a/.github/workflows/reusable-clang-tidy.yml b/.github/workflows/reusable-clang-tidy.yml
    index 2049b1ce55..8dd1af9d99 100644
    --- a/.github/workflows/reusable-clang-tidy.yml
    +++ b/.github/workflows/reusable-clang-tidy.yml
    @@ -43,7 +43,7 @@ jobs:
             uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
     
           - name: Prepare runner
    -        uses: XRPLF/actions/prepare-runner@c00c22ada3bd6bcda48fcb0d62fbbab49fec8a0f
    +        uses: XRPLF/actions/prepare-runner@51af40f99ea91a08c3528ddf16d98132dcc7e63c
             with:
               enable_ccache: false
     
    @@ -59,6 +59,13 @@ jobs:
             with:
               compiler: ${{ env.COMPILER }}
     
    +      - name: Use cargo artifacts cache
    +        uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
    +        with:
    +          cache-directories: ${{ env.BUILD_DIR }}/corrosion
    +          save-if: ${{ github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/heads/release') }}
    +          workspaces: crates -> ../${{ env.BUILD_DIR }}/cargo
    +
           - name: Setup Conan
             uses: ./.github/actions/setup-conan
     
    @@ -80,13 +87,13 @@ jobs:
                   -Dwerr=ON \
                   -Dxrpld=ON \
                   -Dverify_headers=ON \
    +              -Drust=ON \
                   ..
     
    -      # clang-tidy needs headers generated from proto files
    -      - name: Build libxrpl.libpb
    +      - name: Build clang-tidy prerequisites
             working-directory: ${{ env.BUILD_DIR }}
             run: |
    -          ninja -j ${{ steps.nproc.outputs.nproc }} xrpl.libpb
    +          ninja -j ${{ steps.nproc.outputs.nproc }} tidy_prerequisites
     
           - name: Run clang tidy
             id: run_clang_tidy
    diff --git a/.github/workflows/reusable-rust.yml b/.github/workflows/reusable-rust.yml
    new file mode 100644
    index 0000000000..e9d281c692
    --- /dev/null
    +++ b/.github/workflows/reusable-rust.yml
    @@ -0,0 +1,86 @@
    +# Clippy, coverage and documentation for the Rust crates in crates/. Each runs
    +# as an independent job on a GitHub-hosted runner, but inside the same container
    +# image used to build the crates in the C++/Corrosion path, so the toolchain
    +# (and therefore the lints, coverage instrumentation and the cargo cache) matches
    +# what production builds use.
    +#
    +# Rust unit tests are deliberately NOT run here. They run as part of the C++
    +# build (reusable-build-test-config.yml), which already compiles the crates on a
    +# self-hosted runner, so there is no need to provision a toolchain again.
    +name: Rust
    +
    +on:
    +  workflow_call:
    +    secrets:
    +      CODECOV_TOKEN:
    +        description: "The Codecov token to use for uploading coverage reports."
    +        required: true
    +
    +defaults:
    +  run:
    +    shell: bash
    +    working-directory: crates
    +
    +permissions:
    +  contents: read
    +
    +jobs:
    +  clippy:
    +    runs-on: ubuntu-latest
    +    container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-a0074f8
    +    steps:
    +      - name: Checkout repository
    +        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
    +
    +      - name: Use cargo artifacts cache
    +        uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
    +        with:
    +          workspaces: crates
    +
    +      - name: Run clippy
    +        run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
    +
    +  coverage:
    +    runs-on: ubuntu-latest
    +    container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-a0074f8
    +    steps:
    +      - name: Checkout repository
    +        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
    +
    +      - name: Use cargo artifacts cache
    +        uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
    +        with:
    +          workspaces: crates
    +
    +      - name: Generate coverage report
    +        run: cargo llvm-cov nextest --workspace --all-features --locked --no-tests=warn --lcov --output-path lcov.info
    +
    +      - name: Upload coverage report
    +        if: ${{ github.repository == 'XRPLF/rippled' }}
    +        uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
    +        with:
    +          disable_search: true
    +          disable_telem: true
    +          fail_ci_if_error: true
    +          files: crates/lcov.info
    +          flags: rust
    +          plugins: noop
    +          token: ${{ secrets.CODECOV_TOKEN }}
    +          verbose: true
    +
    +  doc:
    +    runs-on: ubuntu-latest
    +    container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-a0074f8
    +    steps:
    +      - name: Checkout repository
    +        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
    +
    +      - name: Use cargo artifacts cache
    +        uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
    +        with:
    +          workspaces: crates
    +
    +      - name: Build documentation
    +        env:
    +          RUSTDOCFLAGS: "-D warnings"
    +        run: cargo doc --workspace --no-deps --all-features --locked
    diff --git a/.gitignore b/.gitignore
    index 13b59a7e2c..c5af8eb7b4 100644
    --- a/.gitignore
    +++ b/.gitignore
    @@ -89,3 +89,6 @@ target/
     
     # clangd cache
     /.cache
    +
    +# Rust build directory
    +crates/target
    diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
    index d339cb29ed..e5e69759fd 100644
    --- a/.pre-commit-config.yaml
    +++ b/.pre-commit-config.yaml
    @@ -62,6 +62,15 @@ repos:
             types_or: [c++, c, proto]
             exclude: ^include/xrpl/protocol_autogen/(transactions|ledger_entries)/
     
    +  - repo: local
    +    hooks:
    +      - id: cargo-fmt
    +        name: cargo fmt
    +        entry: cargo fmt --manifest-path crates/Cargo.toml --all
    +        language: system
    +        types: [rust]
    +        pass_filenames: false # rustfmt formats the whole workspace
    +
       - repo: https://github.com/BlankSpruce/gersemi-pre-commit
         rev: e98930bdc210d3387007f9252d8c1694ea7e410f # frozen: 0.27.7
         hooks:
    diff --git a/BUILD.md b/BUILD.md
    index ae2e69bb97..e98d204d0b 100644
    --- a/BUILD.md
    +++ b/BUILD.md
    @@ -304,6 +304,7 @@ See [Sanitizers docs](./docs/build/sanitizers.md) for more details.
     | ---------------- | ------------- | ----------------------------------------------------------------------------- |
     | `assert`         | OFF           | Force enabling assertions.                                                    |
     | `coverage`       | OFF           | Prepare the coverage report.                                                  |
    +| `rust`           | OFF           | Build the Rust crates and the C++ code that depends on them.                  |
     | `tests`          | OFF           | Build tests.                                                                  |
     | `unity`          | OFF           | Configure a unity build.                                                      |
     | `verify_headers` | ON            | Make the `verify-headers` target available to compile each header on its own. |
    @@ -316,6 +317,30 @@ memory) since they concatenate sources into fewer translation units. Non-unity
     builds may be faster for incremental builds, and can be helpful for detecting
     `#include` omissions.
     
    +### Rust crates
    +
    +The Rust crates in `crates/` are only part of the build when `rust` is ON. With
    +`-Drust=OFF` (the default) the `crates` directory is not added to the build, no
    +cxxbridge bindings are generated, and the C++ tests that exercise the Rust
    +interop are not compiled — so no Rust toolchain is needed. CI builds always pass
    +`-Drust=ON`.
    +
    +With `-Drust=ON` you need one extra dependency: a Rust toolchain (`cargo`,
    +`rustc`) matching the channel pinned in
    +[`rust-toolchain.toml`](./rust-toolchain.toml), which compiles the crates and
    +generates the cxxbridge bindings. It is provided by the
    +[Nix development shell](./docs/build/nix.md), so `-Drust=ON` works there without
    +any extra setup; otherwise install it as described in
    +[Rust](./docs/build/environment.md#rust).
    +
    +The crates also have their own Rust unit tests. Those are run with `cargo` and
    +need only the Rust toolchain, independently of CMake and of the `rust` option
    +(CI runs them with `cargo nextest`):
    +
    +```bash
    +cargo test --manifest-path crates/Cargo.toml --workspace
    +```
    +
     ### Verifying headers
     
     The regular build only compiles `.cpp` files, so a header is only ever checked
    diff --git a/CMakeLists.txt b/CMakeLists.txt
    index efe7396661..a324cecedc 100644
    --- a/CMakeLists.txt
    +++ b/CMakeLists.txt
    @@ -158,7 +158,13 @@ if(coverage)
         include(XrplCov)
     endif()
     
    +add_custom_target(tidy_prerequisites)
    +
    +if(rust)
    +    add_subdirectory(crates)
    +endif()
     include(XrplCore)
    +
     include(XrplProtocolAutogen)
     include(XrplInstall)
     include(XrplValidatorKeys)
    diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
    index fc385cf6ed..35309a9824 100644
    --- a/CONTRIBUTING.md
    +++ b/CONTRIBUTING.md
    @@ -225,8 +225,9 @@ environment, so you don't need to install most of the individual tools
     yourself. The version of each hook sourced from an external repository
     (`clang-format`, `gersemi`, etc.) is pinned in that file, so running the hooks
     locally uses exactly the same versions as CI. A few `local` hooks — most notably
    -`clang-tidy` — run tools from your own environment; see
    -[Installing clang-tidy](#installing-clang-tidy) for how to get those.
    +`clang-tidy` and `cargo fmt` — run tools from your own environment; see
    +[Installing clang-tidy](#installing-clang-tidy) and
    +[Rust](./docs/build/environment.md#rust) for how to get those.
     
     To get started, install `pre-commit` and enable the git hook scripts:
     
    @@ -255,6 +256,7 @@ The hooks configured in this repository include, among others:
     - `clang-tidy` — C++ static analysis (see [Clang-tidy](#clang-tidy)); opt in with `TIDY=1`
     - `fix-include-style`, `fix-pragma-once`, `check-doxygen-style` — C++ hygiene
     - `gersemi` — CMake formatting
    +- `cargo fmt` — Rust formatting for the crates in `crates/`
     - `prettier`, `black`, `shfmt` — formatting for JavaScript/JSON/Markdown, Python, and shell
     - `cspell` — spell checking
     
    @@ -319,7 +321,11 @@ See the [environment setup guide](./docs/build/environment.md#clang-tidy) for ho
     
     ### Running clang-tidy locally
     
    -Before running clang-tidy, you must build the project to generate required files (particularly protobuf headers). Refer to [`BUILD.md`](./BUILD.md) for build instructions.
    +Before running clang-tidy, you must generate the files it depends on (protobuf headers, and, when the project is configured with `-Drust=ON`, the cxxbridge headers from the Rust crates). Configure the project as described in [`BUILD.md`](./BUILD.md), then build the `tidy_prerequisites` target, which generates all of them:
    +
    +```bash
    +cmake --build build --target tidy_prerequisites
    +```
     
     #### Via pre-commit (recommended)
     
    diff --git a/README.md b/README.md
    index 88c7943ebb..a0d30ef68b 100644
    --- a/README.md
    +++ b/README.md
    @@ -54,6 +54,7 @@ Here are some good places to start learning the source code:
     | `./docs`   | Source documentation files and doxygen config. |
     | `./cfg`    | Example configuration files.                   |
     | `./src`    | Source code.                                   |
    +| `./crates` | Rust source code.                              |
     
     Some of the directories under `src` are external repositories included using
     git-subtree. See those directories' README files for more details.
    diff --git a/cmake/XrplCore.cmake b/cmake/XrplCore.cmake
    index a3e08145d5..f3951d4eac 100644
    --- a/cmake/XrplCore.cmake
    +++ b/cmake/XrplCore.cmake
    @@ -51,6 +51,8 @@ target_compile_options(
     
     target_link_libraries(xrpl.libpb PUBLIC protobuf::libprotobuf gRPC::grpc++)
     
    +add_dependencies(tidy_prerequisites xrpl.libpb)
    +
     # TODO: Clean up the number of library targets later.
     add_library(xrpl.imports.main INTERFACE)
     
    diff --git a/cmake/XrplSettings.cmake b/cmake/XrplSettings.cmake
    index be9bf1fda2..58b902baa1 100644
    --- a/cmake/XrplSettings.cmake
    +++ b/cmake/XrplSettings.cmake
    @@ -32,6 +32,11 @@ endif()
     
     option(benchmark "Build benchmarks" ON)
     
    +# When OFF, the crates directory is not added to the build at all: no Rust
    +# toolchain is required, no cxxbridge bindings are generated, and the C++ tests
    +# that consume those bindings are left out of the build tree.
    +option(rust "Build the Rust crates and the C++ code that depends on them" OFF)
    +
     # Enabled by default so every header is compiled on its own as the main file of
     # its own compile_commands.json entry - this is what lets clang-tidy (and clangd
     # and IDEs) analyse a header's own includes directly. The per-header objects are
    diff --git a/conan.lock b/conan.lock
    index 5b01ffbf76..176f0b27cb 100644
    --- a/conan.lock
    +++ b/conan.lock
    @@ -23,6 +23,7 @@
             "fast_float/8.2.10#f6f28d6bb22112078e7dbda611caf681%1782494504.298",
             "ed25519/2015.03#ae761bdc52730a843f0809bdf6c1b1f6%1782307148.15562",
             "date/3.0.4#862e11e80030356b53c2c38599ceb32b%1782392402.538492",
    +        "corrosion/0.6.1#bfa292df0a957bc70a450ff316cd9435%1786119416.131296",
             "c-ares/1.34.6#545240bb1c40e2cacd4362d6b8967650%1782392402.681654",
             "bzip2/1.0.8#c470882369c2d95c5c77e970c0c7e321%1782392402.296732",
             "boost/1.91.0#ea540ca2133d831b560036aa24dece3c%1782392419.475605",
    diff --git a/conanfile.py b/conanfile.py
    index 2742405b6c..0683a3779f 100644
    --- a/conanfile.py
    +++ b/conanfile.py
    @@ -28,6 +28,7 @@ class Xrpl(ConanFile):
         }
     
         requires = [
    +        "corrosion/0.6.1",
             "ed25519/2015.03",
             "fast_float/8.2.10",
             "grpc/1.81.1",
    diff --git a/crates/.cargo/config.toml b/crates/.cargo/config.toml
    new file mode 100644
    index 0000000000..fc29aa80f7
    --- /dev/null
    +++ b/crates/.cargo/config.toml
    @@ -0,0 +1,17 @@
    +# The Rust static libraries are linked into C++ targets, so the runtime linkage
    +# here has to match what the C++ build uses (see cmake/XrplCompiler.cmake).
    +#
    +# macOS needs nothing: AppleClang cannot link libgcc/libc++ statically, so the
    +# C++ build skips those flags on Apple as well.
    +
    +# Both amd64 and arm64 Linux builds link libgcc statically. This only affects
    +# links that rustc itself drives (`cargo test` binaries and the like) — the
    +# `staticlib` crates consumed by CMake are archived, not linked, so rustc
    +# silently ignores link args for them. Keeping libgcc_s.so.1 off the xrpld link
    +# line is handled in crates/CMakeLists.txt instead.
    +[target.'cfg(target_os = "linux")']
    +rustflags = ["-C", "link-args=-static-libgcc"]
    +
    +# Windows builds use the static MSVC runtime.
    +[target.'cfg(windows)']
    +rustflags = ["-C", "target-feature=+crt-static"]
    diff --git a/crates/CMakeLists.txt b/crates/CMakeLists.txt
    new file mode 100644
    index 0000000000..3f83045cdb
    --- /dev/null
    +++ b/crates/CMakeLists.txt
    @@ -0,0 +1,104 @@
    +find_package(Corrosion REQUIRED)
    +
    +corrosion_import_crate(MANIFEST_PATH ${CMAKE_CURRENT_SOURCE_DIR}/Cargo.toml)
    +
    +# The generated C++ lands in the build tree, so put a .clang-tidy next to it to
    +# keep clang-tidy from analyzing code we don't own.
    +configure_file(
    +    generated.clang-tidy
    +    "${CMAKE_CURRENT_BINARY_DIR}/.clang-tidy"
    +    COPYONLY
    +)
    +
    +add_custom_target(xrpl_crates)
    +add_dependencies(tidy_prerequisites xrpl_crates)
    +
    +# On macOS, ld warns `ignoring duplicate libraries` when linking a crate.
    +# Corrosion is the source of both duplicates it names:
    +#
    +# * The crate archive and its cxxbridge archive, because
    +#   `corrosion_add_cxxbridge` makes the two depend on each other, and CMake
    +#   repeats a static library cycle on the link line so single-pass linkers can
    +#   resolve it. (LINK_INTERFACE_MULTIPLICITY can only raise that count.)
    +# * `-lSystem`, which Corrosion copies from rustc's `native-static-libs` even
    +#   though the compiler driver always links libSystem.
    +#
    +# ld needs neither: it resolves the cycle from one copy of each archive and
    +# links libSystem once. So silence the warning rather than rewrite Corrosion's
    +# link interface, which the cycle is also part of. The option itself is old —
    +# Xcode 15 is only where the warning became the default — and the check below
    +# leaves it out on a linker that does not know it.
    +if(is_macos)
    +    include(CheckLinkerFlag)
    +    check_linker_flag(
    +        CXX
    +        -Wl,-no_warn_duplicate_libraries
    +        have_no_warn_duplicate_libraries
    +    )
    +endif()
    +
    +function(_unlink_libgcc_s crate)
    +    if(NOT (is_linux AND static))
    +        return()
    +    endif()
    +
    +    # Corrosion exposes a crate's staticlib as an imported `-static`
    +    # target and puts the native libs in its INTERFACE_LINK_LIBRARIES. If either
    +    # of those changes, warn instead of silently letting libgcc_s.so.1 return.
    +    set(imported "${crate}-static")
    +    if(NOT TARGET ${imported})
    +        message(
    +            FATAL_ERROR
    +            "Corrosion did not create the imported target '${imported}', so "
    +            "libgcc_s cannot be removed from the link interface of '${crate}'. "
    +            "xrpld will link libgcc_s.so.1 dynamically. Check where Corrosion "
    +            "${CORROSION_VERSION} now records `native-static-libs`."
    +        )
    +        return()
    +    endif()
    +
    +    get_target_property(libs ${imported} INTERFACE_LINK_LIBRARIES)
    +    if(NOT "gcc_s" IN_LIST libs)
    +        message(
    +            WARNING
    +            "'gcc_s' was not in the link interface of '${imported}' as "
    +            "expected. If the Rust toolchain stopped reporting it this "
    +            "workaround is obsolete and can be deleted; otherwise xrpld may "
    +            "link libgcc_s.so.1 dynamically. Verify with: "
    +            "objdump -p xrpld | grep NEEDED"
    +        )
    +        return()
    +    endif()
    +
    +    list(REMOVE_ITEM libs gcc_s)
    +    set_property(TARGET ${imported} PROPERTY INTERFACE_LINK_LIBRARIES ${libs})
    +endfunction()
    +
    +function(add_xrpl_crate name)
    +    cmake_parse_arguments(ARG "" "CRATE" "FILES" ${ARGN})
    +    _unlink_libgcc_s(${ARG_CRATE})
    +    # `cc` picks its runtime flag from `crt-static` alone, so it compiles a
    +    # crate's C++ with `-MT`; Debug needs `-MTd` (to match cmake/XrplCompiler.cmake).
    +    if(is_msvc)
    +        corrosion_set_env_vars(
    +            ${ARG_CRATE}
    +            "$<$:CXXFLAGS=-MTd>"
    +        )
    +    endif()
    +    corrosion_add_cxxbridge(${name}_cxxbridge CRATE ${ARG_CRATE} FILES
    +                            ${ARG_FILES}
    +    )
    +    # Generated cxxbridge headers don't exist at configure time; CMake 3.28+
    +    # validates INTERFACE_SOURCES on consuming targets. Clear it to skip the
    +    # existence check — build-time ordering is enforced by the custom commands.
    +    set_target_properties(${name}_cxxbridge PROPERTIES INTERFACE_SOURCES "")
    +    if(have_no_warn_duplicate_libraries)
    +        target_link_options(
    +            ${name}_cxxbridge
    +            INTERFACE -Wl,-no_warn_duplicate_libraries
    +        )
    +    endif()
    +    add_dependencies(xrpl_crates ${name}_cxxbridge)
    +endfunction()
    +
    +add_xrpl_crate(rs_hello_world CRATE rs_hello_world FILES lib.rs)
    diff --git a/crates/Cargo.lock b/crates/Cargo.lock
    new file mode 100644
    index 0000000000..bc38558c16
    --- /dev/null
    +++ b/crates/Cargo.lock
    @@ -0,0 +1,301 @@
    +# This file is automatically @generated by Cargo.
    +# It is not intended for manual editing.
    +version = 4
    +
    +[[package]]
    +name = "anstyle"
    +version = "1.0.14"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
    +
    +[[package]]
    +name = "cc"
    +version = "1.2.61"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"
    +dependencies = [
    + "find-msvc-tools",
    + "shlex",
    +]
    +
    +[[package]]
    +name = "clap"
    +version = "4.6.1"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
    +dependencies = [
    + "clap_builder",
    +]
    +
    +[[package]]
    +name = "clap_builder"
    +version = "4.6.0"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
    +dependencies = [
    + "anstyle",
    + "clap_lex",
    + "strsim",
    +]
    +
    +[[package]]
    +name = "clap_lex"
    +version = "1.1.0"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
    +
    +[[package]]
    +name = "codespan-reporting"
    +version = "0.13.1"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
    +dependencies = [
    + "serde",
    + "termcolor",
    + "unicode-width",
    +]
    +
    +[[package]]
    +name = "cxx"
    +version = "1.0.198"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "6fe442a792c7c736eea18b32a7f8a3b63cf8aafabda6760042dc2fdeda456291"
    +dependencies = [
    + "cc",
    + "cxx-build",
    + "cxxbridge-cmd",
    + "cxxbridge-flags",
    + "cxxbridge-macro",
    + "foldhash",
    + "link-cplusplus",
    +]
    +
    +[[package]]
    +name = "cxx-build"
    +version = "1.0.198"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "e3184a94384c663718698311a78a51ac00c484c10b4eeac06fb0a068c5f64fa2"
    +dependencies = [
    + "cc",
    + "codespan-reporting",
    + "indexmap",
    + "proc-macro2",
    + "quote",
    + "scratch",
    + "syn 3.0.3",
    +]
    +
    +[[package]]
    +name = "cxxbridge-cmd"
    +version = "1.0.198"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "0148d8fd1199329ddf1d157a5e134e51ceff37c6a7ddd38615c399d81cb05d8d"
    +dependencies = [
    + "clap",
    + "codespan-reporting",
    + "indexmap",
    + "proc-macro2",
    + "quote",
    + "syn 3.0.3",
    +]
    +
    +[[package]]
    +name = "cxxbridge-flags"
    +version = "1.0.198"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "52850339faed2eaadd24e286dc1d8268cc6f8a7bd9524d713adc9099566b4c89"
    +
    +[[package]]
    +name = "cxxbridge-macro"
    +version = "1.0.198"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "2c77c856545d886c9bd5215409ebb63b925e262135248b50c79e5a5f194ee47c"
    +dependencies = [
    + "indexmap",
    + "proc-macro2",
    + "quote",
    + "syn 3.0.3",
    +]
    +
    +[[package]]
    +name = "equivalent"
    +version = "1.0.2"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
    +
    +[[package]]
    +name = "find-msvc-tools"
    +version = "0.1.9"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
    +
    +[[package]]
    +name = "foldhash"
    +version = "0.2.0"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
    +
    +[[package]]
    +name = "hashbrown"
    +version = "0.17.0"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
    +
    +[[package]]
    +name = "indexmap"
    +version = "2.14.0"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
    +dependencies = [
    + "equivalent",
    + "hashbrown",
    +]
    +
    +[[package]]
    +name = "link-cplusplus"
    +version = "1.0.12"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "7f78c730aaa7d0b9336a299029ea49f9ee53b0ed06e9202e8cb7db9bae7b8c82"
    +dependencies = [
    + "cc",
    +]
    +
    +[[package]]
    +name = "proc-macro2"
    +version = "1.0.106"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
    +dependencies = [
    + "unicode-ident",
    +]
    +
    +[[package]]
    +name = "quote"
    +version = "1.0.45"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
    +dependencies = [
    + "proc-macro2",
    +]
    +
    +[[package]]
    +name = "rs-hello_world"
    +version = "0.1.0"
    +dependencies = [
    + "cxx",
    +]
    +
    +[[package]]
    +name = "scratch"
    +version = "1.0.9"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "d68f2ec51b097e4c1a75b681a8bec621909b5e91f15bb7b840c4f2f7b01148b2"
    +
    +[[package]]
    +name = "serde"
    +version = "1.0.228"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
    +dependencies = [
    + "serde_core",
    + "serde_derive",
    +]
    +
    +[[package]]
    +name = "serde_core"
    +version = "1.0.228"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
    +dependencies = [
    + "serde_derive",
    +]
    +
    +[[package]]
    +name = "serde_derive"
    +version = "1.0.228"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
    +dependencies = [
    + "proc-macro2",
    + "quote",
    + "syn 2.0.117",
    +]
    +
    +[[package]]
    +name = "shlex"
    +version = "1.3.0"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
    +
    +[[package]]
    +name = "strsim"
    +version = "0.11.1"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
    +
    +[[package]]
    +name = "syn"
    +version = "2.0.117"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
    +dependencies = [
    + "proc-macro2",
    + "quote",
    + "unicode-ident",
    +]
    +
    +[[package]]
    +name = "syn"
    +version = "3.0.3"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
    +dependencies = [
    + "proc-macro2",
    + "quote",
    + "unicode-ident",
    +]
    +
    +[[package]]
    +name = "termcolor"
    +version = "1.4.1"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
    +dependencies = [
    + "winapi-util",
    +]
    +
    +[[package]]
    +name = "unicode-ident"
    +version = "1.0.24"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
    +
    +[[package]]
    +name = "unicode-width"
    +version = "0.2.2"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
    +
    +[[package]]
    +name = "winapi-util"
    +version = "0.1.11"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
    +dependencies = [
    + "windows-sys",
    +]
    +
    +[[package]]
    +name = "windows-link"
    +version = "0.2.1"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
    +
    +[[package]]
    +name = "windows-sys"
    +version = "0.61.2"
    +source = "registry+https://github.com/rust-lang/crates.io-index"
    +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
    +dependencies = [
    + "windows-link",
    +]
    diff --git a/crates/Cargo.toml b/crates/Cargo.toml
    new file mode 100644
    index 0000000000..0bb0e9c550
    --- /dev/null
    +++ b/crates/Cargo.toml
    @@ -0,0 +1,15 @@
    +[workspace]
    +members = ["hello_world"]
    +resolver = "3"
    +
    +[workspace.dependencies]
    +cxx = { version = "1.0.198", features = ["c++20"] }
    +
    +[workspace.package]
    +edition = "2024"
    +
    +[profile.release]
    +opt-level = 3
    +overflow-checks = true
    +lto = true
    +debug = true
    diff --git a/crates/generated.clang-tidy b/crates/generated.clang-tidy
    new file mode 100644
    index 0000000000..8e2202d44a
    --- /dev/null
    +++ b/crates/generated.clang-tidy
    @@ -0,0 +1,10 @@
    +---
    +# Neutralizes clang-tidy for the corrosion/cxxbridge-generated C++. Copied into
    +# the crates build directory by crates/CMakeLists.txt, next to the generated
    +# sources, so clang-tidy picks it up instead of the top-level configuration.
    +#
    +# One check is kept enabled to avoid clang-tidy's "no checks enabled" error.
    +Checks: "-*,google-readability-todo"
    +WarningsAsErrors: ""
    +HeaderFilterRegex: ""
    +InheritParentConfig: false
    diff --git a/crates/hello_world/Cargo.toml b/crates/hello_world/Cargo.toml
    new file mode 100644
    index 0000000000..2e5a329c9a
    --- /dev/null
    +++ b/crates/hello_world/Cargo.toml
    @@ -0,0 +1,10 @@
    +[package]
    +name = "rs-hello_world"
    +version = "0.1.0"
    +edition.workspace = true
    +
    +[lib]
    +crate-type = ["staticlib"]
    +
    +[dependencies]
    +cxx.workspace = true
    diff --git a/crates/hello_world/src/lib.rs b/crates/hello_world/src/lib.rs
    new file mode 100644
    index 0000000000..b1cb121fa0
    --- /dev/null
    +++ b/crates/hello_world/src/lib.rs
    @@ -0,0 +1,10 @@
    +#[cxx::bridge(namespace = "rs::hello_world")]
    +mod ffi {
    +    extern "Rust" {
    +        fn hello_world() -> String;
    +    }
    +}
    +
    +pub fn hello_world() -> String {
    +    "hello_world".to_string()
    +}
    diff --git a/docs/build/environment.md b/docs/build/environment.md
    index 5616f32f37..51580b12a5 100644
    --- a/docs/build/environment.md
    +++ b/docs/build/environment.md
    @@ -46,6 +46,9 @@ Besides a compiler, building `xrpld` requires:
     On Linux and macOS, the [Nix development shell](./nix.md) provides all of them
     (see below). On Windows they have to be installed manually.
     
    +Building with `-Drust=ON` additionally requires a Rust toolchain, see
    +[Rust](#rust). A default build does not, so it is not in the table above.
    +
     Once they are in place, verify that everything is installed and runnable with:
     
     ```bash
    @@ -121,6 +124,25 @@ manually:
     - [Git for Windows](https://git-scm.com/download/win)
     - Python, Conan, and CMake, at the versions listed in
       [Required tools](#required-tools).
    +- a [Rust toolchain](https://rustup.rs) — only needed to build with
    +  `-Drust=ON`, see [Rust](#rust)
    +
    +## Rust
    +
    +The repository contains a Rust workspace in [`crates/`](../../crates), whose
    +crates are exposed to C++ through [cxx](https://cxx.rs) bindings. It is **not**
    +part of a default build: the CMake `rust` option is OFF by default, and with it
    +off no Rust toolchain is needed. It is only required when configuring with
    +`-Drust=ON` (which is what CI does), see [Options](../../BUILD.md#options).
    +
    +The toolchain (`cargo`, `rustc`) is pinned to the channel in
    +[`rust-toolchain.toml`](../../rust-toolchain.toml) at the repository root. If
    +you install Rust with [rustup](https://rustup.rs), that file is picked up
    +automatically, and `cargo`/`rustc` in the repository will use the pinned
    +version.
    +
    +Everything else the Rust build needs on the CMake side comes from Conan along
    +with the rest of the dependencies, so there is nothing further to install.
     
     ## Clang-tidy
     
    diff --git a/docs/build/nix.md b/docs/build/nix.md
    index 4c082afb28..0b701b39f3 100644
    --- a/docs/build/nix.md
    +++ b/docs/build/nix.md
    @@ -128,6 +128,12 @@ Coverage builds (`-Dcoverage=ON`) work in the `gcc` shell (and `gcc-plain` on Li
     each ships a `gcov` matching its compiler, since Nix's cc-wrapper does not expose one.
     The `clang` shells do not include `llvm-cov`, so use a `gcc` shell for coverage.
     
    +Builds of the Rust crates (`-Drust=ON`) also work out of the box: every shell
    +provides the Rust toolchain pinned in
    +[`rust-toolchain.toml`](../../rust-toolchain.toml) (see
    +[Rust](./environment.md#rust)), plus the `cargo-audit`, `cargo-llvm-cov` and
    +`cargo-nextest` plugins.
    +
     ## Conan configuration
     
     The shell runs [`conan/init.sh`](../../conan/init.sh) on entry, so
    diff --git a/src/tests/libxrpl/CMakeLists.txt b/src/tests/libxrpl/CMakeLists.txt
    index 5e4cda243a..9cbfb8ca10 100644
    --- a/src/tests/libxrpl/CMakeLists.txt
    +++ b/src/tests/libxrpl/CMakeLists.txt
    @@ -43,6 +43,9 @@ set(test_modules
     if(NOT WIN32)
         list(APPEND test_modules net)
     endif()
    +if(rust)
    +    target_link_libraries(xrpl_tests PRIVATE rs_hello_world_cxxbridge)
    +endif()
     
     foreach(module IN LISTS test_modules)
         # Append the module's sources (${module}/*.cpp and ${module}.cpp, if any).
    @@ -52,6 +55,12 @@ foreach(module IN LISTS test_modules)
             "${CMAKE_CURRENT_SOURCE_DIR}/${module}/*.cpp"
             "${CMAKE_CURRENT_SOURCE_DIR}/${module}.cpp"
         )
    +    if(NOT rust)
    +        # Tests of the Rust interop include generated cxxbridge headers, which
    +        # do not exist without the crates, so keep them out of the build tree
    +        # entirely. They are named `Rust.cpp`.
    +        list(FILTER sources EXCLUDE REGEX "/Rust[^/]*\\.cpp$")
    +    endif()
         target_sources(xrpl_tests PRIVATE ${sources})
     
         # Expose the module's private headers under their canonical include path.
    diff --git a/src/tests/libxrpl/basics/RustInterop.cpp b/src/tests/libxrpl/basics/RustInterop.cpp
    new file mode 100644
    index 0000000000..8a6ad8a4ed
    --- /dev/null
    +++ b/src/tests/libxrpl/basics/RustInterop.cpp
    @@ -0,0 +1,9 @@
    +#include 
    +#include 
    +
    +#include 
    +
    +TEST(RustInteropTest, hello_world)
    +{
    +    EXPECT_EQ(std::string(rs::hello_world::hello_world()), "hello_world");
    +}
    
    From da57183e0c2682143e34949749b5f201c8f4135f Mon Sep 17 00:00:00 2001
    From: Ayaz Salikhov 
    Date: Wed, 19 Aug 2026 15:05:04 +0000
    Subject: [PATCH 102/102] build: Compress the RPM payload with zstd (#8047)
    
    ---
     .cspell.config.yaml    | 1 +
     package/README.md      | 7 +------
     package/rpm/xrpld.spec | 6 ++++--
     3 files changed, 6 insertions(+), 8 deletions(-)
    
    diff --git a/.cspell.config.yaml b/.cspell.config.yaml
    index 6220cfd60e..e8c5f3c30f 100644
    --- a/.cspell.config.yaml
    +++ b/.cspell.config.yaml
    @@ -384,4 +384,5 @@ words:
       - xrplf
       - xxhash
       - xxhasher
    +  - zstdio
       - CGNAT
    diff --git a/package/README.md b/package/README.md
    index 516dcf9d9b..9c40861530 100644
    --- a/package/README.md
    +++ b/package/README.md
    @@ -238,14 +238,9 @@ what catches a binary still linked against the Nix store's ELF loader (see
     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
    -   writes uncompressed RPM payloads while generating debuginfo packages.
    +   generates debuginfo packages.
     4. Output: `rpmbuild/RPMS/x86_64/xrpld-*.rpm`
     
    -The uncompressed RPM payload setting is intentionally unconditional for
    -generated RPMs. It trades larger RPM artifacts for much shorter package
    -build/validation time, which keeps RPM package validation in the same rough time
    -class as Debian package validation.
    -
     RPM upgrades intentionally do not restart a running `xrpld` service. The spec
     uses `%systemd_postun`, matching Debian's `dh_installsystemd
     --no-stop-on-upgrade` behavior; operators pick up the new binary on the next
    diff --git a/package/rpm/xrpld.spec b/package/rpm/xrpld.spec
    index 0e3ee2a968..23974c8900 100644
    --- a/package/rpm/xrpld.spec
    +++ b/package/rpm/xrpld.spec
    @@ -19,8 +19,10 @@ BuildRequires: systemd-rpm-macros
     
     %undefine _debugsource_packages
     %debug_package
    -# Intentionally trade larger RPM artifacts for faster package validation.
    -%global _binary_payload w.ufdio
    +# Level 3 rather than the el9 default of 19: it shrinks the multi-gigabyte
    +# debuginfo package roughly fourfold in about a second, where 19 would spend
    +# minutes on it.
    +%global _binary_payload w3.zstdio
     %global _find_debuginfo_dwz_opts %{nil}
     
     %build_mtime_policy clamp_to_source_date_epoch
    

    AaLi9S$CuuB?2~`qvfQM&JShVuX|H+j{%OMA}w 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