From 28cc20c81679fd8bb9aa3ac85e450d1daf387816 Mon Sep 17 00:00:00 2001 From: Shawn Xie <35279399+shawnxie999@users.noreply.github.com> Date: Thu, 21 May 2026 02:19:04 -0400 Subject: [PATCH] fix: Fix wrong hybrid offer orderbook placement and update `LedgerStateFix` to amend `ExchangeRate` meta (#7087) Co-authored-by: Peter Chen --- .../xrpl/protocol/detail/transactions.macro | 1 + .../transactions/LedgerStateFix.h | 37 +++ .../xrpl/tx/invariants/DirectoryInvariant.h | 27 ++ include/xrpl/tx/invariants/InvariantCheck.h | 2 + include/xrpl/tx/transactors/dex/OfferCreate.h | 1 + .../tx/transactors/system/LedgerStateFix.h | 1 + .../tx/invariants/DirectoryInvariant.cpp | 96 +++++++ .../tx/transactors/dex/OfferCreate.cpp | 13 +- .../tx/transactors/system/LedgerStateFix.cpp | 86 +++++- src/test/app/FixNFTokenPageLinks_test.cpp | 7 + src/test/app/Invariants_test.cpp | 103 +++++++ src/test/app/PermissionedDEX_test.cpp | 262 ++++++++++++++++++ src/test/jtx/impl/ledgerStateFixes.cpp | 13 + src/test/jtx/ledgerStateFix.h | 4 + .../transactions/LedgerStateFixTests.cpp | 21 ++ 15 files changed, 669 insertions(+), 5 deletions(-) create mode 100644 include/xrpl/tx/invariants/DirectoryInvariant.h create mode 100644 src/libxrpl/tx/invariants/DirectoryInvariant.cpp diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index 9bac9ef7db..450e2558cc 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -688,6 +688,7 @@ TRANSACTION(ttLEDGER_STATE_FIX, 53, LedgerStateFix, ({ {sfLedgerFixType, SoeRequired}, {sfOwner, SoeOptional}, + {sfBookDirectory, SoeOptional}, })) /** This transaction type creates a MPTokensIssuance instance */ diff --git a/include/xrpl/protocol_autogen/transactions/LedgerStateFix.h b/include/xrpl/protocol_autogen/transactions/LedgerStateFix.h index 3c58815a02..52723ad5eb 100644 --- a/include/xrpl/protocol_autogen/transactions/LedgerStateFix.h +++ b/include/xrpl/protocol_autogen/transactions/LedgerStateFix.h @@ -83,6 +83,32 @@ public: { return this->tx_->isFieldPresent(sfOwner); } + + /** + * @brief Get sfBookDirectory (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getBookDirectory() const + { + if (hasBookDirectory()) + { + return this->tx_->at(sfBookDirectory); + } + return std::nullopt; + } + + /** + * @brief Check if sfBookDirectory is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasBookDirectory() const + { + return this->tx_->isFieldPresent(sfBookDirectory); + } }; /** @@ -149,6 +175,17 @@ public: return *this; } + /** + * @brief Set sfBookDirectory (SoeOptional) + * @return Reference to this builder for method chaining. + */ + LedgerStateFixBuilder& + setBookDirectory(std::decay_t const& value) + { + object_[sfBookDirectory] = value; + return *this; + } + /** * @brief Build and return the LedgerStateFix wrapper. * @param publicKey The public key for signing. diff --git a/include/xrpl/tx/invariants/DirectoryInvariant.h b/include/xrpl/tx/invariants/DirectoryInvariant.h new file mode 100644 index 0000000000..96643ea465 --- /dev/null +++ b/include/xrpl/tx/invariants/DirectoryInvariant.h @@ -0,0 +1,27 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +namespace xrpl { + +class ValidBookDirectory +{ + bool badBookDirectory_ = false; + hash_set rootIndexes_; + +public: + void + visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + + bool + finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&); +}; + +} // namespace xrpl diff --git a/include/xrpl/tx/invariants/InvariantCheck.h b/include/xrpl/tx/invariants/InvariantCheck.h index bc9608fd56..a58ac0df50 100644 --- a/include/xrpl/tx/invariants/InvariantCheck.h +++ b/include/xrpl/tx/invariants/InvariantCheck.h @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -393,6 +394,7 @@ using InvariantChecks = std::tuple< ValidMPTIssuance, ValidPermissionedDomain, ValidPermissionedDEX, + ValidBookDirectory, ValidAMM, NoModifiedUnmodifiableFields, ValidPseudoAccounts, diff --git a/include/xrpl/tx/transactors/dex/OfferCreate.h b/include/xrpl/tx/transactors/dex/OfferCreate.h index d1ac8b5626..efae5312e2 100644 --- a/include/xrpl/tx/transactors/dex/OfferCreate.h +++ b/include/xrpl/tx/transactors/dex/OfferCreate.h @@ -85,6 +85,7 @@ private: Keylet const& offerIndex, STAmount const& saTakerPays, STAmount const& saTakerGets, + std::uint64_t openRate, std::function)> const& setDir); }; diff --git a/include/xrpl/tx/transactors/system/LedgerStateFix.h b/include/xrpl/tx/transactors/system/LedgerStateFix.h index 8d704467eb..6fbae6fc6a 100644 --- a/include/xrpl/tx/transactors/system/LedgerStateFix.h +++ b/include/xrpl/tx/transactors/system/LedgerStateFix.h @@ -9,6 +9,7 @@ class LedgerStateFix : public Transactor public: enum class FixType : std::uint16_t { NfTokenPageLink = 1, + BookExchangeRate = 2, }; static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Normal; diff --git a/src/libxrpl/tx/invariants/DirectoryInvariant.cpp b/src/libxrpl/tx/invariants/DirectoryInvariant.cpp new file mode 100644 index 0000000000..2a4fac07d0 --- /dev/null +++ b/src/libxrpl/tx/invariants/DirectoryInvariant.cpp @@ -0,0 +1,96 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace xrpl { + +namespace { + +[[nodiscard]] bool +isRootBookDirectory(SLE const& dir) +{ + // Child page keys do not encode book quality. + return dir.isFieldPresent(sfExchangeRate) || dir.isFieldPresent(sfTakerPaysCurrency) || + dir.isFieldPresent(sfTakerPaysIssuer) || dir.isFieldPresent(sfTakerPaysMPT) || + dir.isFieldPresent(sfTakerGetsCurrency) || dir.isFieldPresent(sfTakerGetsIssuer) || + dir.isFieldPresent(sfTakerGetsMPT) || dir.isFieldPresent(sfDomainID); +} + +[[nodiscard]] bool +badExchangeRate(SLE const& dir) +{ + return isRootBookDirectory(dir) && + (!dir.isFieldPresent(sfExchangeRate) || + dir.getFieldU64(sfExchangeRate) != getQuality(dir.key())); +} + +} // namespace + +void +ValidBookDirectory::visitEntry( + bool, + std::shared_ptr const& before, + std::shared_ptr const& after) +{ + // New root directories must have matching exchange-rate metadata. New + // child directories must point to an existing root. + + // Only validate newly-created directories; LedgerStateFix handles legacy + // bad exchange-rate metadata. + if (badBookDirectory_ || before || !after || after->getType() != ltDIR_NODE) + return; + + auto const rootIndex = after->getFieldH256(sfRootIndex); + if (after->key() == rootIndex && !badBookDirectory_) + { + badBookDirectory_ = badBookDirectory_ || badExchangeRate(*after); + return; + } + + rootIndexes_.insert(rootIndex); +} + +bool +ValidBookDirectory::finalize( + STTx const&, + TER const, + XRPAmount const, + ReadView const& view, + beast::Journal const& j) +{ + if (!view.rules().enabled(fixCleanup3_2_0)) + return true; + + if (badBookDirectory_) + { + JLOG(j.fatal()) << "Invariant failed: book directory exchange rate " + "does not match directory quality"; + return false; + } + + for (auto const& rootIndex : rootIndexes_) + { + auto const root = view.read(Keylet(ltDIR_NODE, rootIndex)); + if (!root) + { + JLOG(j.fatal()) << "Invariant failed: book directory root missing"; + return false; + } + } + + return true; +} + +} // namespace xrpl diff --git a/src/libxrpl/tx/transactors/dex/OfferCreate.cpp b/src/libxrpl/tx/transactors/dex/OfferCreate.cpp index 69c101644c..ed9eb1255d 100644 --- a/src/libxrpl/tx/transactors/dex/OfferCreate.cpp +++ b/src/libxrpl/tx/transactors/dex/OfferCreate.cpp @@ -547,6 +547,7 @@ OfferCreate::applyHybrid( Keylet const& offerKey, STAmount const& saTakerPays, STAmount const& saTakerGets, + std::uint64_t openRate, std::function)> const& setDir) { if (!sleOffer->isFieldPresent(sfDomainID)) @@ -558,7 +559,7 @@ OfferCreate::applyHybrid( // if offer is hybrid, need to also place into open offer dir Book const book{saTakerPays.asset(), saTakerGets.asset(), std::nullopt}; - auto dir = keylet::quality(keylet::kBook(book), getRate(saTakerGets, saTakerPays)); + auto dir = keylet::quality(keylet::kBook(book), openRate); bool const bookExists = sb.exists(dir); auto const bookNode = sb.dirAppend(dir, offerKey, [&](SLE::ref sle) { @@ -924,8 +925,16 @@ OfferCreate::applyGuts(Sandbox& sb, Sandbox& sbCancel) // if it's a hybrid offer, set hybrid flag, and create an open dir if (bHybrid) { + // Pre-fixCleanup3_2_0: the open-book directory quality was computed + // from post-crossing amounts, which may differ from the original rate + // due to rounding in rate preservation. Post-fixCleanup3_2_0: use the + // original placement rate so the open-book directory quality matches + // the domain-book directory. + auto const openRate = ctx_.view().rules().enabled(fixCleanup3_2_0) + ? uRate + : getRate(saTakerGets, saTakerPays); auto const res = - applyHybrid(sb, sleOffer, offerIndex, saTakerPays, saTakerGets, setBookDir); + applyHybrid(sb, sleOffer, offerIndex, saTakerPays, saTakerGets, openRate, setBookDir); if (!isTesSuccess(res)) return {res, true}; // LCOV_EXCL_LINE } diff --git a/src/libxrpl/tx/transactors/system/LedgerStateFix.cpp b/src/libxrpl/tx/transactors/system/LedgerStateFix.cpp index 2f30311e6f..222e5dce7a 100644 --- a/src/libxrpl/tx/transactors/system/LedgerStateFix.cpp +++ b/src/libxrpl/tx/transactors/system/LedgerStateFix.cpp @@ -4,7 +4,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -12,24 +14,72 @@ #include #include +#include +#include #include +#include namespace xrpl { +namespace { + +using FixType = LedgerStateFix::FixType; + +std::array, 2> const kLedgerFixFields = {{ + {FixType::NfTokenPageLink, &sfOwner}, + {FixType::BookExchangeRate, &sfBookDirectory}, +}}; + +[[nodiscard]] SField const* +fixField(FixType const fixType) +{ + auto const iter = std::ranges::find_if( + kLedgerFixFields, [fixType](auto const& entry) { return entry.first == fixType; }); + + if (iter == kLedgerFixFields.end()) + return nullptr; // LCOV_EXCL_LINE + + return iter->second; +} + +[[nodiscard]] bool +hasUnexpectedFixField(STTx const& tx, SField const& expected) +{ + return std::ranges::any_of(kLedgerFixFields, [&tx, &expected](auto const& entry) { + auto const field = entry.second; + return field != &expected && tx.isFieldPresent(*field); + }); +} + +} // namespace + NotTEC LedgerStateFix::preflight(PreflightContext const& ctx) { - switch (static_cast(ctx.tx[sfLedgerFixType])) + auto const fixType = static_cast(ctx.tx[sfLedgerFixType]); + + switch (fixType) { case FixType::NfTokenPageLink: - if (!ctx.tx.isFieldPresent(sfOwner)) - return temINVALID; + break; + + case FixType::BookExchangeRate: + if (!ctx.rules.enabled(fixCleanup3_2_0)) + return temDISABLED; break; default: return tefINVALID_LEDGER_FIX_TYPE; } + auto const expectedField = fixField(fixType); + if (expectedField == nullptr) + return tefINVALID_LEDGER_FIX_TYPE; // LCOV_EXCL_LINE + + // Each fix type allows exactly one fix-specific field. + if (!ctx.tx.isFieldPresent(*expectedField) || hasUnexpectedFixField(ctx.tx, *expectedField)) + return temINVALID; + return tesSUCCESS; } @@ -53,6 +103,24 @@ LedgerStateFix::preclaim(PreclaimContext const& ctx) return tesSUCCESS; } + if (static_cast(ctx.tx[sfLedgerFixType]) == FixType::BookExchangeRate) + { + auto const dirKey = ctx.tx.getFieldH256(sfBookDirectory); + auto const sle = ctx.view.read(Keylet(ltDIR_NODE, dirKey)); + if (!sle) + return tecOBJECT_NOT_FOUND; + + // Must be the first page of a book directory (has sfExchangeRate). + if (!sle->isFieldPresent(sfExchangeRate)) + return tecNO_PERMISSION; + + // ExchangeRate is already correct, nothing to fix. + if (getQuality(sle->key()) == sle->getFieldU64(sfExchangeRate)) + return tecNO_PERMISSION; + + return tesSUCCESS; + } + // preflight is supposed to verify that only valid FixTypes get to preclaim. return tecINTERNAL; // LCOV_EXCL_LINE } @@ -68,6 +136,18 @@ LedgerStateFix::doApply() return tesSUCCESS; } + if (static_cast(ctx_.tx[sfLedgerFixType]) == FixType::BookExchangeRate) + { + auto const dirKey = ctx_.tx.getFieldH256(sfBookDirectory); + auto sle = view().peek(Keylet(ltDIR_NODE, dirKey)); + if (!sle) + return tecINTERNAL; // LCOV_EXCL_LINE + + sle->setFieldU64(sfExchangeRate, getQuality(sle->key())); + view().update(sle); + return tesSUCCESS; + } + // preflight is supposed to verify that only valid FixTypes get to doApply. return tecINTERNAL; // LCOV_EXCL_LINE } diff --git a/src/test/app/FixNFTokenPageLinks_test.cpp b/src/test/app/FixNFTokenPageLinks_test.cpp index f77d924c94..cfe2fd1808 100644 --- a/src/test/app/FixNFTokenPageLinks_test.cpp +++ b/src/test/app/FixNFTokenPageLinks_test.cpp @@ -173,6 +173,13 @@ class FixNFTokenPageLinks_test : public beast::unit_test::Suite tx.removeMember(sfOwner.jsonName); env(tx, Fee(linkFixFee), Ter(temINVALID)); } + { + // NFTokenPageLink fixes require sfOwner and reject fields that + // belong to other LedgerStateFix types. + json::Value tx = ledgerStateFix::nftPageLinks(alice, alice); + tx[sfBookDirectory.jsonName] = to_string(uint256{1}); + env(tx, Fee(linkFixFee), Ter(temINVALID)); + } { // Invalid LedgerFixType codes. json::Value tx = ledgerStateFix::nftPageLinks(alice, alice); diff --git a/src/test/app/Invariants_test.cpp b/src/test/app/Invariants_test.cpp index 21094e0142..5cb872f291 100644 --- a/src/test/app/Invariants_test.cpp +++ b/src/test/app/Invariants_test.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -50,6 +51,7 @@ #include #include #include +#include #include #include @@ -2037,6 +2039,106 @@ class Invariants_test : public beast::unit_test::Suite } } + void + testBookDirectoryExchangeRate() + { + using namespace test::jtx; + testcase << "book directory exchange rate"; + + auto const getBookRootKey = [](Account const& account, std::uint64_t quality) { + Book const book{xrpIssue(), account["USD"], std::nullopt}; + return keylet::quality(keylet::kBook(book), quality); + }; + + // Root book-directory pages carry exchange-rate metadata that must + // match the quality encoded in the directory key. + auto const makeRootPage = [](Keylet const& dir, std::uint64_t exchangeRate) { + auto sleDir = std::make_shared(dir); + sleDir->setFieldH256(sfRootIndex, dir.key); + STVector256 indexes; + indexes.pushBack(uint256{1}); + sleDir->setFieldV256(sfIndexes, indexes); + sleDir->setFieldU64(sfExchangeRate, exchangeRate); + return sleDir; + }; + + // Child pages do not carry quality metadata; they only point back to + // the root directory. + auto const makeChildPage = [](Keylet const& rootDir) { + auto sleDir = std::make_shared(keylet::page(rootDir, 1)); + sleDir->setFieldH256(sfRootIndex, rootDir.key); + STVector256 indexes; + indexes.pushBack(uint256{2}); + sleDir->setFieldV256(sfIndexes, indexes); + return sleDir; + }; + + auto const makeOfferCreateTx = [] { + return STTx{ttOFFER_CREATE, [](STObject& tx) { + Account const account{"A1"}; + tx.setFieldAmount(sfTakerPays, XRP(1)); + tx.setFieldAmount(sfTakerGets, account["USD"](1)); + }}; + }; + std::initializer_list const failTers = {tecINVARIANT_FAILED, tefINVARIANT_FAILED}; + + // Creating a root book directory with mismatched exchange-rate + // metadata violates the invariant. + doInvariantCheck( + {{"book directory exchange rate does not match directory quality"}}, + [&](Account const& a1, Account const&, ApplyContext& ac) { + auto const directoryQuality = STAmount::kURateOne; + auto const dir = getBookRootKey(a1, directoryQuality); + ac.view().insert(makeRootPage(dir, directoryQuality + 1)); + return true; + }, + XRPAmount{}, + makeOfferCreateTx(), + failTers); + + // A new child page must point to an existing root page. + doInvariantCheck( + {{"book directory root missing"}}, + [&](Account const& a1, Account const&, ApplyContext& ac) { + auto const directoryQuality = STAmount::kURateOne; + auto const rootDir = getBookRootKey(a1, directoryQuality); + // Insert only the child page. It points at rootDir, but the + // corresponding root page is intentionally missing. + ac.view().insert(makeChildPage(rootDir)); + return true; + }, + XRPAmount{}, + makeOfferCreateTx(), + failTers); + + // Legacy bad-root tolerance: + // - The view contains a pre-existing root page with bad sfExchangeRate + // metadata. + // - The simulated transaction only creates a child page pointing to + // that root. + // - The invariant must pass because this transaction did not create + // the bad root, only adding a child page. + { + Env env{*this, defaultAmendments()}; + Account const a1{"A1"}; + env.fund(XRP(1000), a1); + env.close(); + + OpenView view{*env.current()}; + auto const directoryQuality = STAmount::kURateOne; + auto const rootDir = getBookRootKey(a1, directoryQuality); + view.rawInsert(makeRootPage(rootDir, directoryQuality + 1)); + + ValidBookDirectory invariant; + invariant.visitEntry(false, nullptr, makeChildPage(rootDir)); + + test::StreamSink sink{beast::Severity::Warning}; + beast::Journal const jlog{sink}; + BEAST_EXPECT( + invariant.finalize(makeOfferCreateTx(), tesSUCCESS, XRPAmount{}, view, jlog)); + } + } + Keylet createLoanBroker(jtx::Account const& a, jtx::Env& env, jtx::PrettyAsset const& asset) { @@ -4489,6 +4591,7 @@ public: testPermissionedDomainInvariants(defaultAmendments() - fixCleanup3_1_3); testPermissionedDEX(defaultAmendments() | fixCleanup3_1_3); testPermissionedDEX(defaultAmendments() - fixCleanup3_1_3); + testBookDirectoryExchangeRate(); testNoModifiedUnmodifiableFields(); testValidPseudoAccounts(); testValidLoanBroker(); diff --git a/src/test/app/PermissionedDEX_test.cpp b/src/test/app/PermissionedDEX_test.cpp index 3a95fb2f92..c6e94d7994 100644 --- a/src/test/app/PermissionedDEX_test.cpp +++ b/src/test/app/PermissionedDEX_test.cpp @@ -7,7 +7,9 @@ #include #include #include +#include #include +#include #include #include // IWYU pragma: keep #include @@ -1457,6 +1459,263 @@ class PermissionedDEX_test : public beast::unit_test::Suite } } + void + testHybridOfferCrossingQuality(FeatureBitset features) + { + bool const fixEnabled = features[fixCleanup3_2_0]; + testcase << "Hybrid offer crossing quality" + << (fixEnabled ? " (fixCleanup3_2_0)" : " (pre-fix)"); + + // Partially-crossed hybrid offer should have consistent quality + // across both book directories. + // + // Steps: + // - Bob places a hybrid offer. + // - Alice places an opposing hybrid offer that partially crosses. + // + // Verify: + // - Domain-book key quality == its sfExchangeRate. + // - Post-fix: open-book key quality == domain-book key quality. + // - Pre-fix: open-book key quality != domain-book key quality + // (key used post-crossing rate, sfExchangeRate used pre-crossing). + + Env env(*this, features); + auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] = + PermissionedDEX(env); + + // Bob places a hybrid offer: TakerPays = XRP(100), TakerGets = USD(40) + auto const bobOfferSeq{env.seq(bob_)}; + env(offer(bob_, XRP(100), USD(40)), Txflags(tfHybrid), Domain(domainID)); + env.close(); + BEAST_EXPECT(offerExists(env, bob_, bobOfferSeq)); + + // Alice places a hybrid offer in the opposite direction that + // partially crosses Bob's offer. + // Alice: TakerPays = USD(100), TakerGets = XRP(300) (rate = 3 XRP/USD) + // Bob's offer is at a better rate (2.5 XRP/USD) so crossing occurs. + auto const aliceOfferSeq{env.seq(alice_)}; + env(offer(alice_, USD(100), XRP(300)), Txflags(tfHybrid), Domain(domainID)); + env.close(); + + // After crossing, Alice's remaining offer should be placed. + auto const sle = env.le(keylet::offer(alice_.id(), aliceOfferSeq)); + BEAST_EXPECT(sle); + BEAST_EXPECT(sle->isFieldPresent(sfAdditionalBooks)); + BEAST_EXPECT(sle->getFieldArray(sfAdditionalBooks).size() == 1); + + auto const domainDirKey = sle->getFieldH256(sfBookDirectory); + auto const openDirKey = + sle->getFieldArray(sfAdditionalBooks)[0].getFieldH256(sfBookDirectory); + + auto const domainQuality = getQuality(domainDirKey); + auto const openQuality = getQuality(openDirKey); + + // Read the directory SLEs and check sfExchangeRate vs key quality. + auto const domainDirSle = env.le(Keylet(ltDIR_NODE, domainDirKey)); + auto const openDirSle = env.le(Keylet(ltDIR_NODE, openDirKey)); + BEAST_EXPECT(domainDirSle); + BEAST_EXPECT(openDirSle); + + auto const domainExRate = domainDirSle->getFieldU64(sfExchangeRate); + auto const openExRate = openDirSle->getFieldU64(sfExchangeRate); + auto const preCrossingQuality = std::uint64_t{5623825668291712342ULL}; + auto const postCrossingQuality = std::uint64_t{5623825668291712341ULL}; + + // Domain directory: sfExchangeRate should always match key quality + // (both use the pre-crossing rate). Correct behavior. + BEAST_EXPECT(domainQuality == preCrossingQuality); + BEAST_EXPECT(domainExRate == preCrossingQuality); + BEAST_EXPECT(domainExRate == domainQuality); + + if (fixEnabled) + { + // Correct behavior: both directory keys use the pre-crossing rate. + BEAST_EXPECT(openQuality == preCrossingQuality); + BEAST_EXPECT(domainQuality == openQuality); + + // sfExchangeRate matches key quality on both directories. + BEAST_EXPECT(openExRate == preCrossingQuality); + BEAST_EXPECT(openExRate == openQuality); + } + else + { + // Wrong legacy behavior: the open-book directory key uses the + // post-crossing rate instead of the domain-book rate. + BEAST_EXPECT(openQuality == postCrossingQuality); + BEAST_EXPECT(domainQuality != openQuality); + + // The open-book sfExchangeRate still uses the pre-crossing rate, + // so it no longer matches the actual quality encoded in the + // open-book directory key. + BEAST_EXPECT(openExRate == preCrossingQuality); + BEAST_EXPECT(openExRate != openQuality); + BEAST_EXPECT(openExRate == domainQuality); + } + } + + void + testBookExchangeRateFix(FeatureBitset features) + { + testcase("LedgerStateFix BookExchangeRate"); + + // Use the pre-fix path to create a hybrid offer with a mismatched + // sfExchangeRate, then apply LedgerStateFix to correct it. + // + // Steps: + // - Create a partially-crossed hybrid offer (pre-fixCleanup3_2_0) + // so the open-book directory has wrong sfExchangeRate. + // - Re-enable fixCleanup3_2_0 and submit a LedgerStateFix to + // repair the open-book directory's sfExchangeRate. + // + // Verify: + // - Before fix: sfExchangeRate != getQuality(key). + // - After fix: sfExchangeRate == getQuality(key). + + { + // Amendment gate: BookExchangeRate fixes require fixCleanup3_2_0. + Env env(*this, features - fixCleanup3_2_0); + Account const carol{"carol"}; + + env.fund(XRP(1000), carol); + env.close(); + + env(ledgerStateFix::bookExchangeRate(carol, uint256{1}), Ter(temDISABLED)); + } + + { + // Preflight check: BookExchangeRate fixes only accept their + // required fix-specific field. + Env env(*this, features); + Account const carol{"carol"}; + + env.fund(XRP(1000), carol); + env.close(); + + // BookExchangeRate fixes require sfBookDirectory. + auto missingBookDirectory = ledgerStateFix::bookExchangeRate(carol, uint256{1}); + missingBookDirectory.removeMember(sfBookDirectory.jsonName); + env(missingBookDirectory, Ter(temINVALID)); + + // BookExchangeRate fixes reject fields that belong to other + // LedgerStateFix types. + auto extraOwner = ledgerStateFix::bookExchangeRate(carol, uint256{1}); + extraOwner[sfOwner.jsonName] = carol.human(); + env(extraOwner, Ter(temINVALID)); + } + + { + Env env(*this, features); + auto const setup = PermissionedDEX(env); + auto const fixFee = drops(env.current()->fees().increment); + + { + // Preclaim check: the target directory must exist. + env(ledgerStateFix::bookExchangeRate(setup.carol, uint256{1}), + Fee(fixFee), + Ter(tecOBJECT_NOT_FOUND)); + } + + { + // Preclaim check: the target directory must be a book root + // page. Owner directories are ltDIR_NODE entries, but they do + // not carry sfExchangeRate. + auto const ownerDir = keylet::ownerDir(setup.bob.id()); + auto const ownerDirSle = env.le(ownerDir); + BEAST_EXPECT(ownerDirSle); + BEAST_EXPECT(!ownerDirSle->isFieldPresent(sfExchangeRate)); + + env(ledgerStateFix::bookExchangeRate(setup.carol, ownerDir.key), + Fee(fixFee), + Ter(tecNO_PERMISSION)); + } + + { + // Preclaim check: a correct sfExchangeRate leaves nothing to + // repair. + auto const bobOfferSeq{env.seq(setup.bob)}; + env(offer(setup.bob, XRP(100), setup.usd(40))); + env.close(); + + auto const sle = env.le(keylet::offer(setup.bob.id(), bobOfferSeq)); + BEAST_EXPECT(sle); + + auto const dirKey = sle->getFieldH256(sfBookDirectory); + { + auto const dirSle = env.le(Keylet(ltDIR_NODE, dirKey)); + BEAST_EXPECT(dirSle); + auto const exchangeRate = dirSle->getFieldU64(sfExchangeRate); + auto const quality = getQuality(dirKey); + BEAST_EXPECT(exchangeRate == quality); + } + + env(ledgerStateFix::bookExchangeRate(setup.carol, dirKey), + Fee(fixFee), + Ter(tecNO_PERMISSION)); + } + } + + { + // Repair path: start without fixCleanup3_2_0 to produce the + // mismatch, then enable the amendment and fix it. + Env env(*this, features - fixCleanup3_2_0); + auto const& [gw_, domainOwner, alice_, bob_, carol_, USD, domainID, credType] = + PermissionedDEX(env); + + // Bob places a hybrid offer. + env(offer(bob_, XRP(100), USD(40)), Txflags(tfHybrid), Domain(domainID)); + env.close(); + + // Alice partially crosses Bob. + auto const aliceOfferSeq{env.seq(alice_)}; + env(offer(alice_, USD(100), XRP(300)), Txflags(tfHybrid), Domain(domainID)); + env.close(); + + auto const sle = env.le(keylet::offer(alice_.id(), aliceOfferSeq)); + BEAST_EXPECT(sle); + + auto const openDirKey = + sle->getFieldArray(sfAdditionalBooks)[0].getFieldH256(sfBookDirectory); + + auto const preCrossingQuality = std::uint64_t{5623825668291712342ULL}; + auto const postCrossingQuality = std::uint64_t{5623825668291712341ULL}; + + // Confirm mismatch exists. + { + auto const dirSle = env.le(Keylet(ltDIR_NODE, openDirKey)); + BEAST_EXPECT(dirSle); + auto const exchangeRate = dirSle->getFieldU64(sfExchangeRate); + auto const quality = getQuality(openDirKey); + BEAST_EXPECT(exchangeRate == preCrossingQuality); + BEAST_EXPECT(quality == postCrossingQuality); + BEAST_EXPECT(exchangeRate != quality); + } + + // Enable fixCleanup3_2_0 and apply the LedgerStateFix. + env.enableFeature(fixCleanup3_2_0); + env.close(); + + auto const fixFee = drops(env.current()->fees().increment); + env(ledgerStateFix::bookExchangeRate(carol_, openDirKey), Fee(fixFee)); + env.close(); + + // Confirm sfExchangeRate now matches the key quality. + { + auto const dirSle = env.le(Keylet(ltDIR_NODE, openDirKey)); + BEAST_EXPECT(dirSle); + auto const exchangeRate = dirSle->getFieldU64(sfExchangeRate); + auto const quality = getQuality(openDirKey); + BEAST_EXPECT(exchangeRate == postCrossingQuality); + BEAST_EXPECT(quality == postCrossingQuality); + BEAST_EXPECT(exchangeRate == quality); + } + + // Submitting again should fail — nothing to fix. + env(ledgerStateFix::bookExchangeRate(carol_, openDirKey), + Fee(fixFee), + Ter(tecNO_PERMISSION)); + } + } + void testCancelRegularOfferWithDomainCreate(FeatureBitset features) { @@ -1528,6 +1787,9 @@ public: testHybridOfferDirectories(all); testHybridMalformedOffer(all); testHybridMalformedOffer(all - fixCleanup3_1_3); + testHybridOfferCrossingQuality(all); + testHybridOfferCrossingQuality(all - fixCleanup3_2_0); + testBookExchangeRateFix(all); // Cancelling a regular offer in a domain OfferCreate is allowed // only after fixCleanup3_2_0. diff --git a/src/test/jtx/impl/ledgerStateFixes.cpp b/src/test/jtx/impl/ledgerStateFixes.cpp index a4b6d9b986..30c6659124 100644 --- a/src/test/jtx/impl/ledgerStateFixes.cpp +++ b/src/test/jtx/impl/ledgerStateFixes.cpp @@ -1,6 +1,7 @@ #include #include +#include #include #include #include @@ -22,4 +23,16 @@ nftPageLinks(jtx::Account const& acct, jtx::Account const& owner) return jv; } +// Fix sfExchangeRate on a book directory. acct pays fee. +json::Value +bookExchangeRate(jtx::Account const& acct, uint256 const& bookDir) +{ + json::Value jv; + jv[sfAccount.jsonName] = acct.human(); + jv[sfLedgerFixType.jsonName] = static_cast(LedgerStateFix::FixType::BookExchangeRate); + jv[sfBookDirectory.jsonName] = to_string(bookDir); + jv[sfTransactionType.jsonName] = jss::LedgerStateFix; + return jv; +} + } // namespace xrpl::test::jtx::ledgerStateFix diff --git a/src/test/jtx/ledgerStateFix.h b/src/test/jtx/ledgerStateFix.h index 769a28fb08..71f3f76101 100644 --- a/src/test/jtx/ledgerStateFix.h +++ b/src/test/jtx/ledgerStateFix.h @@ -10,4 +10,8 @@ namespace xrpl::test::jtx::ledgerStateFix { json::Value nftPageLinks(jtx::Account const& acct, jtx::Account const& owner); +/** Repair sfExchangeRate on a book directory's first page. */ +json::Value +bookExchangeRate(jtx::Account const& acct, uint256 const& bookDir); + } // namespace xrpl::test::jtx::ledgerStateFix diff --git a/src/tests/libxrpl/protocol_autogen/transactions/LedgerStateFixTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/LedgerStateFixTests.cpp index 60034255c7..5b78f0cfdd 100644 --- a/src/tests/libxrpl/protocol_autogen/transactions/LedgerStateFixTests.cpp +++ b/src/tests/libxrpl/protocol_autogen/transactions/LedgerStateFixTests.cpp @@ -31,6 +31,7 @@ TEST(TransactionsLedgerStateFixTests, BuilderSettersRoundTrip) // Transaction-specific field values auto const ledgerFixTypeValue = canonical_UINT16(); auto const ownerValue = canonical_ACCOUNT(); + auto const bookDirectoryValue = canonical_UINT256(); LedgerStateFixBuilder builder{ accountValue, @@ -41,6 +42,7 @@ TEST(TransactionsLedgerStateFixTests, BuilderSettersRoundTrip) // Set optional fields builder.setOwner(ownerValue); + builder.setBookDirectory(bookDirectoryValue); auto tx = builder.build(publicKey, secretKey); @@ -72,6 +74,14 @@ TEST(TransactionsLedgerStateFixTests, BuilderSettersRoundTrip) EXPECT_TRUE(tx.hasOwner()); } + { + auto const& expected = bookDirectoryValue; + auto const actualOpt = tx.getBookDirectory(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfBookDirectory should be present"; + expectEqualField(expected, *actualOpt, "sfBookDirectory"); + EXPECT_TRUE(tx.hasBookDirectory()); + } + } // 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper, @@ -90,6 +100,7 @@ TEST(TransactionsLedgerStateFixTests, BuilderFromStTxRoundTrip) // Transaction-specific field values auto const ledgerFixTypeValue = canonical_UINT16(); auto const ownerValue = canonical_ACCOUNT(); + auto const bookDirectoryValue = canonical_UINT256(); // Build an initial transaction LedgerStateFixBuilder initialBuilder{ @@ -100,6 +111,7 @@ TEST(TransactionsLedgerStateFixTests, BuilderFromStTxRoundTrip) }; initialBuilder.setOwner(ownerValue); + initialBuilder.setBookDirectory(bookDirectoryValue); auto initialTx = initialBuilder.build(publicKey, secretKey); @@ -131,6 +143,13 @@ TEST(TransactionsLedgerStateFixTests, BuilderFromStTxRoundTrip) expectEqualField(expected, *actualOpt, "sfOwner"); } + { + auto const& expected = bookDirectoryValue; + auto const actualOpt = rebuiltTx.getBookDirectory(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfBookDirectory should be present"; + expectEqualField(expected, *actualOpt, "sfBookDirectory"); + } + } // 3) Verify wrapper throws when constructed from wrong transaction type. @@ -190,6 +209,8 @@ TEST(TransactionsLedgerStateFixTests, OptionalFieldsReturnNullopt) // Verify optional fields are not present EXPECT_FALSE(tx.hasOwner()); EXPECT_FALSE(tx.getOwner().has_value()); + EXPECT_FALSE(tx.hasBookDirectory()); + EXPECT_FALSE(tx.getBookDirectory().has_value()); } }