diff --git a/.cspell.config.yaml b/.cspell.config.yaml index e220cd02491..2453ac562df 100644 --- a/.cspell.config.yaml +++ b/.cspell.config.yaml @@ -36,7 +36,9 @@ overrides: - /'[^']*'/g # single-quoted strings - /`[^`]*`/g # backtick strings suggestWords: - - unsynched->unsynced + - xprl->xrpl + - xprld->xrpld # cspell: disable-line not sure what this problem is.... + - unsynched->unsynced # cspell: disable-line not sure what this problem is.... - synched->synced - synch->sync words: @@ -58,7 +60,6 @@ words: - autobridging - bimap - bindir - - blindings - bookdir - Bougalis - Britto @@ -85,7 +86,6 @@ words: - coro - coros - cowid - - cpack - cryptocondition - cryptoconditional - cryptoconditions @@ -96,6 +96,7 @@ words: - daria - dcmake - dearmor + - dedupe - decryptor - dedented - deleteme @@ -108,11 +109,9 @@ words: - distro - doxyfile - dxrpl - - elgamal - enabled - enablerepo - endmacro - - envrc - exceptioned - EXPECT_STREQ - Falco @@ -122,7 +121,6 @@ words: - fmtdur - fsanitize - funclets - - Gamal - gcov - gcovr - ghead @@ -161,11 +159,15 @@ words: - libpb - libxrpl - llection + - localised - LOCALGOOD - logwstream - lseq - lsmf - ltype + - materialisations + - materialises + - materialised - mathbunnyru - mcmodel - MEMORYSTATUSEX @@ -173,6 +175,7 @@ words: - MPTDEX - Merkle - Metafuncton + - minimisation - misprediction - missingok - mptbalance @@ -192,6 +195,7 @@ words: - multisign - multisigned - Nakamoto + - neighbours - nftid - nftoffer - nftoken @@ -220,7 +224,6 @@ words: - partitioner - paychan - paychans - - Pedersen - permdex - perminute - permissioned @@ -239,15 +242,9 @@ words: - pyenv - pyparsing - qalloc - - qbsprofile - queuable - Raphson - - rcflags - replayer - - rerandomize - - rerandomization - - rerandomized - - rerandomizes - rerere - retriable - RIPD @@ -267,10 +264,10 @@ words: - sahyadri - Satoshi - scons - - Schnorr - secp - sendq - seqit + - Serialises - sf - SFIELD - sfields @@ -300,9 +297,10 @@ words: - stvar - stvector - stxchainattestations - - summands - superpeer - superpeers + - specialisation + - summands - takergets - takerpays - ters @@ -319,7 +317,6 @@ words: - txs - ubsan - UBSAN - - ufdio - umant - unacquired - unambiguity @@ -331,6 +328,7 @@ words: - unflatten - unfund - unimpair + - unranked - unroutable - unscalable - unserviced diff --git a/cfg/xrpld-example.cfg b/cfg/xrpld-example.cfg index 9e334e6f4f3..0f14688e8be 100644 --- a/cfg/xrpld-example.cfg +++ b/cfg/xrpld-example.cfg @@ -773,33 +773,10 @@ # # # [path_search] -# When searching for paths, the default search aggressiveness. This can take -# exponentially more resources as the size is increased. +# Set to 1 to enable path finding, or 0 to disable it. +# Path finding is disabled by default; set to 1 to enable it. # -# The recommended value to support advanced pathfinding is: 7 -# -# The default is: 2 -# -# [path_search_fast] -# [path_search_max] -# When searching for paths, the minimum and maximum search aggressiveness. -# -# If you do not need pathfinding, you can set path_search_max to zero to -# disable it and avoid some expensive bookkeeping. -# -# To support advanced pathfinding the recommended value for -# 'path_search_fast' is 2, and for 'path_search_max' is 10. -# -# The default for 'path_search_fast' is 2. The default for 'path_search_max' is 3. -# -# [path_search_old] -# -# For clients that use the legacy path finding interfaces, the search -# aggressiveness to use. -# -# The recommended value to support advanced pathfinding is: 7. -# -# The default is: 2 +# The default is: 0 # # # diff --git a/include/xrpl/ledger/OrderBookDB.h b/include/xrpl/ledger/OrderBookDB.h index 96dc94b1f4b..ccd8f62daaf 100644 --- a/include/xrpl/ledger/OrderBookDB.h +++ b/include/xrpl/ledger/OrderBookDB.h @@ -83,6 +83,18 @@ class OrderBookDB */ virtual bool isBookToXRP(Asset const& asset, std::optional const& domain = std::nullopt) = 0; + + /** Get all assets that appear as takerPays in any known order book. + + Used by pathfinding to seed vertex discovery so that + non-XRP-rooted assets are always included even when they have no + direct XRP book. + + @param domain Optional domain restriction + @return Vector of all known takerPays assets + */ + virtual std::vector + getAllTakerPaysAssets(std::optional const& domain = std::nullopt) = 0; }; /** diff --git a/include/xrpl/tx/paths/detail/Steps.h b/include/xrpl/tx/paths/detail/Steps.h index 8ee37c026cb..062388479be 100644 --- a/include/xrpl/tx/paths/detail/Steps.h +++ b/include/xrpl/tx/paths/detail/Steps.h @@ -504,11 +504,19 @@ class FlowException : public std::runtime_error { public: TER ter; + // When set, the AMM pool (book pair) that failed. Path_find may record + // this hop in PathRequestManager; consensus payment flow never consults it. + std::optional ammBook; FlowException(TER t, std::string const& msg) : std::runtime_error(msg), ter(t) { } + FlowException(TER t, std::string const& msg, Book const& amm) + : std::runtime_error(msg), ter(t), ammBook(amm) + { + } + explicit FlowException(TER t) : std::runtime_error(transHuman(t)), ter(t) { } diff --git a/src/libxrpl/tx/paths/BookStep.cpp b/src/libxrpl/tx/paths/BookStep.cpp index 71902ce8b9b..9da7896e0f2 100644 --- a/src/libxrpl/tx/paths/BookStep.cpp +++ b/src/libxrpl/tx/paths/BookStep.cpp @@ -869,7 +869,10 @@ BookStep::consumeOffer( // when the amendment isn't active. if (sb.rules().enabled(fixAMMOverflowOffer)) { - Throw(tecINVARIANT_FAILED, "AMM pool product invariant failed."); + // Attach the book so path_find can record the hop if it catches + // this exception. Never write/read any blacklist here — AMM + // participation in consensus payment flow must stay deterministic. + Throw(tecINVARIANT_FAILED, "AMM pool product invariant failed.", book_); } } diff --git a/src/libxrpl/tx/transactors/payment/Payment.cpp b/src/libxrpl/tx/transactors/payment/Payment.cpp index 17c96a1919f..79d408dfb8e 100644 --- a/src/libxrpl/tx/transactors/payment/Payment.cpp +++ b/src/libxrpl/tx/transactors/payment/Payment.cpp @@ -439,10 +439,11 @@ Payment::preclaim(PreclaimContext const& ctx) { STPathSet const& paths = ctx.tx.getFieldPathSet(sfPaths); - if (paths.size() > kMaxPathSize || std::ranges::any_of(paths, [](STPath const& path) { - return path.size() > kMaxPathLength; - })) + // Validate path count and length with detailed logging + if (paths.size() > kMaxPathSize) { + JLOG(ctx.j.error()) << "Payment rejected: Too many paths (" << paths.size() << " > " + << kMaxPathSize << ")"; // Open view: the soft tel (unchanged). Inner batch txns are claimed // on a closed view, where a tel is invalid, so use the tef. if (ctx.view.open()) @@ -450,6 +451,26 @@ Payment::preclaim(PreclaimContext const& ctx) if (ctx.parentBatchId && ctx.view.rules().enabled(featureBatchV1_1)) return tefBAD_PATH_COUNT; } + + // Check each path for length violations and log details + for (std::size_t i = 0; i < paths.size(); ++i) + { + if (paths[i].size() > kMaxPathLength) + { + JLOG(ctx.j.error()) << "Payment rejected: Path " << i << " exceeds maximum length (" + << paths[i].size() << " > " << kMaxPathLength << ")"; + // Log the path elements for debugging + JLOG(ctx.j.debug()) << "Path " << i << " has " << paths[i].size() + << " elements (max allowed: " << kMaxPathLength << ")"; + + // Open view: the soft tel (unchanged). Inner batch txns are claimed + // on a closed view, where a tel is invalid, so use the tef. + if (ctx.view.open()) + return telBAD_PATH_COUNT; + if (ctx.parentBatchId && ctx.view.rules().enabled(featureBatchV1_1)) + return tefBAD_PATH_COUNT; + } + } } if (auto const err = credentials::valid(ctx.tx, ctx.view, ctx.tx[sfAccount], ctx.j); diff --git a/src/test/app/Offer_test.cpp b/src/test/app/Offer_test.cpp index 7fc7161e366..1ea763d6178 100644 --- a/src/test/app/Offer_test.cpp +++ b/src/test/app/Offer_test.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -4330,7 +4331,13 @@ class OfferBaseUtil_test : public beast::unit_test::Suite testcase("RippleConnect Smoketest payment flow"); using namespace jtx; - Env env{*this, features}; + Env env( + *this, + envconfig([](std::unique_ptr cfg) { + cfg->pathSearch = true; + return cfg; + }), + features); // This test mimics a payment flow. The players: // A USD gateway with hot and cold wallets diff --git a/src/test/app/PathMPT_test.cpp b/src/test/app/PathMPT_test.cpp index 3ba67b58a69..1a092d2e1d4 100644 --- a/src/test/app/PathMPT_test.cpp +++ b/src/test/app/PathMPT_test.cpp @@ -87,9 +87,7 @@ class PathMPT_test : public beast::unit_test::Suite // with the search parameters that the tests were written for. using namespace jtx; return Env(*this, envconfig([](std::unique_ptr cfg) { - cfg->pathSearchOld = 7; - cfg->pathSearch = 7; - cfg->pathSearchMax = 10; + cfg->pathSearch = true; return cfg; })); } diff --git a/src/test/app/Path_test.cpp b/src/test/app/Path_test.cpp index 8f19a419a06..72709b86059 100644 --- a/src/test/app/Path_test.cpp +++ b/src/test/app/Path_test.cpp @@ -22,14 +22,22 @@ #include #include #include +#include +#include +#include +#include +#include +#include #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -40,6 +48,7 @@ #include #include #include +#include #include #include #include @@ -55,6 +64,7 @@ #include #include #include +#include namespace xrpl::test { @@ -101,9 +111,7 @@ class Path_test : public beast::unit_test::Suite // with the search parameters that the tests were written for. using namespace jtx; return Env(*this, envconfig([](std::unique_ptr cfg) { - cfg->pathSearchOld = 7; - cfg->pathSearch = 7; - cfg->pathSearchMax = 10; + cfg->pathSearch = true; return cfg; })); } @@ -1377,8 +1385,8 @@ class Path_test : public beast::unit_test::Suite pathFind06(bool const domainEnabled) { testcase( - std::string("Path Find: non-XRP -> non-XRP, same currency)") + - (domainEnabled ? " w/ " : " w/o ") + "domain"); + std::string("Path Find: non-XRP -> non-XRP, same currency ") + + (domainEnabled ? "w/ " : "w/o ") + "domain"); using namespace jtx; Env env = pathTestEnv(); Account const a1{"A1"}; @@ -1867,48 +1875,2379 @@ class Path_test : public beast::unit_test::Suite } void - run() override + selfSubscriptionIouToXrp() { - sourceCurrenciesLimit(); - noDirectPathNoIntermediaryNoAlternatives(); - directPathNoIntermediary(); - paymentAutoPathFind(); - indirectPathsPathFind(); - alternativePathsConsumeBestTransferFirst(); - issuesPathNegativeRippleClientIssue23Smaller(); - issuesPathNegativeRippleClientIssue23Larger(); - qualityPathsQualitySetAndTest(); - trustAutoClearTrustNormalClear(); - trustAutoClearTrustAutoClear(); - norippleCombinations(); + // Regression: when src == dst (a self-subscription, common for wallet + // apps that subscribe to their own account), IOU->XRP paths were + // incorrectly suppressed because effectiveDst_==dstAccount_==srcAccount_ + // triggered the repayToSelf guard even when dst is XRP. + // Verify paths are found when the source currency is an IOU. + testcase("self-subscription IOU to XRP path find"); + using namespace jtx; - for (bool const domainEnabled : {false, true}) + Env env = pathTestEnv(); + auto const alice = Account("alice"); + auto const bob = Account("bob"); // market maker + auto const gw = Account("gw"); + auto const usd = gw["USD"]; + + env.fund(XRP(10000), alice, bob, gw); + env.close(); + env.trust(usd(100), alice, bob); + env.close(); + env(pay(gw, alice, usd(50))); + env(pay(gw, bob, usd(50))); + env.close(); + env(offer(bob, usd(10), XRP(100))); + env.close(); + + // src == dst (self-subscription) with explicit sendMax specifying the + // source IOU issuer and convertAll dst (-1) — matches what real wallet + // subscriptions send. Must find the USD→XRP offer-book path. + // (send_max requires destination_amount == -1 in the RPC layer.) + auto const [st, sa, da] = findPaths(env, alice, alice, drops(-1), usd(50).value()); + BEAST_EXPECT(!st.empty()); + } + + void + orderBookDBAllTakerPaysAssets() + { + testcase("OrderBookDB::getAllTakerPaysAssets"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + auto const usd = gw["USD"]; + auto const eur = gw["EUR"]; + auto const gbp = gw["GBP"]; + + env.fund(XRP(10000), alice, bob, gw); + env.close(); + env.trust(usd(1000), alice, bob); + env.trust(eur(1000), alice, bob); + env.trust(gbp(1000), alice, bob); + env.close(); + env(pay(gw, alice, usd(500))); + env(pay(gw, bob, eur(500))); + env(pay(gw, alice, gbp(500))); + env.close(); + + // Create offers to populate order books + // XRP -> USD book (takerPays = XRP) + env(offer(alice, XRP(500), usd(100))); + // XRP -> EUR book (takerPays = XRP, same as above so no new takerPays) + env(offer(bob, XRP(500), eur(100))); + // USD -> XRP book (takerPays = USD) + env(offer(alice, usd(100), XRP(500))); + // EUR -> XRP book (takerPays = EUR) + env(offer(bob, eur(100), XRP(500))); + // GBP -> XRP book (takerPays = GBP) + env(offer(alice, gbp(100), XRP(500))); + env.close(); + + // Trigger OrderBookDB setup so books are populated + auto& obdb = env.app().getOrderBookDB(); + + // getAllTakerPaysAssets should return XRP, USD, EUR, GBP + auto assets = obdb.getAllTakerPaysAssets(); + + // We expect at least 4 distinct takerPays assets: XRP, USD, EUR, GBP + BEAST_EXPECT(assets.size() >= 4); + + // Verify each expected asset is present + bool foundXRP = false, foundUSD = false, foundEUR = false, foundGBP = false; + for (auto const& asset : assets) { - pathFind(domainEnabled); - pathFindConsumeAll(domainEnabled); - alternativePathConsumeBoth(domainEnabled); - alternativePathsConsumeBestTransfer(domainEnabled); - alternativePathsLimitReturnedPathsToBestQuality(domainEnabled); - issuesPathNegativeIssue(domainEnabled); - viaOffersViaGateway(domainEnabled); - xrpToXrp(domainEnabled); - receiveMax(domainEnabled); + if (isXRP(asset)) + foundXRP = true; + else if (asset == usd.asset()) + foundUSD = true; + else if (asset == eur.asset()) + foundEUR = true; + else if (asset == gbp.asset()) + foundGBP = true; + } + BEAST_EXPECT(foundXRP); + BEAST_EXPECT(foundUSD); + BEAST_EXPECT(foundEUR); + BEAST_EXPECT(foundGBP); + } + + void + orderBookDBIsBookToXRP() + { + testcase("OrderBookDB::isBookToXRP"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + auto const gw = Account("gw"); + auto const usd = gw["USD"]; + auto const eur = gw["EUR"]; - // The following path_find_NN tests are data driven tests - // that were originally implemented in js/coffee and migrated - // here. The quantities and currencies used are taken directly from - // those legacy tests, which in some cases probably represented - // customer use cases. + env.fund(XRP(10000), alice, gw); + env.close(); + env.trust(usd(1000), alice); + env.trust(eur(1000), alice); + env.close(); + env(pay(gw, alice, usd(500))); + env(pay(gw, alice, eur(500))); + env.close(); - pathFind01(domainEnabled); - pathFind02(domainEnabled); - pathFind04(domainEnabled); - pathFind05(domainEnabled); - pathFind06(domainEnabled); + // Create USD -> XRP book (takerPays = USD, takerGets = XRP) + env(offer(alice, usd(100), XRP(500))); + // Create EUR -> XRP book (takerPays = EUR, takerGets = XRP) + env(offer(alice, eur(100), XRP(500))); + env.close(); + + auto& obdb = env.app().getOrderBookDB(); + + // USD has a book to XRP + BEAST_EXPECT(obdb.isBookToXRP(usd.asset())); + // EUR has a book to XRP + BEAST_EXPECT(obdb.isBookToXRP(eur.asset())); + // GBP has no book to XRP (never created) + auto const gbp = gw["GBP"]; + BEAST_EXPECT(!obdb.isBookToXRP(gbp.asset())); + } + + void + orderBookDBGetBookSize() + { + testcase("OrderBookDB::getBookSize"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + auto const usd = gw["USD"]; + auto const eur = gw["EUR"]; + + env.fund(XRP(10000), alice, bob, gw); + env.close(); + env.trust(usd(1000), alice, bob); + env.trust(eur(1000), alice, bob); + env.close(); + env(pay(gw, alice, usd(500))); + env(pay(gw, bob, eur(500))); + env.close(); + + // Create multiple books with XRP as takerPays: + // XRP -> USD and XRP -> EUR (2 different takerGets for same takerPays) + env(offer(alice, XRP(500), usd(100))); + env(offer(bob, XRP(500), eur(100))); + env.close(); + + auto& obdb = env.app().getOrderBookDB(); + + // XRP as takerPays should have 2 books (USD and EUR as takerGets) + BEAST_EXPECT(obdb.getBookSize(XRP) == 2); + + // USD as takerPays has no books yet + BEAST_EXPECT(obdb.getBookSize(usd.asset()) == 0); + + // Add a USD -> XRP book + env(offer(alice, usd(100), XRP(500))); + env.close(); + + // Now USD as takerPays should have 1 book + BEAST_EXPECT(obdb.getBookSize(usd.asset()) == 1); + } + + void + orderBookDBGetBooksByTakerPays() + { + testcase("OrderBookDB::getBooksByTakerPays"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + auto const usd = gw["USD"]; + auto const eur = gw["EUR"]; + + env.fund(XRP(10000), alice, bob, gw); + env.close(); + env.trust(usd(1000), alice, bob); + env.trust(eur(1000), alice, bob); + env.close(); + env(pay(gw, alice, usd(500))); + env(pay(gw, bob, eur(500))); + env.close(); + + // Create books with XRP as takerPays + env(offer(alice, XRP(500), usd(100))); + env(offer(bob, XRP(600), eur(100))); + env.close(); + + auto& obdb = env.app().getOrderBookDB(); + + // Get books by XRP takerPays + auto books = obdb.getBooksByTakerPays(XRP); + BEAST_EXPECT(books.size() == 2); + + // Verify the books contain expected takerGets + bool hasUSD = false, hasEUR = false; + for (auto const& book : books) + { + if (book.out == usd.asset()) + hasUSD = true; + if (book.out == eur.asset()) + hasEUR = true; } + BEAST_EXPECT(hasUSD); + BEAST_EXPECT(hasEUR); - hybridOfferPath(); - ammDomainPath(); + // Query for asset with no books + auto const gbp = gw["GBP"]; + books = obdb.getBooksByTakerPays(gbp.asset()); + BEAST_EXPECT(books.empty()); + } + + void + orderBookDBEmptyState() + { + testcase("OrderBookDB empty state"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + env.fund(XRP(10000), alice); + env.close(); + + auto& obdb = env.app().getOrderBookDB(); + + // With no offers, all queries should return empty/false/zero + BEAST_EXPECT(obdb.getAllTakerPaysAssets().empty()); + BEAST_EXPECT(obdb.getBookSize(XRP) == 0); + BEAST_EXPECT(obdb.getBooksByTakerPays(XRP).empty()); + BEAST_EXPECT(!obdb.isBookToXRP(XRP)); + } + + //------------------------------------------------------------------------------ + // PayGraph unit tests for coverage + + void + payGraphBuildAndSnapshot() + { + testcase("PayGraph::build + snapshot"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + auto const usd = gw["USD"]; + auto const eur = gw["EUR"]; + + env.fund(XRP(10000), alice, bob, gw); + env.close(); + env.trust(usd(1000), alice, bob); + env.trust(eur(1000), alice, bob); + env.close(); + env(pay(gw, alice, usd(500))); + env(pay(gw, bob, eur(500))); + env.close(); + + // Create order books: XRP->USD, XRP->EUR, USD->XRP, EUR->XRP + env(offer(alice, XRP(500), usd(100))); + env(offer(bob, XRP(500), eur(100))); + env(offer(alice, usd(100), XRP(500))); + env(offer(bob, eur(100), XRP(500))); + env.close(); + + auto& obdb = env.app().getOrderBookDB(); + auto const ledger = env.closed(); + beast::Journal const journal{env.app().getJournal("PayGraph")}; + + // Build PayGraph from OrderBookDB + ledger + auto pg = PayGraph::build(obdb, *ledger, std::nullopt, journal); + + // Snapshot should be non-null + auto snap = pg->snapshot(); + BEAST_EXPECT(snap != nullptr); + + // Stats should reflect the books we created + auto stats = pg->currentStats(); + BEAST_EXPECT(stats.vertices >= 3); // At least XRP, USD, EUR + BEAST_EXPECT(stats.edges >= 4); // XRP->USD, XRP->EUR, USD->XRP, EUR->XRP + BEAST_EXPECT(stats.orderBooks >= 4); + } + + void + payGraphVertexAndAssetHelpers() + { + testcase("PayGraph::vertexOf + assetOf"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + auto const gw = Account("gw"); + auto const usd = gw["USD"]; + + env.fund(XRP(10000), alice, gw); + env.close(); + env.trust(usd(1000), alice); + env.close(); + env(pay(gw, alice, usd(500))); + env.close(); + + // XRP -> USD book + env(offer(alice, XRP(500), usd(100))); + env.close(); + + auto& obdb = env.app().getOrderBookDB(); + auto const ledger = env.closed(); + beast::Journal const journal{env.app().getJournal("PayGraph")}; + auto pg = PayGraph::build(obdb, *ledger, std::nullopt, journal); + + // vertexOf should return valid VIDs for assets in the graph + auto xrpVid = pg->vertexOf(XRP); + auto usdVid = pg->vertexOf(usd.asset()); + BEAST_EXPECT(xrpVid != PayGraph::kNull); + BEAST_EXPECT(usdVid != PayGraph::kNull); + BEAST_EXPECT(xrpVid != usdVid); + + // assetOf should return the correct asset for a VID + auto xrpAsset = pg->assetOf(xrpVid); + auto usdAsset = pg->assetOf(usdVid); + BEAST_EXPECT(isXRP(xrpAsset)); + BEAST_EXPECT(usdAsset == usd.asset()); + + // vertexOf for unknown asset returns kNull + auto const gbp = gw["GBP"]; + auto gbpVid = pg->vertexOf(gbp.asset()); + BEAST_EXPECT(gbpVid == PayGraph::kNull); + } + + void + payGraphDijkstraShortestPath() + { + testcase("PayGraph::dijkstra + reconstructPath (via kShortestPaths)"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + auto const usd = gw["USD"]; + auto const eur = gw["EUR"]; + + env.fund(XRP(10000), alice, bob, gw); + env.close(); + env.trust(usd(1000), alice, bob); + env.trust(eur(1000), alice, bob); + env.close(); + env(pay(gw, alice, usd(500))); + env(pay(gw, bob, eur(500))); + env.close(); + + // Create chain: XRP -> USD -> EUR (so EUR is reachable from XRP via USD) + env(offer(alice, XRP(500), usd(100))); + env(offer(bob, usd(100), eur(100))); + env.close(); + + auto& obdb = env.app().getOrderBookDB(); + auto const ledger = env.closed(); + beast::Journal const journal{env.app().getJournal("PayGraph")}; + auto pg = PayGraph::build(obdb, *ledger, std::nullopt, journal); + + auto snap = pg->snapshot(); + BEAST_EXPECT(snap != nullptr); + + // Get VIDs + auto xrpVid = pg->vertexOf(XRP); + auto eurVid = pg->vertexOf(eur.asset()); + BEAST_EXPECT(xrpVid != PayGraph::kNull); + BEAST_EXPECT(eurVid != PayGraph::kNull); + + // kShortestPaths exercises dijkstra + reconstructPath internally + auto paths = PayGraph::kShortestPaths(*snap, xrpVid, eurVid, 1); + BEAST_EXPECT(!paths.empty()); + + // Path should go from XRP to EUR (via USD bridge) + auto const& path = paths.front(); + BEAST_EXPECT(path.vids.front() == xrpVid); + BEAST_EXPECT(path.vids.back() == eurVid); + BEAST_EXPECT(path.vids.size() >= 2); // At least src and dst + BEAST_EXPECT(path.cumQuality < std::numeric_limits::max()); + } + + void + payGraphKShortestPaths() + { + testcase("PayGraph::kShortestPaths + findPaths"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + auto const usd = gw["USD"]; + auto const eur = gw["EUR"]; + + env.fund(XRP(10000), alice, bob, gw); + env.close(); + env.trust(usd(1000), alice, bob); + env.trust(eur(1000), alice, bob); + env.close(); + env(pay(gw, alice, usd(500))); + env(pay(gw, bob, eur(500))); + env(pay(gw, alice, eur(500))); // Alice needs EUR for USD->EUR offer + env(pay(gw, bob, usd(500))); // Bob needs USD for XRP->USD offer + env.close(); + + // Create multiple paths from XRP to EUR: + // Path 1: XRP -> EUR (direct) + // Path 2: XRP -> USD -> EUR (via USD bridge) + env(offer(alice, XRP(500), eur(100))); + env.close(); + env(offer(bob, XRP(500), usd(100))); + env.close(); + env(offer(alice, usd(100), eur(100))); + env.close(); + + auto& obdb = env.app().getOrderBookDB(); + auto const ledger = env.closed(); + beast::Journal const journal{env.app().getJournal("PayGraph")}; + auto pg = PayGraph::build(obdb, *ledger, std::nullopt, journal); + + // findPaths convenience wrapper + auto paths = pg->findPaths(XRP, eur.asset(), 6); + BEAST_EXPECT(!paths.empty()); + BEAST_EXPECT(paths.size() <= 6); + + // Paths should be ordered by quality (best first) + for (std::size_t i = 1; i < paths.size(); ++i) + { + BEAST_EXPECT(paths[i].cumQuality >= paths[i - 1].cumQuality); + } + + // Each path should start with XRP and end with EUR + for (auto const& p : paths) + { + auto srcAsset = pg->assetOf(p.vids.front()); + auto dstAsset = pg->assetOf(p.vids.back()); + BEAST_EXPECT(isXRP(srcAsset)); + BEAST_EXPECT(dstAsset == eur.asset()); + } + + // kShortestPaths with no path returns empty + auto const gbp = gw["GBP"]; + paths = pg->findPaths(XRP, gbp.asset(), 6); + BEAST_EXPECT(paths.empty()); + } + + void + payGraphApplyLedgerDelta() + { + testcase("PayGraph::applyLedgerDelta"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + auto const gw = Account("gw"); + auto const usd = gw["USD"]; + + env.fund(XRP(10000), alice, gw); + env.close(); + env.trust(usd(1000), alice); + env.close(); + env(pay(gw, alice, usd(500))); + env.close(); + + // Create initial book: XRP -> USD + env(offer(alice, XRP(500), usd(100))); + env.close(); + + auto& obdb = env.app().getOrderBookDB(); + auto const ledger = env.closed(); + beast::Journal const journal{env.app().getJournal("PayGraph")}; + auto pg = PayGraph::build(obdb, *ledger, std::nullopt, journal); + + // Initial stats + auto statsBefore = pg->currentStats(); + BEAST_EXPECT(statsBefore.totalDeltasCalled == 0); + + // Create a new offer to change the book quality + auto const bob = Account("bob"); + env.fund(XRP(10000), bob); + env.trust(usd(1000), bob); + env(pay(gw, bob, usd(500))); // Fund bob with USD for offer + env.close(); + env(offer(bob, XRP(400), usd(100))); // Better quality offer + env.close(); + + auto const newLedger = env.closed(); + + // Apply delta with the changed book + std::vector changedBooks{{XRP, usd.asset(), std::nullopt}}; + pg->applyLedgerDelta(obdb, *newLedger, changedBooks); + + // Stats should reflect the delta + auto statsAfter = pg->currentStats(); + BEAST_EXPECT(statsAfter.totalDeltasCalled == 1); + BEAST_EXPECT(statsAfter.lastDeltaBooks == 1); + + // Snapshot should still be valid + auto snap = pg->snapshot(); + BEAST_EXPECT(snap != nullptr); + } + + void + payGraphRebuild() + { + testcase("PayGraph::rebuild"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + auto const gw = Account("gw"); + auto const usd = gw["USD"]; + + env.fund(XRP(10000), alice, gw); + env.close(); + env.trust(usd(1000), alice); + env.close(); + env(pay(gw, alice, usd(500))); + env.close(); + + env(offer(alice, XRP(500), usd(100))); + env.close(); + + auto& obdb = env.app().getOrderBookDB(); + auto const ledger = env.closed(); + beast::Journal const journal{env.app().getJournal("PayGraph")}; + auto pg = PayGraph::build(obdb, *ledger, std::nullopt, journal); + + // Rebuild with same ledger (should preserve structure) + pg->rebuild(obdb, *ledger, std::nullopt); + + auto snap = pg->snapshot(); + BEAST_EXPECT(snap != nullptr); + + auto stats = pg->currentStats(); + BEAST_EXPECT(stats.vertices >= 2); // XRP + USD + BEAST_EXPECT(stats.orderBooks >= 1); + } + + void + payGraphEmptyGraph() + { + testcase("PayGraph empty graph (no offers)"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + env.fund(XRP(10000), alice); + env.close(); + + auto& obdb = env.app().getOrderBookDB(); + auto const ledger = env.closed(); + beast::Journal const journal{env.app().getJournal("PayGraph")}; + + // Build with no offers - should still have XRP vertex + auto pg = PayGraph::build(obdb, *ledger, std::nullopt, journal); + + auto snap = pg->snapshot(); + BEAST_EXPECT(snap != nullptr); + + auto stats = pg->currentStats(); + BEAST_EXPECT(stats.vertices >= 1); // At least XRP + BEAST_EXPECT(stats.edges == 0); + + // findPaths should return empty when no edges exist + auto const gw = Account("gw"); + auto const usd = gw["USD"]; + auto paths = pg->findPaths(XRP, usd.asset(), 6); + BEAST_EXPECT(paths.empty()); + } + + void + payGraphDijkstraBlockedVerts() + { + testcase("PayGraph::dijkstra with blocked vertices (via kShortestPaths)"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + auto const usd = gw["USD"]; + auto const eur = gw["EUR"]; + + env.fund(XRP(10000), alice, bob, gw); + env.close(); + env.trust(usd(1000), alice, bob); + env.trust(eur(1000), alice, bob); + env.close(); + env(pay(gw, alice, usd(500))); + env(pay(gw, bob, eur(500))); + env.close(); + + // Create: XRP -> USD -> EUR chain (only path to EUR) + env(offer(alice, XRP(500), usd(100))); + env(offer(bob, usd(100), eur(100))); + env.close(); + + auto& obdb = env.app().getOrderBookDB(); + auto const ledger = env.closed(); + beast::Journal const journal{env.app().getJournal("PayGraph")}; + auto pg = PayGraph::build(obdb, *ledger, std::nullopt, journal); + + auto snap = pg->snapshot(); + auto xrpVid = pg->vertexOf(XRP); + auto eurVid = pg->vertexOf(eur.asset()); + + // kShortestPaths with k=1 should find the single path (exercises dijkstra internally) + auto paths = PayGraph::kShortestPaths(*snap, xrpVid, eurVid, 1); + BEAST_EXPECT(!paths.empty()); + BEAST_EXPECT(paths.front().vids.front() == xrpVid); + BEAST_EXPECT(paths.front().vids.back() == eurVid); + + // kShortestPaths with k > 1 should return fewer paths since there's only one route + auto allPaths = PayGraph::kShortestPaths(*snap, xrpVid, eurVid, 6); + BEAST_EXPECT(allPaths.size() <= 1); // Only one simple path exists + } + + void + payGraphStatsCounters() + { + testcase("PayGraph stats counters"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + auto const gw = Account("gw"); + auto const usd = gw["USD"]; + + env.fund(XRP(10000), alice, gw); + env.close(); + env.trust(usd(1000), alice); + env.close(); + env(pay(gw, alice, usd(500))); + env.close(); + + env(offer(alice, XRP(500), usd(100))); + env.close(); + + auto& obdb = env.app().getOrderBookDB(); + auto const ledger = env.closed(); + beast::Journal const journal{env.app().getJournal("PayGraph")}; + auto pg = PayGraph::build(obdb, *ledger, std::nullopt, journal); + + // Initial state: no deltas applied yet + auto stats = pg->currentStats(); + BEAST_EXPECT(stats.totalDeltasCalled == 0); + BEAST_EXPECT(stats.lastDeltaBooks == 0); + BEAST_EXPECT(stats.ammPools == 0); + + // After delta, counters increment + std::vector changedBooks{{XRP, usd.asset(), std::nullopt}}; + pg->applyLedgerDelta(obdb, *ledger, changedBooks); + + stats = pg->currentStats(); + BEAST_EXPECT(stats.totalDeltasCalled == 1); + BEAST_EXPECT(stats.lastDeltaBooks == 1); + } + + void + payGraphEdgeWeightsLogSpace() + { + testcase("PayGraph edge weights use log-space for multiplicative composition"); + using namespace jtx; + + // Exchange rates compose multiplicatively. Dijkstra/BF *sums* edge + // weights, so weights must be log2(cost_ratio): + // log(r1) + log(r2) = log(r1 * r2) + // + // Direct: XRP → EUR at cost ratio 2.5 (1 XRP → 0.40 EUR) + // Multi: XRP → USD (2.0) then USD → EUR (1.11) + // product 2.22 → 0.45 EUR per XRP (strictly better) + // + // Additive rates would rank direct first (2.5 < 2.0+1.11). + // Log-space ranks multi first (log2(2.22) < log2(2.5)). + + Env env = pathTestEnv(); + auto const bob = Account("bob"); + auto const charlie = Account("charlie"); + auto const gw = Account("gw"); + auto const usd = gw["USD"]; + auto const eur = gw["EUR"]; + + env.fund(XRP(100000), bob, charlie, gw); + env.close(); + env.trust(usd(1000), bob, charlie); + env.trust(eur(1000), bob, charlie); + env.close(); + env(pay(gw, bob, usd(500))); + env(pay(gw, bob, eur(500))); + env(pay(gw, charlie, usd(500))); + env(pay(gw, charlie, eur(500))); + env.close(); + + // jtx offer(account, takerPays, takerGets): taker pays first, gets second. + // Cost ratio = takerPays / takerGets (XRP in drops). + // + // XRP → USD: 100 XRP for 50 USD → cost = 2e6 drops / 50 (ratio 2.0 in XRP units) + // USD → EUR: 100 USD for 90 EUR → cost ≈ 1.111 + // XRP → EUR: 100 XRP for 40 EUR → cost = 2e6 drops / 40 (ratio 2.5 in XRP units) + // Multi product 2.0 * 1.111 = 2.222 < 2.5 direct → multi is cheaper. + env(offer(bob, XRP(100), usd(50))); + env(offer(charlie, usd(100), eur(90))); + env.close(); + env(offer(bob, XRP(100), eur(40))); + env.close(); + + auto& obdb = env.app().getOrderBookDB(); + auto const ledger = env.closed(); + beast::Journal const journal{env.app().getJournal("PayGraph")}; + auto pg = PayGraph::build(obdb, *ledger, std::nullopt, journal); + + auto const paths = pg->findPaths(XRP, eur.asset(), 6); + BEAST_EXPECT(!paths.empty()); + + auto const xrpVid = pg->vertexOf(XRP); + auto const usdVid = pg->vertexOf(usd.asset()); + auto const eurVid = pg->vertexOf(eur.asset()); + + int multiRank = -1; + int directRank = -1; + for (std::size_t i = 0; i < paths.size(); ++i) + { + auto const& vids = paths[i].vids; + if (vids.size() == 2 && vids[0] == xrpVid && vids[1] == eurVid) + directRank = static_cast(i); + else if ( + vids.size() == 3 && vids[0] == xrpVid && vids[1] == usdVid && vids[2] == eurVid) + multiRank = static_cast(i); + } + + BEAST_EXPECT(multiRank >= 0); + BEAST_EXPECT(directRank >= 0); + // Multi-hop must outrank direct: log2(2.22) < log2(2.5). + BEAST_EXPECT(multiRank >= 0 && directRank >= 0 && multiRank < directRank); + if (multiRank >= 0 && directRank >= 0) + { + BEAST_EXPECT( + paths[static_cast(multiRank)].cumQuality < + paths[static_cast(directRank)].cumQuality); + } + } + + // Helper to create a GraphPathfinder from test env + std::unique_ptr + makeGraphPathfinder( + jtx::Env& env, + std::shared_ptr const& graph, + jtx::Account const& src, + jtx::Account const& dst, + STAmount const& dstAmount, + Asset const& srcAsset = jtx::XRP, + std::optional const& srcIssuer = std::nullopt) + { + PathAsset srcPathAsset{srcAsset}; + auto cache = std::make_shared(env.closed(), env.journal); + return std::make_unique( + graph, + cache, + src, + dst, + srcPathAsset, + srcIssuer, + dstAmount, + std::nullopt, // srcAmount + std::nullopt, // domain + env.app()); + } + + void + graphPathfinderBasicFindPaths() + { + testcase("GraphPathfinder::findPaths basic XRP->IOU"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + auto const usd = gw["USD"]; + + env.fund(XRP(10000), alice, bob, gw); + env.close(); + env.trust(usd(1000), bob); + env.trust(usd(1000), alice); + env(pay(gw, alice, usd(500))); // Alice needs USD for XRP->USD offer + env.close(); + + // Create XRP -> USD offer (alice sells XRP for USD) + env(offer(alice, XRP(500), usd(100))); + env.close(); + + auto& obdb = env.app().getOrderBookDB(); + auto const ledger = env.closed(); + beast::Journal const journal{env.app().getJournal("PayGraph")}; + auto pg = PayGraph::build(obdb, *ledger, std::nullopt, journal); + + auto gp = makeGraphPathfinder(env, pg, alice, bob, usd(50)); + + // findPaths should discover the XRP->USD path + bool found = gp->findPaths(); + BEAST_EXPECT(found); + } + + void + graphPathfinderNoGraph() + { + testcase("GraphPathfinder::findPaths with null graph"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + auto const usd = gw["USD"]; + + env.fund(XRP(10000), alice, bob, gw); + env.close(); + + // Null graph should return false from findPaths + auto gp = makeGraphPathfinder(env, nullptr, alice, bob, usd(50)); + bool found = gp->findPaths(); + BEAST_EXPECT(!found); + } + + void + graphPathfinderZeroDestinationAmount() + { + testcase("GraphPathfinder::findPaths with zero destination amount"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + auto const usd = gw["USD"]; + + env.fund(XRP(10000), alice, bob, gw); + env.close(); + env.trust(usd(1000), bob); + env.close(); + + auto& obdb = env.app().getOrderBookDB(); + auto const ledger = env.closed(); + beast::Journal const journal{env.app().getJournal("PayGraph")}; + auto pg = PayGraph::build(obdb, *ledger, std::nullopt, journal); + + // Zero destination amount should return false + auto gp = makeGraphPathfinder(env, pg, alice, bob, usd(0)); + bool found = gp->findPaths(); + BEAST_EXPECT(!found); + } + + void + graphPathfinderComputeAndGetBestPaths() + { + testcase("GraphPathfinder::computePathRanks + getBestPaths"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + auto const usd = gw["USD"]; + + env.fund(XRP(10000), alice, bob, gw); + env.close(); + env.trust(usd(1000), bob); + env.trust(usd(1000), alice); + env(pay(gw, bob, usd(500))); + env(pay(gw, alice, usd(500))); // Alice needs USD for XRP->USD offer + env.close(); + + // Create XRP -> USD offer + env(offer(alice, XRP(500), usd(100))); + env.close(); + + auto& obdb = env.app().getOrderBookDB(); + auto const ledger = env.closed(); + beast::Journal const journal{env.app().getJournal("PayGraph")}; + auto pg = PayGraph::build(obdb, *ledger, std::nullopt, journal); + + auto gp = makeGraphPathfinder(env, pg, alice, bob, usd(50)); + + // findPaths then rank and select best + bool found = gp->findPaths(); + BEAST_EXPECT(found); + + gp->computePathRanks(6); + auto bestPaths = gp->getBestPaths(6, STPathSet(), xrpAccount()); + + // Should have found at least one path + BEAST_EXPECT(bestPaths.size() >= 1); + } + + void + graphPathfinderGetBestPathsEmpty() + { + testcase("GraphPathfinder::getBestPaths with no paths"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + auto const bob = Account("bob"); + + env.fund(XRP(10000), alice, bob); + env.close(); + + // No graph, no paths — getBestPaths should return empty + auto gp = makeGraphPathfinder(env, nullptr, alice, bob, XRP(50)); + bool found = gp->findPaths(); + BEAST_EXPECT(!found); + + auto bestPaths = gp->getBestPaths(6, STPathSet(), xrpAccount()); + BEAST_EXPECT(bestPaths.empty()); + } + + void + graphPathfinderContinueCallbackAbort() + { + testcase("GraphPathfinder::findPaths with abort callback"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + auto const usd = gw["USD"]; + + env.fund(XRP(10000), alice, bob, gw); + env.close(); + env.trust(usd(1000), bob); + env.trust(usd(1000), alice); + env(pay(gw, alice, usd(500))); // Alice needs USD for XRP->USD offer + env.close(); + + env(offer(alice, XRP(500), usd(100))); + env.close(); + + auto& obdb = env.app().getOrderBookDB(); + auto const ledger = env.closed(); + beast::Journal const journal{env.app().getJournal("PayGraph")}; + auto pg = PayGraph::build(obdb, *ledger, std::nullopt, journal); + + auto gp = makeGraphPathfinder(env, pg, alice, bob, usd(50)); + + // Callback that always returns false (abort immediately) + bool aborted = gp->findPaths([]() { return false; }); + // Even with abort, the return may be true/false depending on paths found so far + // The key is it doesn't crash + BEAST_EXPECT(aborted == true || aborted == false); + } + + void + graphPathfinderIOUToXRP() + { + testcase("GraphPathfinder::findPaths IOU->XRP path"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + auto const usd = gw["USD"]; + + env.fund(XRP(10000), alice, bob, gw); + env.close(); + env.trust(usd(1000), alice); + env(pay(gw, alice, usd(500))); + env.close(); + + // Create USD -> XRP offer (alice sells USD for XRP) + env(offer(alice, usd(100), XRP(500))); + env.close(); + + auto& obdb = env.app().getOrderBookDB(); + auto const ledger = env.closed(); + beast::Journal const journal{env.app().getJournal("PayGraph")}; + auto pg = PayGraph::build(obdb, *ledger, std::nullopt, journal); + + // Alice sends USD to bob who wants XRP + auto gp = makeGraphPathfinder(env, pg, alice, bob, XRP(100), usd.asset()); + + bool found = gp->findPaths(); + BEAST_EXPECT(found); + } + + void + graphPathfinderMultiHopPath() + { + testcase("GraphPathfinder::findPaths multi-hop (XRP->USD->EUR)"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + auto const usd = gw["USD"]; + auto const eur = gw["EUR"]; + + env.fund(XRP(10000), alice, bob, gw); + env.close(); + env.trust(usd(1000), alice, bob); + env.trust(eur(1000), alice, bob); + env(pay(gw, alice, usd(500))); + env(pay(gw, alice, eur(500))); // Alice needs EUR for USD->EUR offer + env(pay(gw, bob, eur(500))); + env.close(); + + // XRP -> USD offer + env(offer(alice, XRP(500), usd(100))); + env.close(); + // USD -> EUR offer + env(offer(alice, usd(100), eur(100))); + env.close(); + + auto& obdb = env.app().getOrderBookDB(); + auto const ledger = env.closed(); + beast::Journal const journal{env.app().getJournal("PayGraph")}; + auto pg = PayGraph::build(obdb, *ledger, std::nullopt, journal); + + // Alice sends XRP to bob who wants EUR (multi-hop via USD) + auto gp = makeGraphPathfinder(env, pg, alice, bob, eur(50)); + + bool found = gp->findPaths(); + BEAST_EXPECT(found); + + gp->computePathRanks(6); + auto bestPaths = gp->getBestPaths(6, STPathSet(), xrpAccount()); + BEAST_EXPECT(bestPaths.size() >= 1); + } + + void + pathLengthValidation() + { + testcase("Path length validation - telBAD_PATH_COUNT"); + using namespace jtx; + + // Simple test to verify path length validation works + // Maximum path length is 8 hops (defined in Payment transactor) + + Env env = pathTestEnv(); + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gateway"); + + env.fund(XRP(10000), alice, bob, gw); + env.close(); + + // Create simple trust lines for USD issued by gateway + auto const usd = gw["USD"]; + env.trust(usd(1000), alice); // Alice trusts gateway's USD + env.trust(usd(1000), bob); // Bob trusts gateway's USD + env.close(); + + // Fund accounts with USD + env(pay(gw, alice, usd(500))); + env(pay(gw, bob, usd(500))); + env.close(); + + // Create an offer from alice to enable pathfinding (USD -> XRP) + env(offer(alice, usd(100), XRP(100))); + env.close(); + + // Test pathfinding - should work for valid paths + auto [st, sa, da] = findPaths(env, alice, bob, usd(10)); + + // If paths are found, verify they don't exceed maximum length (8) + for (auto const& path : st) + { + // kMaxPathLength = 8 (defined in Payment transactor) + BEAST_EXPECT(path.size() <= 8); + } + + // Test passes if we reach here without assertion failures + } + + void + run() override + { + sourceCurrenciesLimit(); + noDirectPathNoIntermediaryNoAlternatives(); + directPathNoIntermediary(); + paymentAutoPathFind(); + indirectPathsPathFind(); + alternativePathsConsumeBestTransferFirst(); + issuesPathNegativeRippleClientIssue23Smaller(); + issuesPathNegativeRippleClientIssue23Larger(); + qualityPathsQualitySetAndTest(); + trustAutoClearTrustNormalClear(); + trustAutoClearTrustAutoClear(); + norippleCombinations(); + pathLengthValidation(); + + for (bool const domainEnabled : {false, true}) + { + pathFind(domainEnabled); + pathFindConsumeAll(domainEnabled); + alternativePathConsumeBoth(domainEnabled); + alternativePathsConsumeBestTransfer(domainEnabled); + alternativePathsLimitReturnedPathsToBestQuality(domainEnabled); + issuesPathNegativeIssue(domainEnabled); + viaOffersViaGateway(domainEnabled); + xrpToXrp(domainEnabled); + receiveMax(domainEnabled); + + pathFind01(domainEnabled); + pathFind02(domainEnabled); + pathFind04(domainEnabled); + pathFind05(domainEnabled); + pathFind06(domainEnabled); + } + + hybridOfferPath(); + ammDomainPath(); + selfSubscriptionIouToXrp(); + + // OrderBookDBImpl unit tests for coverage + orderBookDBAllTakerPaysAssets(); + orderBookDBIsBookToXRP(); + orderBookDBGetBookSize(); + orderBookDBGetBooksByTakerPays(); + orderBookDBEmptyState(); + + // PayGraph unit tests for coverage + payGraphBuildAndSnapshot(); + payGraphVertexAndAssetHelpers(); + payGraphDijkstraShortestPath(); + payGraphKShortestPaths(); + payGraphApplyLedgerDelta(); + payGraphRebuild(); + payGraphEmptyGraph(); + payGraphDijkstraBlockedVerts(); + payGraphStatsCounters(); + payGraphEdgeWeightsLogSpace(); + + // GraphPathfinder unit tests for coverage + graphPathfinderBasicFindPaths(); + graphPathfinderNoGraph(); + graphPathfinderZeroDestinationAmount(); + graphPathfinderComputeAndGetBestPaths(); + graphPathfinderGetBestPathsEmpty(); + graphPathfinderContinueCallbackAbort(); + graphPathfinderIOUToXRP(); + graphPathfinderMultiHopPath(); + + // PathRequest unit tests for coverage (via PathRequestManager API) + pathRequestParseJsonMissingFields(); + pathRequestParseJsonMalformedAccount(); + pathRequestParseJsonMalformedAmount(); + pathRequestParseJsonSendMaxWithoutConvertAll(); + pathRequestParseJsonSourceCurrencies(); + pathRequestIsValidSourceNotFound(); + pathRequestIsValidDestNotFoundNonXrp(); + pathRequestIsValidDestNotFoundBelowReserve(); + pathRequestDoCreateAndDoUpdate(); + pathRequestNewAndNeedsUpdate(); + pathRequestLegacyPathRequest(); + pathRequestFindPathsNoGraph(); + + // PathRequestManager unit tests for coverage + pathRequestManagerGetAssetCache(); + pathRequestManagerGetAssetCacheJumpBack(); + pathRequestManagerRequestsPending(); + pathRequestManagerEnsurePayGraph(); + pathRequestManagerFindPathsNullLedger(); + pathRequestManagerFindPathsBasic(); + pathRequestManagerFindPathsNoGraph(); + pathRequestManagerFindPathsIOUToXRP(); + pathRequestManagerMakeLegacyPathRequestInvalidReset(); + pathRequestManagerMakeLegacyPathRequestValid(); + pathRequestManagerGetPayGraph(); + pathRequestManagerInsertPathRequestOrdering(); + pathRequestManagerDoLegacyPathRequestNoAlternatives(); + pathRequestManagerReportFastAndFull(); + pathRequestManagerSignalOrderBookReady(); + pathRequestManagerFindPathsDomain(); + pathRequestManagerGetAssetCacheJumpForward(); + pathRequestManagerUpdateAllPathSearchDisabled(); + pathRequestManagerMakeLegacyPathRequestTooBusy(); + + // OrderBookDB domain-specific coverage + orderBookDBAllTakerPaysAssetsDomain(); + + // PayGraphDelta unit tests for coverage + payGraphDeltaExtractChangedBooks(); + payGraphDeltaMergeBooks(); + liquidityDepthProvesTopOfBookProblem(); + } + + void + pathRequestParseJsonMissingFields() + { + testcase("PathRequest::parseJson missing required fields"); + using namespace jtx; + Env env = pathTestEnv(); + auto& prm = env.app().getPathRequestManager(); + + // Missing source_account + json::Value jv = json::ValueType::Object; + jv[jss::destination_account] = "rEb8TK1gPpG1GzNUnR1CcyRSVxQk9LNq2A"; + jv[jss::destination_amount] = json::ValueType::Object; + jv[jss::destination_amount][jss::currency] = "USD"; + jv[jss::destination_amount][jss::value] = "10"; + jv[jss::destination_amount][jss::issuer] = "rEb8TK1gPpG1GzNUnR1CcyRSVxQk9LNq2A"; + + Resource::Consumer c; + auto res = prm.doLegacyPathRequest(c, env.closed(), jv); + BEAST_EXPECT(res.isMember(jss::error)); + } + + void + pathRequestParseJsonMalformedAccount() + { + testcase("PathRequest::parseJson malformed account"); + using namespace jtx; + Env env = pathTestEnv(); + auto& prm = env.app().getPathRequestManager(); + + json::Value jv = json::ValueType::Object; + jv[jss::source_account] = "invalid_base58"; + jv[jss::destination_account] = "rEb8TK1gPpG1GzNUnR1CcyRSVxQk9LNq2A"; + jv[jss::destination_amount] = json::ValueType::Object; + jv[jss::destination_amount][jss::currency] = "USD"; + jv[jss::destination_amount][jss::value] = "10"; + jv[jss::destination_amount][jss::issuer] = "rEb8TK1gPpG1GzNUnR1CcyRSVxQk9LNq2A"; + + Resource::Consumer c; + auto res = prm.doLegacyPathRequest(c, env.closed(), jv); + BEAST_EXPECT(res.isMember(jss::error)); + } + + void + pathRequestParseJsonMalformedAmount() + { + testcase("PathRequest::parseJson malformed destination amount"); + using namespace jtx; + Env env = pathTestEnv(); + auto const gw = Account("gw"); + auto const alice = Account("alice"); + env.fund(XRP(10000), alice, gw); + env.close(); + + auto& prm = env.app().getPathRequestManager(); + + json::Value jv = json::ValueType::Object; + jv[jss::source_account] = toBase58(alice); + jv[jss::destination_account] = toBase58(gw); + jv[jss::destination_amount] = "not_an_object"; + + Resource::Consumer c; + auto res = prm.doLegacyPathRequest(c, env.closed(), jv); + BEAST_EXPECT(res.isMember(jss::error)); + } + + void + pathRequestParseJsonSendMaxWithoutConvertAll() + { + testcase("PathRequest::parseJson send_max without convert_all destination"); + using namespace jtx; + Env env = pathTestEnv(); + auto const gw = Account("gw"); + auto const alice = Account("alice"); + auto const usd = gw["USD"]; + env.fund(XRP(10000), alice, gw); + env.close(); + + auto& prm = env.app().getPathRequestManager(); + + json::Value jv = json::ValueType::Object; + jv[jss::source_account] = toBase58(alice); + jv[jss::destination_account] = toBase58(gw); + // Normal destination amount (not convert_all) + jv[jss::destination_amount] = json::ValueType::Object; + jv[jss::destination_amount][jss::currency] = "USD"; + jv[jss::destination_amount][jss::value] = "10"; + jv[jss::destination_amount][jss::issuer] = toBase58(gw); + // send_max with non-convert_all should fail + jv[jss::send_max] = json::ValueType::Object; + jv[jss::send_max][jss::currency] = "USD"; + jv[jss::send_max][jss::value] = "100"; + jv[jss::send_max][jss::issuer] = toBase58(gw); + + Resource::Consumer c; + auto res = prm.doLegacyPathRequest(c, env.closed(), jv); + BEAST_EXPECT(res.isMember(jss::error)); + } + + void + pathRequestParseJsonSourceCurrencies() + { + testcase("PathRequest::parseJson source_currencies validation"); + using namespace jtx; + Env env = pathTestEnv(); + auto const gw = Account("gw"); + auto const alice = Account("alice"); + auto const usd = gw["USD"]; + env.fund(XRP(10000), alice, gw); + env.close(); + + auto& prm = env.app().getPathRequestManager(); + + // Empty source_currencies array should fail + json::Value jv = json::ValueType::Object; + jv[jss::source_account] = toBase58(alice); + jv[jss::destination_account] = toBase58(gw); + jv[jss::destination_amount] = json::ValueType::Object; + jv[jss::destination_amount][jss::currency] = "USD"; + jv[jss::destination_amount][jss::value] = "10"; + jv[jss::destination_amount][jss::issuer] = toBase58(gw); + jv[jss::source_currencies] = json::ValueType::Array; + + Resource::Consumer c; + auto res = prm.doLegacyPathRequest(c, env.closed(), jv); + BEAST_EXPECT(res.isMember(jss::error)); + } + + void + pathRequestIsValidSourceNotFound() + { + testcase("PathRequest::isValid source account not found"); + using namespace jtx; + Env env = pathTestEnv(); + auto const gw = Account("gw"); + auto const nonexistent = Account("nonexistent"); + env.fund(XRP(10000), gw); + env.close(); + + auto& prm = env.app().getPathRequestManager(); + + json::Value jv = json::ValueType::Object; + jv[jss::source_account] = toBase58(nonexistent); + jv[jss::destination_account] = toBase58(gw); + jv[jss::destination_amount] = json::ValueType::Object; + jv[jss::destination_amount][jss::currency] = "USD"; + jv[jss::destination_amount][jss::value] = "10"; + jv[jss::destination_amount][jss::issuer] = toBase58(gw); + + Resource::Consumer c; + auto res = prm.doLegacyPathRequest(c, env.closed(), jv); + BEAST_EXPECT(res.isMember(jss::error)); + } + + void + pathRequestIsValidDestNotFoundNonXrp() + { + testcase("PathRequest::isValid destination not found with non-XRP amount"); + using namespace jtx; + Env env = pathTestEnv(); + auto const gw = Account("gw"); + auto const alice = Account("alice"); + auto const nonexistent = Account("nonexistent"); + env.fund(XRP(10000), alice, gw); + env.close(); + + auto& prm = env.app().getPathRequestManager(); + + json::Value jv = json::ValueType::Object; + jv[jss::source_account] = toBase58(alice); + jv[jss::destination_account] = toBase58(nonexistent); + // Non-XRP amount to nonexistent account should fail + jv[jss::destination_amount] = json::ValueType::Object; + jv[jss::destination_amount][jss::currency] = "USD"; + jv[jss::destination_amount][jss::value] = "10"; + jv[jss::destination_amount][jss::issuer] = toBase58(gw); + + Resource::Consumer c; + auto res = prm.doLegacyPathRequest(c, env.closed(), jv); + BEAST_EXPECT(res.isMember(jss::error)); + } + + void + pathRequestIsValidDestNotFoundBelowReserve() + { + testcase("PathRequest::isValid destination not found with XRP below reserve"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + auto const nonexistent = Account("nonexistent"); + env.fund(XRP(10000), alice); + env.close(); + + auto& prm = env.app().getPathRequestManager(); + + json::Value jv = json::ValueType::Object; + jv[jss::source_account] = toBase58(alice); + jv[jss::destination_account] = toBase58(nonexistent); + // XRP amount below reserve to nonexistent account should fail + jv[jss::destination_amount] = json::ValueType::Object; + jv[jss::destination_amount][jss::currency] = "XRP"; + jv[jss::destination_amount][jss::value] = "1"; // Below reserve + + Resource::Consumer c; + auto res = prm.doLegacyPathRequest(c, env.closed(), jv); + BEAST_EXPECT(res.isMember(jss::error)); + } + + void + pathRequestDoCreateAndDoUpdate() + { + testcase("PathRequest::doCreate + doUpdate full flow"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + auto const usd = gw["USD"]; + + env.fund(XRP(10000), alice, bob, gw); + env.close(); + env.trust(usd(1000), bob); + env.trust(usd(1000), alice); + env(pay(gw, alice, usd(500))); + env(pay(gw, bob, usd(500))); + env.close(); + + // Create XRP -> USD offer + env(offer(alice, XRP(500), usd(100))); + env.close(); + + auto& prm = env.app().getPathRequestManager(); + prm.ensurePayGraph(env.closed()); + + json::Value jv = json::ValueType::Object; + jv[jss::source_account] = toBase58(alice); + jv[jss::destination_account] = toBase58(bob); + jv[jss::destination_amount] = json::ValueType::Object; + jv[jss::destination_amount][jss::currency] = "USD"; + jv[jss::destination_amount][jss::value] = "50"; + jv[jss::destination_amount][jss::issuer] = toBase58(gw); + + PathRequest::pointer req; + bool completed = false; + auto completion = [&]() { completed = true; }; + + Resource::Consumer c; + auto res = prm.makeLegacyPathRequest(req, completion, c, env.closed(), jv); + + BEAST_EXPECT(!res.isMember(jss::error)); + BEAST_EXPECT(req != nullptr); + BEAST_EXPECT(req->hasCompletion()); + } + + void + pathRequestNewAndNeedsUpdate() + { + testcase("PathRequest::isNew + needsUpdate state machine"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + auto const usd = gw["USD"]; + + env.fund(XRP(10000), alice, bob, gw); + env.close(); + env.trust(usd(1000), bob); + env.close(); + + auto& prm = env.app().getPathRequestManager(); + + json::Value jv = json::ValueType::Object; + jv[jss::source_account] = toBase58(alice); + jv[jss::destination_account] = toBase58(bob); + jv[jss::destination_amount] = json::ValueType::Object; + jv[jss::destination_amount][jss::currency] = "USD"; + jv[jss::destination_amount][jss::value] = "50"; + jv[jss::destination_amount][jss::issuer] = toBase58(gw); + + PathRequest::pointer req; + Resource::Consumer c; + prm.makeLegacyPathRequest(req, []() {}, c, env.closed(), jv); + + // New request should be "new" (lastIndex == 0) + BEAST_EXPECT(req->isNew()); + + // needsUpdate should return true for new request + bool needs = req->needsUpdate(true, env.closed()->seq() + 1); + BEAST_EXPECT(needs); + + // While inProgress, needsUpdate should return false + needs = req->needsUpdate(true, env.closed()->seq() + 2); + BEAST_EXPECT(!needs); + + // After updateComplete, inProgress is cleared + req->updateComplete(); + + // lastIndex_ remains 0 (never updated in current code), so isNew() stays true + BEAST_EXPECT(req->isNew()); + } + + void + pathRequestLegacyPathRequest() + { + testcase("PathRequest::doLegacyPathRequest synchronous execution"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + auto const usd = gw["USD"]; + + env.fund(XRP(10000), alice, bob, gw); + env.close(); + env.trust(usd(1000), bob); + env.trust(usd(1000), alice); + env(pay(gw, alice, usd(500))); + env(pay(gw, bob, usd(500))); + env.close(); + + // Create XRP -> USD offer + env(offer(alice, XRP(500), usd(100))); + env.close(); + + auto& prm = env.app().getPathRequestManager(); + prm.ensurePayGraph(env.closed()); + + json::Value jv = json::ValueType::Object; + jv[jss::source_account] = toBase58(alice); + jv[jss::destination_account] = toBase58(bob); + jv[jss::destination_amount] = json::ValueType::Object; + jv[jss::destination_amount][jss::currency] = "USD"; + jv[jss::destination_amount][jss::value] = "50"; + jv[jss::destination_amount][jss::issuer] = toBase58(gw); + + // doLegacyPathRequest executes synchronously + Resource::Consumer c; + auto res = prm.doLegacyPathRequest(c, env.closed(), jv); + + BEAST_EXPECT(!res.isMember(jss::error)); + BEAST_EXPECT(res.isMember(jss::alternatives)); + } + + void + pathRequestFindPathsNoGraph() + { + testcase("PathRequest findPaths returns error when PayGraph not ready"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + auto const usd = gw["USD"]; + + env.fund(XRP(10000), alice, bob, gw); + env.close(); + env.trust(usd(1000), bob); + env.close(); + + auto& prm = env.app().getPathRequestManager(); + // Do NOT build PayGraph — ensure it's null + + json::Value jv = json::ValueType::Object; + jv[jss::source_account] = toBase58(alice); + jv[jss::destination_account] = toBase58(bob); + jv[jss::destination_amount] = json::ValueType::Object; + jv[jss::destination_amount][jss::currency] = "USD"; + jv[jss::destination_amount][jss::value] = "50"; + jv[jss::destination_amount][jss::issuer] = toBase58(gw); + + PathRequest::pointer req; + Resource::Consumer c; + auto res = prm.makeLegacyPathRequest(req, []() {}, c, env.closed(), jv); + + BEAST_EXPECT(req != nullptr); + + // doUpdate without PayGraph - the findPaths method returns RpcNotReady + // when graph_ is null. We verify the request was created successfully. + BEAST_EXPECT(req->hasCompletion()); + } + + // PathRequestManager unit tests for coverage + + void + pathRequestManagerGetAssetCache() + { + testcase("PathRequestManager::getAssetCache creation and reuse"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + env.fund(XRP(10000), alice); + env.close(); + + auto& prm = env.app().getPathRequestManager(); + auto const ledger = env.closed(); + + // First call creates a new cache + auto cache1 = prm.getAssetCache(ledger, false); + BEAST_EXPECT(cache1 != nullptr); + BEAST_EXPECT(cache1->getLedger()->seq() == ledger->seq()); + + // Second call with same ledger should reuse the cache + auto cache2 = prm.getAssetCache(ledger, false); + BEAST_EXPECT(cache2 == cache1); + + // Authoritative call with newer ledger creates new cache + env.close(); + auto const ledger2 = env.closed(); + auto cache3 = prm.getAssetCache(ledger2, true); + BEAST_EXPECT(cache3 != nullptr); + BEAST_EXPECT(cache3->getLedger()->seq() == ledger2->seq()); + } + + void + pathRequestManagerGetAssetCacheJumpBack() + { + testcase("PathRequestManager::getAssetCache jump back creates new cache"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + env.fund(XRP(10000), alice); + env.close(); + + // Advance several ledgers + for (int i = 0; i < 10; i++) + env.close(); + + auto const ledger = env.closed(); + auto& prm = env.app().getPathRequestManager(); + + // Create cache for seq=11 + auto cache1 = prm.getAssetCache(ledger, true); + BEAST_EXPECT(cache1 != nullptr); + + // Advance more ledgers to get a newer cache + env.close(); + auto const ledgerNewer = env.closed(); + auto cache2 = prm.getAssetCache(ledgerNewer, true); + BEAST_EXPECT(cache2 != nullptr); + BEAST_EXPECT(cache2->getLedger()->seq() > cache1->getLedger()->seq()); + } + + void + pathRequestManagerRequestsPending() + { + testcase("PathRequestManager::requestsPending"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + auto const bob = Account("bob"); + env.fund(XRP(10000), alice, bob); + env.close(); + + auto& prm = env.app().getPathRequestManager(); + + // Initially no requests pending + BEAST_EXPECT(!prm.requestsPending()); + } + + void + pathRequestManagerEnsurePayGraph() + { + testcase("PathRequestManager::ensurePayGraph build and reuse"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + auto const usd = gw["USD"]; + + env.fund(XRP(10000), alice, bob, gw); + env.close(); + env.trust(usd(1000), alice, bob); + env(pay(gw, alice, usd(500))); + env.close(); + env(offer(alice, XRP(500), usd(100))); + env.close(); + + auto& prm = env.app().getPathRequestManager(); + auto const ledger = env.closed(); + + // First call builds the graph + auto graph1 = prm.ensurePayGraph(ledger); + BEAST_EXPECT(graph1 != nullptr); + + // Second call with same ledger reuses the graph + auto graph2 = prm.ensurePayGraph(ledger); + BEAST_EXPECT(graph1 == graph2); + + // After ledger close, ensurePayGraph reuses the same in-memory graph. + // Edge updates land via updateAll/applyLedgerDelta, not a full rebuild + // on every path_find (ledgers close ~3s; rebuild was multi-second). + env.close(); + auto const ledger2 = env.closed(); + auto graph3 = prm.ensurePayGraph(ledger2); + BEAST_EXPECT(graph3 != nullptr); + BEAST_EXPECT(graph3 == graph1); + } + + void + pathRequestManagerFindPathsNullLedger() + { + testcase("PathRequestManager::findPaths with null ledger"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + auto const bob = Account("bob"); + env.fund(XRP(10000), alice, bob); + env.close(); + + auto& prm = env.app().getPathRequestManager(); + + // Null ledger should return empty STPathSet + STPathSet paths = prm.findPaths( + nullptr, alice, bob, XRP(100), PathAsset(xrpCurrency()), std::nullopt, std::nullopt, 6); + BEAST_EXPECT(paths.empty()); + } + + void + pathRequestManagerFindPathsBasic() + { + testcase("PathRequestManager::findPaths basic XRP to IOU"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + auto const usd = gw["USD"]; + + env.fund(XRP(10000), alice, bob, gw); + env.close(); + env.trust(usd(1000), alice, bob); + env(pay(gw, alice, usd(500))); + env.close(); + env(offer(alice, XRP(500), usd(100))); + env.close(); + + auto& prm = env.app().getPathRequestManager(); + auto const ledger = env.closed(); + + // Build graph first + prm.ensurePayGraph(ledger); + + // Find paths from alice to bob for USD + STPathSet paths = prm.findPaths( + ledger, alice, bob, usd(50), PathAsset(xrpCurrency()), std::nullopt, std::nullopt, 6); + BEAST_EXPECT(paths.size() >= 1); + } + + void + pathRequestManagerFindPathsNoGraph() + { + testcase("PathRequestManager::findPaths with no PayGraph built"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + auto const bob = Account("bob"); + env.fund(XRP(10000), alice, bob); + env.close(); + + auto& prm = env.app().getPathRequestManager(); + auto const ledger = env.closed(); + + // Without ensurePayGraph, findPaths should return empty + STPathSet paths = prm.findPaths( + ledger, alice, bob, XRP(100), PathAsset(xrpCurrency()), std::nullopt, std::nullopt, 6); + BEAST_EXPECT(paths.empty()); + } + + void + pathRequestManagerFindPathsIOUToXRP() + { + testcase("PathRequestManager::findPaths IOU to XRP"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + auto const usd = gw["USD"]; + + env.fund(XRP(10000), alice, bob, gw); + env.close(); + env.trust(usd(1000), alice); + env(pay(gw, alice, usd(500))); + env.close(); + + // Alice offers USD for XRP + env(offer(alice, usd(100), XRP(500))); + env.close(); + + auto& prm = env.app().getPathRequestManager(); + auto const ledger = env.closed(); + prm.ensurePayGraph(ledger); + + // Alice sends USD to bob who receives XRP + STPathSet paths = + prm.findPaths(ledger, alice, bob, XRP(100), usd.asset(), std::nullopt, std::nullopt, 6); + BEAST_EXPECT(paths.size() >= 1); + } + + void + pathRequestManagerMakeLegacyPathRequestInvalidReset() + { + testcase("PathRequestManager::makeLegacyPathRequest resets req on invalid"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + auto const bob = Account("bob"); + env.fund(XRP(10000), alice, bob); + env.close(); + + auto& prm = env.app().getPathRequestManager(); + + // Missing required fields should reset req to null + PathRequest::pointer req; + Resource::Consumer c; + + json::Value jv = json::ValueType::Object; + // No source_account, destination_account, or destination_amount + + auto res = prm.makeLegacyPathRequest(req, []() {}, c, env.closed(), jv); + BEAST_EXPECT(req == nullptr); + BEAST_EXPECT(res.isMember(jss::error)); + } + + void + pathRequestManagerMakeLegacyPathRequestValid() + { + testcase("PathRequestManager::makeLegacyPathRequest with valid request"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + auto const usd = gw["USD"]; + + env.fund(XRP(10000), alice, bob, gw); + env.close(); + env.trust(usd(1000), bob); + env.close(); + + auto& prm = env.app().getPathRequestManager(); + + json::Value jv = json::ValueType::Object; + jv[jss::source_account] = toBase58(alice); + jv[jss::destination_account] = toBase58(bob); + jv[jss::destination_amount] = json::ValueType::Object; + jv[jss::destination_amount][jss::currency] = "USD"; + jv[jss::destination_amount][jss::value] = "50"; + jv[jss::destination_amount][jss::issuer] = toBase58(gw); + + PathRequest::pointer req; + Resource::Consumer c; + auto res = prm.makeLegacyPathRequest(req, []() {}, c, env.closed(), jv); + + BEAST_EXPECT(req != nullptr); + BEAST_EXPECT(!res.isMember(jss::error)); + } + + void + pathRequestManagerGetPayGraph() + { + testcase("PathRequestManager::getPayGraph returns null before build"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + env.fund(XRP(10000), alice); + env.close(); + + auto& prm = env.app().getPathRequestManager(); + + // Full OB scan path: ready + one-time build from scanned books. + prm.signalOrderBookReady(env.closed()); + BEAST_EXPECT(prm.getPayGraph() != nullptr); + } + + void + pathRequestManagerInsertPathRequestOrdering() + { + testcase("PathRequestManager::insertPathRequest ordering"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + auto const usd = gw["USD"]; + + env.fund(XRP(10000), alice, bob, gw); + env.close(); + env.trust(usd(1000), bob); + env.close(); + + auto& prm = env.app().getPathRequestManager(); + + json::Value jv = json::ValueType::Object; + jv[jss::source_account] = toBase58(alice); + jv[jss::destination_account] = toBase58(bob); + jv[jss::destination_amount] = json::ValueType::Object; + jv[jss::destination_amount][jss::currency] = "USD"; + jv[jss::destination_amount][jss::value] = "50"; + jv[jss::destination_amount][jss::issuer] = toBase58(gw); + + // Create two requests + PathRequest::pointer req1; + PathRequest::pointer req2; + Resource::Consumer c; + + prm.makeLegacyPathRequest(req1, []() {}, c, env.closed(), jv); + prm.makeLegacyPathRequest(req2, []() {}, c, env.closed(), jv); + + BEAST_EXPECT(req1 != nullptr); + BEAST_EXPECT(req2 != nullptr); + + // Both should be pending + BEAST_EXPECT(prm.requestsPending()); + + // After completing req1 update, it's no longer new + req1->updateComplete(); + + // Create a third new request - should be inserted before serviced ones + PathRequest::pointer req3; + prm.makeLegacyPathRequest(req3, []() {}, c, env.closed(), jv); + BEAST_EXPECT(req3 != nullptr); + } + + void + pathRequestManagerDoLegacyPathRequestNoAlternatives() + { + testcase("PathRequestManager::doLegacyPathRequest with no paths found"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + auto const usd = gw["USD"]; + env.fund(XRP(10000), alice, bob, gw); + env.close(); + // Create trust lines but no offers — pathfinding should return empty alternatives + env.trust(usd(1000), alice, bob); + env(pay(gw, alice, usd(500))); + env(pay(gw, bob, usd(500))); + env.close(); + + auto& prm = env.app().getPathRequestManager(); + + json::Value jv = json::ValueType::Object; + jv[jss::source_account] = toBase58(alice); + jv[jss::destination_account] = toBase58(bob); + jv[jss::destination_amount] = json::ValueType::Object; + jv[jss::destination_amount][jss::currency] = "USD"; + jv[jss::destination_amount][jss::value] = "50"; + jv[jss::destination_amount][jss::issuer] = toBase58(gw); + + Resource::Consumer c; + auto res = prm.doLegacyPathRequest(c, env.closed(), jv); + BEAST_EXPECT(!res.isMember(jss::error)); + } + + void + pathRequestManagerReportFastAndFull() + { + testcase("PathRequestManager::reportFast and reportFull metrics"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + env.fund(XRP(10000), alice); + env.close(); + + auto& prm = env.app().getPathRequestManager(); + + // These should not crash - they just record metrics + prm.reportFast(std::chrono::milliseconds(10)); + prm.reportFull(std::chrono::milliseconds(20)); + } + + void + pathRequestManagerSignalOrderBookReady() + { + testcase("PathRequestManager::signalOrderBookReady"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + env.fund(XRP(10000), alice); + env.close(); + + auto& prm = env.app().getPathRequestManager(); + auto const ledger = env.closed(); + + // Ready + build from the scanned ledger's book set. + prm.signalOrderBookReady(ledger); + BEAST_EXPECT(prm.getPayGraph() != nullptr); + } + + void + pathRequestManagerFindPathsDomain() + { + testcase("PathRequestManager::findPaths with domain"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + auto const usd = gw["USD"]; + + env.fund(XRP(10000), alice, bob, gw); + env.close(); + env.trust(usd(1000), alice, bob); + env.close(); + + auto& prm = env.app().getPathRequestManager(); + auto const ledger = env.closed(); + + // Domain-specific path finding with a domain that has no offers + // should return empty (tests the domain branch in findPaths) + uint256 domain("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"); + auto paths = prm.findPaths( + ledger, alice, bob, usd(10), PathAsset(xrpCurrency()), std::nullopt, domain, 5); + + // Should return empty since no domain-specific offers exist + BEAST_EXPECT(paths.empty()); + } + + void + pathRequestManagerGetAssetCacheJumpForward() + { + testcase("PathRequestManager::getAssetCache jump forward non-authoritative"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + env.fund(XRP(10000), alice); + env.close(); + + auto& prm = env.app().getPathRequestManager(); + + // Create cache for current ledger + auto const ledger1 = env.closed(); + auto cache1 = prm.getAssetCache(ledger1, true); + BEAST_EXPECT(cache1 != nullptr); + auto const seq1 = cache1->getLedger()->seq(); + + // Advance 10 more ledgers (well beyond the +8 threshold) + for (int i = 0; i < 10; i++) + env.close(); + + auto const ledger2 = env.closed(); + auto const seq2 = ledger2->seq(); + BEAST_EXPECT(seq2 > seq1 + 8); + + // Non-authoritative call with ledger far ahead (> lineSeq + 8) should + // create a new cache (line 50 in PathRequestManager.cpp) + auto cache2 = prm.getAssetCache(ledger2, false); + BEAST_EXPECT(cache2 != cache1); + BEAST_EXPECT(cache2->getLedger()->seq() == seq2); + } + + void + pathRequestManagerUpdateAllPathSearchDisabled() + { + testcase("PathRequestManager::updateAll with pathSearch disabled"); + using namespace jtx; + // Default config has pathSearch = false + Env env(*this); + auto const alice = Account("alice"); + env.fund(XRP(10000), alice); + env.close(); + + auto& prm = env.app().getPathRequestManager(); + auto const ledger = env.closed(); + + // updateAll should return early when pathSearch is disabled + // and should not crash + prm.updateAll(ledger); + + // PayGraph should remain nullptr since pathSearch is disabled + BEAST_EXPECT(prm.getPayGraph() == nullptr); + } + + void + pathRequestManagerMakeLegacyPathRequestTooBusy() + { + testcase("PathRequestManager::makeLegacyPathRequest returns RpcTooBusy"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + auto const bob = Account("bob"); + env.fund(XRP(10000), alice, bob); + env.close(); + + auto& prm = env.app().getPathRequestManager(); + auto const ledger = env.closed(); + + json::Value jv = json::ValueType::Object; + jv[jss::source_account] = toBase58(alice); + jv[jss::destination_account] = toBase58(bob); + jv[jss::destination_amount] = "100"; + + Resource::Consumer c; + PathRequest::pointer req; + + // First call should succeed (not too busy) + auto res = prm.makeLegacyPathRequest(req, [] {}, c, ledger, jv); + BEAST_EXPECT(!res.isMember(jss::error)); + BEAST_EXPECT(req != nullptr); + } + + //------------------------------------------------------------------------------ + // OrderBookDB domain-specific getAllTakerPaysAssets test + + void + orderBookDBAllTakerPaysAssetsDomain() + { + testcase("OrderBookDB::getAllTakerPaysAssets with domain"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + auto const usd = gw["USD"]; + auto const eur = gw["EUR"]; + + env.fund(XRP(10000), alice, bob, gw); + env.close(); + env.trust(usd(1000), alice, bob); + env.trust(eur(1000), alice, bob); + env.close(); + env(pay(gw, alice, usd(500))); + env(pay(gw, bob, eur(500))); + env.close(); + + // Create domain and set it up for all accounts + std::optional domainID = setupDomain(env, {alice, bob, gw}); + + // Create domain-specific offers + // Domain: XRP -> USD (takerPays = XRP within domain) + env(offer(alice, XRP(500), usd(100)), Domain(*domainID)); + // Domain: EUR -> XRP (takerPays = EUR within domain) + env(offer(bob, eur(100), XRP(500)), Domain(*domainID)); + env.close(); + + // Also create a non-domain offer for comparison + // Non-domain: USD -> XRP (takerPays = USD, no domain) + env(offer(alice, usd(100), XRP(500))); + env.close(); + + auto& obdb = env.app().getOrderBookDB(); + + // Query with domain - should only return assets from domain books + auto domainAssets = obdb.getAllTakerPaysAssets(*domainID); + + // Domain has XRP -> USD and EUR -> XRP, so takerPays are XRP and EUR + BEAST_EXPECT(domainAssets.size() == 2); + + bool foundXRP = false, foundEUR = false, foundUSD = false; + for (auto const& asset : domainAssets) + { + if (isXRP(asset)) + foundXRP = true; + else if (asset == eur.asset()) + foundEUR = true; + else if (asset == usd.asset()) + foundUSD = true; + } + BEAST_EXPECT(foundXRP); // XRP is takerPays in domain XRP->USD book + BEAST_EXPECT(foundEUR); // EUR is takerPays in domain EUR->XRP book + BEAST_EXPECT(!foundUSD); // USD->XRP book is non-domain, should not appear + + // Query without domain - should include all books (domain + non-domain) + auto allAssets = obdb.getAllTakerPaysAssets(); + // Should have XRP, EUR from domain books AND USD from non-domain book + bool foundAllUSD = false; + for (auto const& asset : allAssets) + { + if (asset == usd.asset()) + foundAllUSD = true; + } + BEAST_EXPECT(foundAllUSD); // Non-domain USD book appears in global query + } + + //------------------------------------------------------------------------------ + // PayGraphDelta unit tests for coverage + + void + payGraphDeltaExtractChangedBooks() + { + testcase("PayGraphDelta::extractChangedBooks from TxMeta"); + using namespace jtx; + Env env = pathTestEnv(); + auto const alice = Account("alice"); + auto const gw = Account("gw"); + auto const usd = gw["USD"]; + + env.fund(XRP(10000), alice, gw); + env.close(); + env.trust(usd(1000), alice); + env.close(); + env(pay(gw, alice, usd(500))); + env.close(); + + // Create an offer: XRP -> USD + env(offer(alice, XRP(500), usd(100))); + env.close(); + + // Get transaction metadata + auto const& meta = env.meta(); + BEAST_EXPECT(meta != nullptr); + + // Extract changed books from the offer creation metadata + TxMeta txMeta( + env.tx()->getTransactionID(), env.closed()->seq(), *const_cast(meta.get())); + + auto books = extractChangedBooks(txMeta, std::nullopt); + + // An OfferCreate should produce one changed book (XRP -> USD) + BEAST_EXPECT(books.size() == 1); + BEAST_EXPECT(isXRP(books[0].in)); + BEAST_EXPECT(books[0].out == usd.asset()); + + // Test with empty nodes array (no offers changed) + STArray emptyNodes; + auto emptyBooks = extractChangedBooks(emptyNodes, std::nullopt); + BEAST_EXPECT(emptyBooks.empty()); + + // Test the STArray overload directly via getNodes() + auto booksFromNodes = extractChangedBooks(txMeta.getNodes(), std::nullopt); + BEAST_EXPECT(booksFromNodes.size() == 1); + } + + void + payGraphDeltaMergeBooks() + { + testcase("PayGraphDelta::mergeBooks deduplication"); + using namespace jtx; + Env env = pathTestEnv(); + auto const gw = Account("gw"); + auto const usd = gw["USD"]; + auto const eur = gw["EUR"]; + + Asset const xrp = XRP; + Asset const usdAsset = usd.asset(); + Asset const eurAsset = eur.asset(); + + // dest has XRP->USD book + std::vector dest{{xrp, usdAsset, std::nullopt}}; + + // src has XRP->USD (duplicate) and XRP->EUR (new) + std::vector src{{xrp, usdAsset, std::nullopt}, {xrp, eurAsset, std::nullopt}}; + + mergeBooks(dest, src); + + // Should have 2 unique books after merge + BEAST_EXPECT(dest.size() == 2); + + // cspell:ignore hasXRPUSD hasXRPEUR + bool hasXRPUSD = false, hasXRPEUR = false; + for (auto const& book : dest) + { + if (book.in == xrp && book.out == usdAsset) + hasXRPUSD = true; + if (book.in == xrp && book.out == eurAsset) + hasXRPEUR = true; + } + BEAST_EXPECT(hasXRPUSD); + BEAST_EXPECT(hasXRPEUR); + + // Test merging into empty dest + std::vector emptyDest; + mergeBooks(emptyDest, src); + BEAST_EXPECT(emptyDest.size() == 2); + + // Test merging empty src (no change to dest) + std::vector beforeMerge = dest; + std::vector emptySrc; + mergeBooks(dest, emptySrc); + BEAST_EXPECT(dest.size() == beforeMerge.size()); + } + + void + liquidityDepthProvesTopOfBookProblem() + { + testcase("Liquidity depth: validate actual liquidity is used and not just top-of-book"); + using namespace jtx; + + // Top-of-book weights ignore liquidity depth. + // + // Path A (thin, direct): one offer selling 5 USD at an amazing rate + // (0.01 XRP per USD). Edge weight says "super cheap." + // Path B (deep, via EUR): lots of depth at a worse effective rate + // (~0.50 XRP per USD). Edge weight says "expensive." + // + // Sending 3 USD? Path A wins — great rate, enough depth. + // Sending 10_000 USD? Path A is unusable (only 5 USD at that rate). + // Without a depth penalty, Path A still ranks first and burns a + // k-shortest candidate slot (only 18 exist: 6 × 3 oversample). + + Env env = pathTestEnv(); + + auto const gw = Account("gw"); + auto const thinMM = Account("thinMM"); + auto const deepMM = Account("deepMM"); + + env.fund(XRP(1'000'000), gw, thinMM, deepMM); + env.close(); + + auto const USD = gw["USD"]; + auto const EUR = gw["EUR"]; + + env.trust(USD(100'000), thinMM, deepMM); + env.trust(EUR(100'000), deepMM); + env.close(); + + // Thin book: only 5 USD of inventory. + env(pay(gw, thinMM, USD(5))); + // Deep book: enough to fill a 10_000 USD payment. + env(pay(gw, deepMM, USD(10'000))); + env(pay(gw, deepMM, EUR(10'000))); + env.close(); + + // Path A: XRP -> USD, 0.01 XRP/USD, 5 USD depth. + // offer: taker pays 0.05 XRP, gets 5 USD. + env(offer(thinMM, XRP(0.05), USD(5))); + env.close(); + + // Path B: XRP -> EUR -> USD, ~0.50 XRP/USD effective, deep. + // Hop1: 50 XRP per 100 EUR; hop2: 100 EUR per 100 USD. + // 100 stacked offers → 10_000 USD of depth on the final hop. + for (int i = 0; i < 100; ++i) + { + env(offer(deepMM, XRP(50), EUR(100))); + env(offer(deepMM, EUR(100), USD(100))); + } + env.close(); + + auto& obdb = env.app().getOrderBookDB(); + auto const ledger = env.closed(); + beast::Journal const journal{env.app().getJournal("PayGraph")}; + auto pg = PayGraph::build(obdb, *ledger, std::nullopt, journal); + + auto const xrpAsset = Asset{xrpl::xrpIssue()}; + auto const usdAsset = Asset{USD.issue()}; + auto const eurAsset = Asset{EUR.issue()}; + + auto const xrpVid = pg->vertexOf(xrpAsset); + auto const usdVid = pg->vertexOf(usdAsset); + auto const eurVid = pg->vertexOf(eurAsset); + + auto rankOf = [&](std::vector const& paths, bool thin) -> int { + for (std::size_t i = 0; i < paths.size(); ++i) + { + auto const& vids = paths[i].vids; + if (thin) + { + if (vids.size() == 2 && vids[0] == xrpVid && vids[1] == usdVid) + return static_cast(i); + } + else if ( + vids.size() == 3 && vids[0] == xrpVid && vids[1] == eurVid && vids[2] == usdVid) + { + return static_cast(i); + } + } + return -1; + }; + + // --- Small payment: thin path has enough depth and the better rate --- + { + auto const paths = pg->findPaths(xrpAsset, usdAsset, 6, USD(3)); + BEAST_EXPECT(!paths.empty()); + int const thinRank = rankOf(paths, true); + int const deepRank = rankOf(paths, false); + BEAST_EXPECT(thinRank >= 0); + BEAST_EXPECT(deepRank >= 0); + // Thin must win when the payment fits in its book. + BEAST_EXPECT(thinRank >= 0 && deepRank >= 0 && thinRank < deepRank); + } + + // --- Larger payment still discovers both candidates (depth is advisory) --- + // Full-book walks are intentionally avoided for speed; rippleCalculate + // remains the authority for fillability. + { + auto const paths = pg->findPaths(xrpAsset, usdAsset, 6, USD(10)); + BEAST_EXPECT(!paths.empty()); + BEAST_EXPECT(rankOf(paths, true) >= 0); + BEAST_EXPECT(rankOf(paths, false) >= 0); + } } }; diff --git a/src/test/jtx/Env.h b/src/test/jtx/Env.h index 7e22cdd5711..ec219527c88 100644 --- a/src/test/jtx/Env.h +++ b/src/test/jtx/Env.h @@ -13,7 +13,6 @@ #include #include #include -#include #include #include @@ -233,7 +232,6 @@ class Env , journal{bundle_.app->getJournal("Env")} { memoize(Account::kMaster); - Pathfinder::initPathTable(); foreachFeature(features, [&appFeats = app().config().features](uint256 const& f) { appFeats.insert(f); }); diff --git a/src/test/jtx/escrow.h b/src/test/jtx/escrow.h index 68737c2e350..7b91d4107f6 100644 --- a/src/test/jtx/escrow.h +++ b/src/test/jtx/escrow.h @@ -3,15 +3,11 @@ #include #include #include +#include +#include -#include -#include +#include #include -#include -#include - -#include -#include /** * Escrow operations. diff --git a/src/test/jtx/impl/AMMTest.cpp b/src/test/jtx/impl/AMMTest.cpp index 3b4aae20e24..416869581d7 100644 --- a/src/test/jtx/impl/AMMTest.cpp +++ b/src/test/jtx/impl/AMMTest.cpp @@ -213,9 +213,7 @@ AMMTest::pathTestEnv() // different from the current defaults. This function creates an env // with the search parameters that the tests were written for. return Env(*this, envconfig([](std::unique_ptr cfg) { - cfg->pathSearchOld = 7; - cfg->pathSearch = 7; - cfg->pathSearchMax = 10; + cfg->pathSearch = true; return cfg; })); } diff --git a/src/test/jtx/impl/TestHelpers.cpp b/src/test/jtx/impl/TestHelpers.cpp index 4d3869b4f91..a24c540632f 100644 --- a/src/test/jtx/impl/TestHelpers.cpp +++ b/src/test/jtx/impl/TestHelpers.cpp @@ -190,9 +190,7 @@ pathTestEnv(beast::unit_test::Suite& suite) // with the search parameters that the tests were written for. using namespace jtx; return Env(suite, envconfig([](std::unique_ptr cfg) { - cfg->pathSearchOld = 7; - cfg->pathSearch = 7; - cfg->pathSearchMax = 10; + cfg->pathSearch = true; return cfg; })); } diff --git a/src/test/jtx/impl/paths.cpp b/src/test/jtx/impl/paths.cpp index eb4b36ae4ff..7bdc5894180 100644 --- a/src/test/jtx/impl/paths.cpp +++ b/src/test/jtx/impl/paths.cpp @@ -5,7 +5,7 @@ #include #include -#include +#include #include #include @@ -41,22 +41,8 @@ Paths::operator()(Env& env, JTx& jt) const domain = num; } - Pathfinder pf( - std::make_shared(env.current(), env.app().getJournal("AssetCache")), - from, - to, - in_, - in_.getIssuer(), - amount, - std::nullopt, - domain, - env.app()); - if (!pf.findPaths(depth_)) - return; - - STPath fp; - pf.computePathRanks(limit_); - auto const found = pf.getBestPaths(limit_, fp, {}, in_.getIssuer()); + auto const found = env.app().getPathRequestManager().findPaths( + env.current(), from, to, amount, in_, in_.getIssuer(), domain, limit_); // VFALCO TODO API to allow caller to examine the STPathSet // VFALCO isDefault should be renamed to empty() diff --git a/src/test/jtx/paths.h b/src/test/jtx/paths.h index 07d0117f8fd..eca046d7477 100644 --- a/src/test/jtx/paths.h +++ b/src/test/jtx/paths.h @@ -23,12 +23,11 @@ class Paths { private: Asset in_; - int depth_; unsigned int limit_; public: - Paths(Asset const& in, int depth = 7, unsigned int limit = 4) - : in_(in), depth_(depth), limit_(limit) + Paths(Asset const& in, [[maybe_unused]] int depth = 7, unsigned int limit = 4) + : in_(in), limit_(limit) { } diff --git a/src/test/rpc/AmendmentBlocked_test.cpp b/src/test/rpc/AmendmentBlocked_test.cpp index 850d6db35b7..80291fd03fe 100644 --- a/src/test/rpc/AmendmentBlocked_test.cpp +++ b/src/test/rpc/AmendmentBlocked_test.cpp @@ -9,10 +9,10 @@ #include #include +#include #include #include -#include #include #include #include @@ -20,7 +20,6 @@ #include #include -#include namespace xrpl { @@ -31,7 +30,8 @@ class AmendmentBlocked_test : public beast::unit_test::Suite { using namespace test::jtx; Env env{*this, envconfig([](std::unique_ptr cfg) { - cfg->loadFromString(std::string("[") + Sections::kSigningSupport + "]\ntrue"); + cfg->loadFromString("[" SECTION_SIGNING_SUPPORT "]\ntrue"); + cfg->pathSearch = true; return cfg; })}; auto const gw = Account{"gateway"}; diff --git a/src/test/rpc/NoRipple_test.cpp b/src/test/rpc/NoRipple_test.cpp index 3b3f8b36747..05318dd3267 100644 --- a/src/test/rpc/NoRipple_test.cpp +++ b/src/test/rpc/NoRipple_test.cpp @@ -75,7 +75,13 @@ class NoRipple_test : public beast::unit_test::Suite auto const bob = Account("bob"); auto const carol = Account("carol"); - Env env(*this, features); + Env env( + *this, + envconfig([](std::unique_ptr cfg) { + cfg->pathSearch = true; + return cfg; + }), + features); env.fund(XRP(10000), gw, alice, bob, carol); env.close(); diff --git a/src/xrpld/app/ledger/LedgerMaster.h b/src/xrpld/app/ledger/LedgerMaster.h index 32163fd57b9..22082752677 100644 --- a/src/xrpld/app/ledger/LedgerMaster.h +++ b/src/xrpld/app/ledger/LedgerMaster.h @@ -1,42 +1,25 @@ #pragma once #include -#include +#include #include #include #include #include #include -#include #include #include -#include #include #include -#include -#include -#include -#include #include #include -#include #include #include -#include +#include -#include - -#include -#include -#include -#include -#include #include #include -#include -#include -#include namespace xrpl { diff --git a/src/xrpld/app/ledger/OrderBookDBImpl.cpp b/src/xrpld/app/ledger/OrderBookDBImpl.cpp index 1e474ef9492..3cea4845f2e 100644 --- a/src/xrpld/app/ledger/OrderBookDBImpl.cpp +++ b/src/xrpld/app/ledger/OrderBookDBImpl.cpp @@ -1,6 +1,7 @@ #include #include +#include #include #include @@ -215,6 +216,20 @@ OrderBookDBImpl::update(std::shared_ptr const& ledger) xrpDomainBooks_.swap(xrpDomainBooks); } + // Build/rebuild PayGraph against *this* ledger (the one just scanned). + // Must run after allBooks_ is swapped in. Fixes RpcNotReady when + // path_find arrived before updateAll could build the graph, and avoids + // building against a stale validated ledger that lacks these offers. + try + { + registry_.get().getPathRequestManager().signalOrderBookReady(ledger); + } + catch (...) + { + // Pathfinding is best-effort; never fail the OB update. + JLOG(j_.warn()) << "signalOrderBookReady failed after OrderBookDB update"; + } + registry_.get().getLedgerMaster().newOrderBookDB(); } @@ -353,4 +368,26 @@ affectedBooks(AcceptedLedgerTx const& alTx, beast::Journal const& j) return result; } +std::vector +OrderBookDBImpl::getAllTakerPaysAssets(std::optional const& domain) +{ + std::scoped_lock const sl(lock_); + std::vector ret; + if (!domain) + { + ret.reserve(allBooks_.size()); + for (auto const& [asset, _] : allBooks_) + ret.push_back(asset); + } + else + { + for (auto const& [key, _] : domainBooks_) + { + if (key.second == *domain) + ret.push_back(key.first); + } + } + return ret; +} + } // namespace xrpl diff --git a/src/xrpld/app/ledger/OrderBookDBImpl.h b/src/xrpld/app/ledger/OrderBookDBImpl.h index 5f436a69467..074f7497276 100644 --- a/src/xrpld/app/ledger/OrderBookDBImpl.h +++ b/src/xrpld/app/ledger/OrderBookDBImpl.h @@ -1,22 +1,11 @@ #pragma once -#include -#include #include #include -#include -#include -#include #include -#include -#include -#include -#include #include #include -#include -#include namespace xrpl { @@ -61,6 +50,9 @@ class OrderBookDBImpl final : public OrderBookDB bool isBookToXRP(Asset const& asset, std::optional const& domain = std::nullopt) override; + std::vector + getAllTakerPaysAssets(std::optional const& domain = std::nullopt) override; + // OrderBookDBImpl-specific methods void update(std::shared_ptr const& ledger); diff --git a/src/xrpld/app/main/Application.cpp b/src/xrpld/app/main/Application.cpp index d329475874b..d269a703c5e 100644 --- a/src/xrpld/app/main/Application.cpp +++ b/src/xrpld/app/main/Application.cpp @@ -36,7 +36,6 @@ #include #include #include -#include #include #include @@ -381,7 +380,7 @@ class ApplicationImp : public Application, public BasicApp , nodeFamily_(*this, *collectorManager_) , orderBookDB_(makeOrderBookDb( *this, - {.pathSearchMax = config_->pathSearchMax, .standalone = config_->standalone()})) + {.pathSearchMax = config_->pathSearch, .standalone = config_->standalone()})) , pathRequestManager_( std::make_unique( *this, @@ -1239,8 +1238,6 @@ ApplicationImp::setup(boost::program_options::variables_map const& cmdline) logs_->journal("Amendments")); } - Pathfinder::initPathTable(); - auto const startUp = config_->startUp; JLOG(journal_.debug()) << "startUp: " << startUp; if (startUp == StartUpType::Fresh) diff --git a/src/xrpld/app/misc/SHAMapStoreImp.cpp b/src/xrpld/app/misc/SHAMapStoreImp.cpp index 9b5f412fc5f..e5346c45997 100644 --- a/src/xrpld/app/misc/SHAMapStoreImp.cpp +++ b/src/xrpld/app/misc/SHAMapStoreImp.cpp @@ -422,11 +422,21 @@ SHAMapStoreImp::dbPaths() { Section const section{app_.config().section(Sections::kNodeDatabase)}; - // Skip creating the directory when an in-memory database is used. - if (boost::iequals(get(section, Keys::kType), "memory")) + // Skip creating the directory when an in-memory / pathless backend is used. + // type=memory and type=rwdb do not require an on-disk node store path. + // An empty path must not call create_directories("") — that throws + // boost::filesystem::filesystem_error (EINVAL / "Invalid argument"). + auto const dbType = get(section, Keys::kType); + if (boost::iequals(dbType, "memory") || boost::iequals(dbType, "rwdb")) return; boost::filesystem::path dbPath = get(section, Keys::kPath); + if (dbPath.empty()) + { + journal_.error() << "node db path is not configured (type=" << dbType << ")"; + Throw("node db path must be set for on-disk backends."); + } + if (boost::filesystem::exists(dbPath)) { if (!boost::filesystem::is_directory(dbPath)) diff --git a/src/xrpld/core/Config.h b/src/xrpld/core/Config.h index 852e46218ab..36070c2283b 100644 --- a/src/xrpld/core/Config.h +++ b/src/xrpld/core/Config.h @@ -88,6 +88,16 @@ struct FeeSetup class Config : public BasicConfig { public: + /** + * Compute the effective number of job queue worker threads. + * This logic is shared between config validation and runtime. + */ + static int + computeEffectiveWorkers( + bool standalone, + bool forceMultiThread, + int workers, + std::size_t nodeSize); // Settings related to the configuration file location and directories static char const* const kConfigFileName; static char const* const kConfigLegacyName; @@ -192,13 +202,9 @@ class Config : public BasicConfig // options; higher values result in exponentially higher // resource usage. // - // Servers operating as validators disable path finding by - // default by setting the `PATH_SEARCH_MAX` option to 0 - // unless it is explicitly set in the configuration file. - int pathSearchOld = 2; - int pathSearch = 2; - int pathSearchFast = 2; - int pathSearchMax = 3; + // Path searching is disabled by default + bool pathSearch = false; + int pathWorkers = 2; // Validation std::optional validationQuorum; // validations to consider ledger authoritative @@ -345,6 +351,23 @@ class Config : public BasicConfig return useTxTables_; } + /** Returns true when the RWDB backend is running in null mode. + + In null mode the in-memory node store never persists or retrieves + objects — nodes are retained purely through the Ledger -> SHAMap + shared_ptr retention chain. Activated via the XRPL_RWDB_NULL + environment variable. + */ + static bool + nullBackend() + { + static bool const kV = [] { + char const* e = std::getenv("XRPL_RWDB_NULL"); + return e && *e && std::string_view(e) != "0"; + }(); + return kV; + } + [[nodiscard]] bool canSign() const { diff --git a/src/xrpld/core/ConfigSections.h b/src/xrpld/core/ConfigSections.h new file mode 100644 index 00000000000..91327a7e3a5 --- /dev/null +++ b/src/xrpld/core/ConfigSections.h @@ -0,0 +1,78 @@ +#pragma once + +#include + +namespace xrpl { + +// VFALCO DEPRECATED in favor of the BasicConfig interface +struct ConfigSection +{ + explicit ConfigSection() = default; + + static std::string + nodeDatabase() + { + return "node_db"; + } + static std::string + importNodeDatabase() + { + return "import_db"; + } +}; + +// VFALCO TODO Rename and replace these macros with variables. +#define SECTION_AMENDMENTS "amendments" +#define SECTION_AMENDMENT_MAJORITY_TIME "amendment_majority_time" +#define SECTION_BETA_RPC_API "beta_rpc_api" +#define SECTION_CLUSTER_NODES "cluster_nodes" +#define SECTION_COMPRESSION "compression" +#define SECTION_DEBUG_LOGFILE "debug_logfile" +#define SECTION_ELB_SUPPORT "elb_support" +#define SECTION_FEE_DEFAULT "fee_default" +#define SECTION_FETCH_DEPTH "fetch_depth" +#define SECTION_INSIGHT "insight" +#define SECTION_IO_WORKERS "io_workers" +#define SECTION_IPS "ips" +#define SECTION_IPS_FIXED "ips_fixed" +#define SECTION_LEDGER_HISTORY "ledger_history" +#define SECTION_LEDGER_REPLAY "ledger_replay" +#define SECTION_MAX_TRANSACTIONS "max_transactions" +#define SECTION_NETWORK_ID "network_id" +#define SECTION_NETWORK_QUORUM "network_quorum" +#define SECTION_NODE_SEED "node_seed" +#define SECTION_NODE_SIZE "node_size" +#define SECTION_OVERLAY "overlay" +#define SECTION_PATH_SEARCH "path_search" +#define SECTION_PATH_WORKERS "path_workers" +#define SECTION_PEER_PRIVATE "peer_private" +#define SECTION_PEERS_MAX "peers_max" +#define SECTION_PEERS_IN_MAX "peers_in_max" +#define SECTION_PEERS_OUT_MAX "peers_out_max" +#define SECTION_PORT_GRPC "port_grpc" +#define SECTION_PREFETCH_WORKERS "prefetch_workers" +#define SECTION_REDUCE_RELAY "reduce_relay" +#define SECTION_RELATIONAL_DB "relational_db" +#define SECTION_RELAY_PROPOSALS "relay_proposals" +#define SECTION_RELAY_VALIDATIONS "relay_validations" +#define SECTION_RPC_STARTUP "rpc_startup" +#define SECTION_SIGNING_SUPPORT "signing_support" +#define SECTION_SNTP "sntp_servers" +#define SECTION_SSL_VERIFY "ssl_verify" +#define SECTION_SSL_VERIFY_FILE "ssl_verify_file" +#define SECTION_SSL_VERIFY_DIR "ssl_verify_dir" +#define SECTION_SERVER_DOMAIN "server_domain" +#define SECTION_SWEEP_INTERVAL "sweep_interval" +#define SECTION_VALIDATORS_FILE "validators_file" +#define SECTION_VALIDATION_SEED "validation_seed" +#define SECTION_VALIDATOR_KEYS "validator_keys" +#define SECTION_VALIDATOR_KEY_REVOCATION "validator_key_revocation" +#define SECTION_VALIDATOR_LIST_KEYS "validator_list_keys" +#define SECTION_VALIDATOR_LIST_SITES "validator_list_sites" +#define SECTION_VALIDATOR_LIST_THRESHOLD "validator_list_threshold" +#define SECTION_VALIDATORS "validators" +#define SECTION_VALIDATOR_TOKEN "validator_token" +#define SECTION_VETO_AMENDMENTS "veto_amendments" +#define SECTION_WORKERS "workers" + +} // namespace xrpl diff --git a/src/xrpld/core/detail/Config.cpp b/src/xrpld/core/detail/Config.cpp index 3b7b57328b1..f6d821b9202 100644 --- a/src/xrpld/core/detail/Config.cpp +++ b/src/xrpld/core/detail/Config.cpp @@ -1,5 +1,7 @@ #include +#include + #include #include #include @@ -9,7 +11,6 @@ #include #include #include -#include #include #include #include @@ -384,7 +385,7 @@ Config::setup(std::string const& strConf, bool bQuiet, bool bSilent, bool bStand load(); { // load() may have set a new value for the dataDir - std::string const dbPath(legacy(Sections::kDatabasePath)); + std::string const dbPath(legacy("database_path")); if (!dbPath.empty()) { dataDir = boost::filesystem::path(dbPath); @@ -403,7 +404,7 @@ Config::setup(std::string const& strConf, bool bQuiet, bool bSilent, bool bStand if (ec) Throw(boost::str(boost::format("Can not create %s") % dataDir)); - legacy(Sections::kDatabasePath, boost::filesystem::absolute(dataDir).string()); + legacy("database_path", boost::filesystem::absolute(dataDir).string()); } HTTPClient::initializeSSLContext(this->sslVerifyDir, this->sslVerifyFile, this->sslVerify, j_); @@ -411,11 +412,11 @@ Config::setup(std::string const& strConf, bool bQuiet, bool bSilent, bool bStand if (runStandalone_) ledgerHistory = 0; - Section const ledgerTxTablesSection = section(Sections::kLedgerTxTables); - getIfExists(ledgerTxTablesSection, Keys::kUseTxTables, useTxTables_); + Section const ledgerTxTablesSection = section("ledger_tx_tables"); + getIfExists(ledgerTxTablesSection, "use_tx_tables", useTxTables_); - Section const& nodeDbSection{section(Sections::kNodeDatabase)}; - getIfExists(nodeDbSection, Keys::kFastLoad, fastLoad); + Section const& nodeDbSection{section(ConfigSection::nodeDatabase())}; + getIfExists(nodeDbSection, "fast_load", fastLoad); } // 0 ports are allowed for unit tests, but still not allowed to be present in @@ -423,16 +424,16 @@ Config::setup(std::string const& strConf, bool bQuiet, bool bSilent, bool bStand static void checkZeroPorts(Config const& config) { - if (!config.exists(Sections::kServer)) + if (!config.exists("server")) return; - for (auto const& name : config.section(Sections::kServer).values()) + for (auto const& name : config.section("server").values()) { if (!config.exists(name)) return; auto const& section = config[name]; - auto const optResult = section.get(Keys::kPort); + auto const optResult = section.get("port"); if (optResult) { auto const port = beast::lexicalCast(*optResult); @@ -476,10 +477,10 @@ Config::loadFromString(std::string const& fileContents) build(secConfig); - if (auto s = getIniFileSection(secConfig, Sections::kIps)) + if (auto s = getIniFileSection(secConfig, SECTION_IPS)) ips = *s; - if (auto s = getIniFileSection(secConfig, Sections::kIpsFixed)) + if (auto s = getIniFileSection(secConfig, SECTION_IPS_FIXED)) ipsFixed = *s; // if the user has specified ip:port then replace : with a space. @@ -506,16 +507,16 @@ Config::loadFromString(std::string const& fileContents) { std::string dbPath; - if (getSingleSection(secConfig, Sections::kDatabasePath, dbPath, j_)) + if (getSingleSection(secConfig, "database_path", dbPath, j_)) { boost::filesystem::path const p(dbPath); - legacy(Sections::kDatabasePath, boost::filesystem::absolute(p).string()); + legacy("database_path", boost::filesystem::absolute(p).string()); } } std::string strTemp; - if (getSingleSection(secConfig, Sections::kNetworkId, strTemp, j_)) + if (getSingleSection(secConfig, SECTION_NETWORK_ID, strTemp, j_)) { if (strTemp == "main") { @@ -535,45 +536,43 @@ Config::loadFromString(std::string const& fileContents) } } - if (getSingleSection(secConfig, Sections::kPeerPrivate, strTemp, j_)) + if (getSingleSection(secConfig, SECTION_PEER_PRIVATE, strTemp, j_)) peerPrivate = beast::lexicalCastThrow(strTemp); - if (getSingleSection(secConfig, Sections::kPeersMax, strTemp, j_)) + if (getSingleSection(secConfig, SECTION_PEERS_MAX, strTemp, j_)) { peersMax = beast::lexicalCastThrow(strTemp); } else { std::optional peersInMaxOpt{}; - if (getSingleSection(secConfig, Sections::kPeersInMax, strTemp, j_)) + if (getSingleSection(secConfig, SECTION_PEERS_IN_MAX, strTemp, j_)) { peersInMaxOpt = beast::lexicalCastThrow(strTemp); if (*peersInMaxOpt > 1000) { - Throw( - std::string("Invalid value specified in [") + Sections::kPeersInMax + - "] section; the value must be less or equal than 1000"); + Throw("Invalid value specified in [" SECTION_PEERS_IN_MAX + "] section; the value must be less or equal than 1000"); } } std::optional peersOutMaxOpt{}; - if (getSingleSection(secConfig, Sections::kPeersOutMax, strTemp, j_)) + if (getSingleSection(secConfig, SECTION_PEERS_OUT_MAX, strTemp, j_)) { peersOutMaxOpt = beast::lexicalCastThrow(strTemp); if (*peersOutMaxOpt < 10 || *peersOutMaxOpt > 1000) { - Throw( - std::string("Invalid value specified in [") + Sections::kPeersOutMax + - "] section; the value must be in range 10-1000"); + Throw("Invalid value specified in [" SECTION_PEERS_OUT_MAX + "] section; the value must be in range 10-1000"); } } // if one section is configured then the other must be configured too if ((peersInMaxOpt && !peersOutMaxOpt) || (peersOutMaxOpt && !peersInMaxOpt)) { - Throw( - std::string("Both sections [") + Sections::kPeersInMax + "]" + " and [" + - Sections::kPeersOutMax + "] must be configured"); + Throw("Both sections [" SECTION_PEERS_IN_MAX + "]" + "and [" SECTION_PEERS_OUT_MAX "] must be configured"); } if (peersInMaxOpt && peersOutMaxOpt) @@ -583,7 +582,7 @@ Config::loadFromString(std::string const& fileContents) } } - if (getSingleSection(secConfig, Sections::kNodeSize, strTemp, j_)) + if (getSingleSection(secConfig, SECTION_NODE_SIZE, strTemp, j_)) { if (boost::iequals(strTemp, "tiny")) { @@ -611,19 +610,19 @@ Config::loadFromString(std::string const& fileContents) } } - if (getSingleSection(secConfig, Sections::kSigningSupport, strTemp, j_)) + if (getSingleSection(secConfig, SECTION_SIGNING_SUPPORT, strTemp, j_)) signingEnabled_ = beast::lexicalCastThrow(strTemp); - if (getSingleSection(secConfig, Sections::kElbSupport, strTemp, j_)) + if (getSingleSection(secConfig, SECTION_ELB_SUPPORT, strTemp, j_)) elbSupport = beast::lexicalCastThrow(strTemp); - getSingleSection(secConfig, Sections::kSslVerifyFile, sslVerifyFile, j_); - getSingleSection(secConfig, Sections::kSslVerifyDir, sslVerifyDir, j_); + getSingleSection(secConfig, SECTION_SSL_VERIFY_FILE, sslVerifyFile, j_); + getSingleSection(secConfig, SECTION_SSL_VERIFY_DIR, sslVerifyDir, j_); - if (getSingleSection(secConfig, Sections::kSslVerify, strTemp, j_)) + if (getSingleSection(secConfig, SECTION_SSL_VERIFY, strTemp, j_)) sslVerify = beast::lexicalCastThrow(strTemp); - if (getSingleSection(secConfig, Sections::kRelayValidations, strTemp, j_)) + if (getSingleSection(secConfig, SECTION_RELAY_VALIDATIONS, strTemp, j_)) { if (boost::iequals(strTemp, "all")) { @@ -639,13 +638,12 @@ Config::loadFromString(std::string const& fileContents) } else { - Throw( - std::string("Invalid value specified in [") + Sections::kRelayValidations + - "] section"); + Throw("Invalid value specified in [" SECTION_RELAY_VALIDATIONS + "] section"); } } - if (getSingleSection(secConfig, Sections::kRelayProposals, strTemp, j_)) + if (getSingleSection(secConfig, SECTION_RELAY_PROPOSALS, strTemp, j_)) { if (boost::iequals(strTemp, "all")) { @@ -661,30 +659,28 @@ Config::loadFromString(std::string const& fileContents) } else { - Throw( - std::string("Invalid value specified in [") + Sections::kRelayProposals + - "] section"); + Throw("Invalid value specified in [" SECTION_RELAY_PROPOSALS + "] section"); } } - if (exists(Sections::kValidationSeed) && exists(Sections::kValidatorToken)) + if (exists(SECTION_VALIDATION_SEED) && exists(SECTION_VALIDATOR_TOKEN)) { - Throw( - std::string("Cannot have both [") + Sections::kValidationSeed + "] and [" + - Sections::kValidatorToken + "] config sections"); + Throw("Cannot have both [" SECTION_VALIDATION_SEED + "] and [" SECTION_VALIDATOR_TOKEN "] config sections"); } - if (getSingleSection(secConfig, Sections::kNetworkQuorum, strTemp, j_)) + if (getSingleSection(secConfig, SECTION_NETWORK_QUORUM, strTemp, j_)) networkQuorum = beast::lexicalCastThrow(strTemp); - fees = setupFeeVote(section(Sections::kVoting)); + fees = setupFeeVote(section("voting")); /* [fee_default] is documented in the example config files as useful for * things like offline transaction signing. Until that's completely * deprecated, allow it to override the [voting] section. */ - if (getSingleSection(secConfig, Sections::kFeeDefault, strTemp, j_)) + if (getSingleSection(secConfig, SECTION_FEE_DEFAULT, strTemp, j_)) fees.referenceFee = beast::lexicalCastThrow(strTemp); - if (getSingleSection(secConfig, Sections::kLedgerHistory, strTemp, j_)) + if (getSingleSection(secConfig, SECTION_LEDGER_HISTORY, strTemp, j_)) { if (boost::iequals(strTemp, "full")) { @@ -700,7 +696,7 @@ Config::loadFromString(std::string const& fileContents) } } - if (getSingleSection(secConfig, Sections::kFetchDepth, strTemp, j_)) + if (getSingleSection(secConfig, SECTION_FETCH_DEPTH, strTemp, j_)) { if (boost::iequals(strTemp, "none")) { @@ -718,80 +714,88 @@ Config::loadFromString(std::string const& fileContents) fetchDepth = std::max(fetchDepth, 10); } - // By default, validators don't have pathfinding enabled, unless it is - // explicitly requested by the server's admin. - if (exists(Sections::kValidationSeed) || exists(Sections::kValidatorToken)) - pathSearchMax = 0; - - if (getSingleSection(secConfig, Sections::kPathSearchOld, strTemp, j_)) - pathSearchOld = beast::lexicalCastThrow(strTemp); - if (getSingleSection(secConfig, Sections::kPathSearch, strTemp, j_)) - pathSearch = beast::lexicalCastThrow(strTemp); - if (getSingleSection(secConfig, Sections::kPathSearchFast, strTemp, j_)) - pathSearchFast = beast::lexicalCastThrow(strTemp); - if (getSingleSection(secConfig, Sections::kPathSearchMax, strTemp, j_)) - pathSearchMax = beast::lexicalCastThrow(strTemp); - - if (getSingleSection(secConfig, Sections::kDebugLogfile, strTemp, j_)) + if (getSingleSection(secConfig, SECTION_PATH_SEARCH, strTemp, j_)) + pathSearch = beast::lexicalCastThrow(strTemp) != 0; + if (getSingleSection(secConfig, SECTION_PATH_WORKERS, strTemp, j_)) + { + pathWorkers = beast::lexicalCastThrow(strTemp); + + if (pathWorkers < 2) + { + Throw("Invalid " SECTION_PATH_WORKERS + ": must be greater than or equal to 2."); + } + } + + if (getSingleSection(secConfig, SECTION_DEBUG_LOGFILE, strTemp, j_)) debugLogfile_ = strTemp; - if (getSingleSection(secConfig, Sections::kSweepInterval, strTemp, j_)) + if (getSingleSection(secConfig, SECTION_SWEEP_INTERVAL, strTemp, j_)) { sweepInterval = beast::lexicalCastThrow(strTemp); if (sweepInterval < 10 || sweepInterval > 600) { - Throw( - std::string("Invalid ") + Sections::kSweepInterval + - ": must be between 10 and 600 inclusive"); + Throw("Invalid " SECTION_SWEEP_INTERVAL + ": must be between 10 and 600 inclusive"); } } - if (getSingleSection(secConfig, Sections::kWorkers, strTemp, j_)) + if (getSingleSection(secConfig, SECTION_WORKERS, strTemp, j_)) { workers = beast::lexicalCastThrow(strTemp); if (workers < 1 || workers > 1024) { - Throw( - std::string("Invalid ") + Sections::kWorkers + - ": must be between 1 and 1024 inclusive."); + Throw("Invalid " SECTION_WORKERS + ": must be between 1 and 1024 inclusive."); } } - if (getSingleSection(secConfig, Sections::kIoWorkers, strTemp, j_)) + auto const effectiveWorkers = + Config::computeEffectiveWorkers(standalone(), forceMultiThread, workers, nodeSize); + + auto const maxUpdatePfLimit = std::max(2, (effectiveWorkers * 3) / 4); + if (pathWorkers > maxUpdatePfLimit) + { + Throw(boost::str( + boost::format( + "Invalid %1%: configured value %2% exceeds maximum %3% " + "(3/4 of effective job queue workers = %4%, minimum maximum of 2).") % + SECTION_PATH_WORKERS % pathWorkers % maxUpdatePfLimit % effectiveWorkers)); + } + + if (getSingleSection(secConfig, SECTION_IO_WORKERS, strTemp, j_)) { ioWorkers = beast::lexicalCastThrow(strTemp); if (ioWorkers < 1 || ioWorkers > 1024) { - Throw( - std::string("Invalid ") + Sections::kIoWorkers + - ": must be between 1 and 1024 inclusive."); + Throw("Invalid " SECTION_IO_WORKERS + ": must be between 1 and 1024 inclusive."); } } - if (getSingleSection(secConfig, Sections::kPrefetchWorkers, strTemp, j_)) + if (getSingleSection(secConfig, SECTION_PREFETCH_WORKERS, strTemp, j_)) { prefetchWorkers = beast::lexicalCastThrow(strTemp); if (prefetchWorkers < 1 || prefetchWorkers > 1024) { - Throw( - std::string("Invalid ") + Sections::kPrefetchWorkers + - ": must be between 1 and 1024 inclusive."); + Throw("Invalid " SECTION_PREFETCH_WORKERS + ": must be between 1 and 1024 inclusive."); } } - if (getSingleSection(secConfig, Sections::kCompression, strTemp, j_)) + if (getSingleSection(secConfig, SECTION_COMPRESSION, strTemp, j_)) compression = beast::lexicalCastThrow(strTemp); - if (getSingleSection(secConfig, Sections::kLedgerReplay, strTemp, j_)) + if (getSingleSection(secConfig, SECTION_LEDGER_REPLAY, strTemp, j_)) ledgerReplay = beast::lexicalCastThrow(strTemp); - if (exists(Sections::kReduceRelay)) + if (exists(SECTION_REDUCE_RELAY)) { - auto sec = section(Sections::kReduceRelay); + auto sec = section(SECTION_REDUCE_RELAY); /** * ////////////////// !!TEMPORARY CODE BLOCK!! //////////////////////// @@ -801,23 +805,22 @@ Config::loadFromString(std::string const& fileContents) // is the default algorithm, it must be replaced with: // // VP_REDUCE_RELAY_BASE_SQUELCH_ENABLE = // // sec.value_or("vp_base_squelch_enable", true); // - if (sec.exists(Keys::kVpBaseSquelchEnable) && sec.exists(Keys::kVpEnable)) + if (sec.exists("vp_base_squelch_enable") && sec.exists("vp_enable")) { - Throw( - std::string("Invalid ") + Sections::kReduceRelay + - " cannot specify both vp_base_squelch_enable and vp_enable " - "options. " - "vp_enable was deprecated and replaced by " - "vp_base_squelch_enable"); + Throw("Invalid " SECTION_REDUCE_RELAY + " cannot specify both vp_base_squelch_enable and vp_enable " + "options. " + "vp_enable was deprecated and replaced by " + "vp_base_squelch_enable"); } - if (sec.exists(Keys::kVpBaseSquelchEnable)) + if (sec.exists("vp_base_squelch_enable")) { - vpReduceRelayBaseSquelchEnable = sec.valueOr(Keys::kVpBaseSquelchEnable, false); + vpReduceRelayBaseSquelchEnable = sec.valueOr("vp_base_squelch_enable", false); } - else if (sec.exists(Keys::kVpEnable)) + else if (sec.exists("vp_enable")) { - vpReduceRelayBaseSquelchEnable = sec.valueOr(Keys::kVpEnable, false); + vpReduceRelayBaseSquelchEnable = sec.valueOr("vp_enable", false); } else { @@ -833,108 +836,102 @@ Config::loadFromString(std::string const& fileContents) // Temporary squelching config for the peers selected as a source of // // validator messages. The config must be removed once squelching is // // made the default routing algorithm. // - vpReduceRelaySquelchMaxSelectedPeers = sec.valueOr(Keys::kVpBaseSquelchMaxSelectedPeers, 5); + vpReduceRelaySquelchMaxSelectedPeers = sec.valueOr("vp_base_squelch_max_selected_peers", 5); if (vpReduceRelaySquelchMaxSelectedPeers < 3) { - Throw( - std::string("Invalid ") + Sections::kReduceRelay + - " vp_base_squelch_max_selected_peers must be " - "greater than or equal to 3"); + Throw("Invalid " SECTION_REDUCE_RELAY + " vp_base_squelch_max_selected_peers must be " + "greater than or equal to 3"); } /** * ////////////// !!END OF TEMPORARY CODE BLOCK!! ///////////////////// */ - txReduceRelayEnable = sec.valueOr(Keys::kTxEnable, false); - txReduceRelayMetrics = sec.valueOr(Keys::kTxMetrics, false); - txReduceRelayMinPeers = sec.valueOr(Keys::kTxMinPeers, 20); - txRelayPercentage = sec.valueOr(Keys::kTxRelayPercentage, 25); + txReduceRelayEnable = sec.valueOr("tx_enable", false); + txReduceRelayMetrics = sec.valueOr("tx_metrics", false); + txReduceRelayMinPeers = sec.valueOr("tx_min_peers", 20); + txRelayPercentage = sec.valueOr("tx_relay_percentage", 25); if (txRelayPercentage < 10 || txRelayPercentage > 100 || txReduceRelayMinPeers < 10) { - Throw( - std::string("Invalid ") + Sections::kReduceRelay + - ", tx_min_peers must be greater than or equal to 10" - ", tx_relay_percentage must be greater than or equal to 10 " - "and less than or equal to 100"); + Throw("Invalid " SECTION_REDUCE_RELAY + ", tx_min_peers must be greater than or equal to 10" + ", tx_relay_percentage must be greater than or equal to 10 " + "and less than or equal to 100"); } } - if (getSingleSection(secConfig, Sections::kMaxTransactions, strTemp, j_)) + if (getSingleSection(secConfig, SECTION_MAX_TRANSACTIONS, strTemp, j_)) { maxTransactions = std::clamp(beast::lexicalCastThrow(strTemp), kMinJobQueueTx, kMaxJobQueueTx); } - if (getSingleSection(secConfig, Sections::kServerDomain, strTemp, j_)) + if (getSingleSection(secConfig, SECTION_SERVER_DOMAIN, strTemp, j_)) { if (!isProperlyFormedTomlDomain(strTemp)) { Throw( - std::string("Invalid ") + Sections::kServerDomain + + "Invalid " SECTION_SERVER_DOMAIN ": the domain name does not appear to meet the requirements."); } serverDomain = strTemp; } - if (exists(Sections::kOverlay)) + if (exists(SECTION_OVERLAY)) { - auto const sec = section(Sections::kOverlay); + auto const sec = section(SECTION_OVERLAY); using namespace std::chrono; try { - if (auto val = sec.get(Keys::kMaxUnknownTime)) + if (auto val = sec.get("max_unknown_time")) maxUnknownTime = seconds{beast::lexicalCastThrow(*val)}; } catch (...) { - Throw( - std::string("Invalid value 'max_unknown_time' in ") + Sections::kOverlay + - ": must be of the form '' representing seconds."); + Throw("Invalid value 'max_unknown_time' in " SECTION_OVERLAY + ": must be of the form '' representing seconds."); } if (maxUnknownTime < seconds{300} || maxUnknownTime > seconds{1800}) { Throw( - std::string("Invalid value 'max_unknown_time' in ") + Sections::kOverlay + + "Invalid value 'max_unknown_time' in " SECTION_OVERLAY ": the time must be between 300 and 1800 seconds, inclusive."); } try { - if (auto val = sec.get(Keys::kMaxDivergedTime)) + if (auto val = sec.get("max_diverged_time")) maxDivergedTime = seconds{beast::lexicalCastThrow(*val)}; } catch (...) { - Throw( - std::string("Invalid value 'max_diverged_time' in ") + Sections::kOverlay + - ": must be of the form '' representing seconds."); + Throw("Invalid value 'max_diverged_time' in " SECTION_OVERLAY + ": must be of the form '' representing seconds."); } if (maxDivergedTime < seconds{60} || maxDivergedTime > seconds{900}) { - Throw( - std::string("Invalid value 'max_diverged_time' in ") + Sections::kOverlay + - ": the time must be between 60 and 900 seconds, inclusive."); + Throw("Invalid value 'max_diverged_time' in " SECTION_OVERLAY + ": the time must be between 60 and 900 seconds, inclusive."); } } - if (getSingleSection(secConfig, Sections::kAmendmentMajorityTime, strTemp, j_)) + if (getSingleSection(secConfig, SECTION_AMENDMENT_MAJORITY_TIME, strTemp, j_)) { using namespace std::chrono; boost::regex const re(R"(^\s*(\d+)\s*(minutes|hours|days|weeks)\s*(\s+.*)?$)"); boost::smatch match; if (!boost::regex_match(strTemp, match, re)) { - Throw( - std::string("Invalid ") + Sections::kAmendmentMajorityTime + - ", must be: [0-9]+ [minutes|hours|days|weeks]"); + Throw("Invalid " SECTION_AMENDMENT_MAJORITY_TIME + ", must be: [0-9]+ [minutes|hours|days|weeks]"); } - auto const duration = beast::lexicalCastThrow(match[1].str()); + std::uint32_t const duration = beast::lexicalCastThrow(match[1].str()); if (boost::iequals(match[2], "minutes")) { @@ -955,14 +952,13 @@ Config::loadFromString(std::string const& fileContents) if (amendmentMajorityTime < minutes(15)) { - Throw( - std::string("Invalid ") + Sections::kAmendmentMajorityTime + - ", the minimum amount of time an amendment must hold a " - "majority is 15 minutes"); + Throw("Invalid " SECTION_AMENDMENT_MAJORITY_TIME + ", the minimum amount of time an amendment must hold a " + "majority is 15 minutes"); } } - if (getSingleSection(secConfig, Sections::kBetaRpcApi, strTemp, j_)) + if (getSingleSection(secConfig, SECTION_BETA_RPC_API, strTemp, j_)) betaRpcApi = beast::lexicalCastThrow(strTemp); // Do not load trusted validator configuration for standalone mode @@ -978,14 +974,14 @@ Config::loadFromString(std::string const& fileContents) // if we can't find it. boost::filesystem::path validatorsFile; - if (getSingleSection(secConfig, Sections::kValidatorsFile, strTemp, j_)) + if (getSingleSection(secConfig, SECTION_VALIDATORS_FILE, strTemp, j_)) { validatorsFile = strTemp; if (validatorsFile.empty()) { - Throw( - std::string("Invalid path specified in [") + Sections::kValidatorsFile + "]"); + Throw("Invalid path specified in [" SECTION_VALIDATORS_FILE + "]"); } if (!validatorsFile.is_absolute() && !configDir.empty()) @@ -994,7 +990,7 @@ Config::loadFromString(std::string const& fileContents) if (!boost::filesystem::exists(validatorsFile)) { Throw( - std::string("The file specified in [") + Sections::kValidatorsFile + + "The file specified in [" SECTION_VALIDATORS_FILE "] " "does not exist: " + validatorsFile.string()); @@ -1004,8 +1000,8 @@ Config::loadFromString(std::string const& fileContents) !boost::filesystem::is_symlink(validatorsFile)) { Throw( - std::string("Invalid file specified in [") + Sections::kValidatorsFile + - "]: " + validatorsFile.string()); + "Invalid file specified in [" SECTION_VALIDATORS_FILE "]: " + + validatorsFile.string()); } } else if (!configDir.empty()) @@ -1014,9 +1010,13 @@ 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 (!boost::filesystem::exists(validatorsFile)) + { + validatorsFile.clear(); + } + else if ( + !boost::filesystem::is_regular_file(validatorsFile) && + !boost::filesystem::is_symlink(validatorsFile)) { validatorsFile.clear(); } @@ -1038,44 +1038,41 @@ Config::loadFromString(std::string const& fileContents) auto iniFile = parseIniFile(data, true); - auto entries = getIniFileSection(iniFile, Sections::kValidators); + auto entries = getIniFileSection(iniFile, SECTION_VALIDATORS); if (entries != nullptr) - section(Sections::kValidators).append(*entries); + section(SECTION_VALIDATORS).append(*entries); - auto valKeyEntries = getIniFileSection(iniFile, Sections::kValidatorKeys); + auto valKeyEntries = getIniFileSection(iniFile, SECTION_VALIDATOR_KEYS); if (valKeyEntries != nullptr) - section(Sections::kValidatorKeys).append(*valKeyEntries); + section(SECTION_VALIDATOR_KEYS).append(*valKeyEntries); - auto valSiteEntries = getIniFileSection(iniFile, Sections::kValidatorListSites); + auto valSiteEntries = getIniFileSection(iniFile, SECTION_VALIDATOR_LIST_SITES); if (valSiteEntries != nullptr) - section(Sections::kValidatorListSites).append(*valSiteEntries); + section(SECTION_VALIDATOR_LIST_SITES).append(*valSiteEntries); - auto valListKeys = getIniFileSection(iniFile, Sections::kValidatorListKeys); + auto valListKeys = getIniFileSection(iniFile, SECTION_VALIDATOR_LIST_KEYS); if (valListKeys != nullptr) - section(Sections::kValidatorListKeys).append(*valListKeys); + section(SECTION_VALIDATOR_LIST_KEYS).append(*valListKeys); - auto valListThreshold = getIniFileSection(iniFile, Sections::kValidatorListThreshold); + auto valListThreshold = getIniFileSection(iniFile, SECTION_VALIDATOR_LIST_THRESHOLD); if (valListThreshold != nullptr) - section(Sections::kValidatorListThreshold).append(*valListThreshold); + section(SECTION_VALIDATOR_LIST_THRESHOLD).append(*valListThreshold); if ((entries == nullptr) && (valKeyEntries == nullptr) && (valListKeys == nullptr)) { Throw( - std::string("The file specified in [") + Sections::kValidatorsFile + + "The file specified in [" SECTION_VALIDATORS_FILE "] " - "does not contain a [" + - Sections::kValidators + + "does not contain a [" SECTION_VALIDATORS "], " - "[" + - Sections::kValidatorKeys + + "[" SECTION_VALIDATOR_KEYS "] or " - "[" + - Sections::kValidatorListKeys + + "[" SECTION_VALIDATOR_LIST_KEYS "]" " section: " + validatorsFile.string()); @@ -1083,7 +1080,7 @@ Config::loadFromString(std::string const& fileContents) } validatorListThreshold = [&]() -> std::optional { - auto const& listThreshold = section(Sections::kValidatorListThreshold); + auto const& listThreshold = section(SECTION_VALIDATOR_LIST_THRESHOLD); if (listThreshold.lines().empty()) { return std::nullopt; @@ -1096,38 +1093,34 @@ Config::loadFromString(std::string const& fileContents) { return std::nullopt; // NOTE: Explicitly ask for computed } - if (listThreshold > section(Sections::kValidatorListKeys).values().size()) + if (listThreshold > section(SECTION_VALIDATOR_LIST_KEYS).values().size()) { Throw( - std::string( - "Value in config section " - "[") + - Sections::kValidatorListThreshold + + "Value in config section " + "[" SECTION_VALIDATOR_LIST_THRESHOLD "] exceeds the number of configured list keys"); } return listThreshold; } Throw( - std::string( - "Config section " - "[") + - Sections::kValidatorListThreshold + "] should contain single value only"); + "Config section " + "[" SECTION_VALIDATOR_LIST_THRESHOLD "] should contain single value only"); }(); // Consolidate [validator_keys] and [validators] - section(Sections::kValidators).append(section(Sections::kValidatorKeys).lines()); + section(SECTION_VALIDATORS).append(section(SECTION_VALIDATOR_KEYS).lines()); - if (!section(Sections::kValidatorListSites).lines().empty() && - section(Sections::kValidatorListKeys).lines().empty()) + if (!section(SECTION_VALIDATOR_LIST_SITES).lines().empty() && + section(SECTION_VALIDATOR_LIST_KEYS).lines().empty()) { Throw( - "[" + std::string(Sections::kValidatorListKeys) + "] config section is missing"); + "[" + std::string(SECTION_VALIDATOR_LIST_KEYS) + "] config section is missing"); } } { - auto const part = section(Sections::kFeatures); + auto const part = section("features"); for (auto const& s : part.values()) { if (auto const f = getRegisteredFeature(s)) @@ -1209,15 +1202,15 @@ setupFeeVote(Section const& section) FeeSetup setup; { std::uint64_t temp = 0; - if (set(temp, Keys::kReferenceFee, section) && + if (set(temp, "reference_fee", section) && temp <= std::numeric_limits::max()) setup.referenceFee = temp; } { std::uint32_t temp = 0; - if (set(temp, Keys::kAccountReserve, section)) + if (set(temp, "account_reserve", section)) setup.accountReserve = temp; - if (set(temp, Keys::kOwnerReserve, section)) + if (set(temp, "owner_reserve", section)) setup.ownerReserve = temp; } return setup; @@ -1230,7 +1223,7 @@ setupDatabaseCon(Config const& c, std::optional j) setup.startUp = c.startUp; setup.standAlone = c.standalone(); - setup.dataDir = c.legacy(Sections::kDatabasePath); + setup.dataDir = c.legacy("database_path"); if (!setup.standAlone && setup.dataDir.empty()) { Throw("database_path must be set."); @@ -1238,7 +1231,7 @@ setupDatabaseCon(Config const& c, std::optional j) if (!setup.globalPragma) { - auto const& sqlite = c.section(Sections::kSqlite); + auto const& sqlite = c.section("sqlite"); auto result = std::make_unique>(); result->reserve(3); @@ -1355,11 +1348,11 @@ setupDatabaseCon(Config const& c, std::optional j) // TX Pragma int64_t pageSize = 4096; int64_t journalSizeLimit = 1582080; - if (c.exists(Sections::kSqlite)) + if (c.exists("sqlite")) { - auto& s = c.section(Sections::kSqlite); - set(journalSizeLimit, Keys::kJournalSizeLimit, s); - set(pageSize, Keys::kPageSize, s); + auto& s = c.section("sqlite"); + set(journalSizeLimit, "journal_size_limit", s); + set(pageSize, "page_size", s); if (pageSize < 512 || pageSize > 65536) Throw("Invalid page_size. Must be between 512 and 65536."); @@ -1374,4 +1367,35 @@ setupDatabaseCon(Config const& c, std::optional j) return setup; } + +int +Config::computeEffectiveWorkers( + bool standalone, + bool forceMultiThread, + int workers, + std::size_t nodeSize) +{ + if (standalone && !forceMultiThread) + return 1; + + if (workers != 0) + return workers; + + auto count = static_cast(std::thread::hardware_concurrency()); + + if (nodeSize >= 4 && count >= 16) + { + count = 6 + std::min(count, 8); + } + else if (nodeSize >= 3 && count >= 8) + { + count = 4 + std::min(count, 6); + } + else + { + count = 2 + std::min(count, 4); + } + + return count; +} } // namespace xrpl diff --git a/src/xrpld/rpc/detail/GraphPathfinder.cpp b/src/xrpld/rpc/detail/GraphPathfinder.cpp new file mode 100644 index 00000000000..ca12f4af283 --- /dev/null +++ b/src/xrpld/rpc/detail/GraphPathfinder.cpp @@ -0,0 +1,1070 @@ +#include + +#include +#include +#include +#include + +#include +#include +#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 { + +//============================================================================== +// Construction +//============================================================================== + +GraphPathfinder::GraphPathfinder( + std::shared_ptr const& graph, + std::shared_ptr const& cache, + AccountID const& srcAccount, + AccountID const& dstAccount, + PathAsset const& srcPathAsset, + std::optional const& srcIssuer, + STAmount const& dstAmount, + std::optional const& /*srcAmount unused — derived from graph*/, + std::optional const& domain, + Application& app) + : graph_(graph) + , snap_(graph ? graph->snapshot() : nullptr) + , srcAccount_(srcAccount) + , dstAccount_(dstAccount) + , effectiveDst_(isXRP(dstAmount.getIssuer()) ? dstAccount : dstAmount.getIssuer()) + , dstAmount_(dstAmount) + , srcPathAsset_(srcPathAsset) + , srcIssuer_(srcIssuer) + , srcAmount_(srcPathAsset.visit( + [&](Currency const& currency) { + auto const& account = srcIssuer.value_or(isXRP(currency) ? xrpAccount() : srcAccount); + return STAmount(Issue{currency, account}, 1u, 0, true); + }, + [](MPTID const& mpt) { return STAmount(mpt, 1u, 0, true); })) + , convertAll_(convertAllCheck(dstAmount)) + , domain_(domain) + , ledger_(cache->getLedger()) + , cache_(cache) + , app_(app) + , j_(app.getJournal("GraphPathfinder")) +{ +} + +//============================================================================== +// findPaths +// +// Phase 1: run Yen's K-Shortest on the asset-exchange graph (microseconds). +// Phase 2: materialise each abstract path into a concrete STPath. +//============================================================================== + +bool +GraphPathfinder::findPaths(std::function const& continueCallback) +{ + if (!snap_ || !ledger_) + { + JLOG(j_.debug()) << "GraphPathfinder: no snapshot or ledger"; + return false; + } + + if (dstAmount_ == beast::kZero) + { + JLOG(j_.debug()) << "GraphPathfinder: zero destination amount"; + return false; + } + + if (srcAccount_ == dstAccount_ && srcPathAsset_ == dstAmount_.asset()) + { + JLOG(j_.debug()) << "GraphPathfinder: trivial same-account same-asset"; + return true; + } + + // Resolve source and destination assets. + Asset const srcAsset = srcPathAsset_.visit( + [&](Currency const& c) -> Asset { + AccountID const& iss = srcIssuer_.value_or(isXRP(c) ? xrpAccount() : srcAccount_); + return Issue{c, iss}; + }, + [](MPTID const& mpt) -> Asset { return MPTIssue{mpt}; }); + + Asset const dstAsset = dstAmount_.asset(); + + // Find abstract paths on the asset graph. + // kMaxK = 6: we want at most 6 paths for the subscriber. + static constexpr int kMaxK = 6; + + // Resolve graph vertex IDs for source and destination. + // + // The exact lookup by {currency, issuer} may fail for non-XRP sources when + // the sender's account is used as the issuer (e.g. when PathAsset drops the + // gateway issuer). For the source, fall back to a currency-only search + // constrained to issuers that the source account actually holds trust lines + // for — this prevents false-positive paths (e.g. treating g2["HKD"] as + // valid source when the account only holds g1["HKD"]). + // + // For the destination we do an unconstrained currency-only fallback: the + // suffix logic in materialise() handles the trust-line hops from the OB + // gateway to the actual destination account, so we must include all OB + // vertices for that currency. + auto findVIDsSrc = [&](Asset const& asset) -> std::vector { + auto it = snap_->index.find(asset); + if (it != snap_->index.end()) + return {it->second}; + + if (asset.holds() && !isXRP(asset.get().currency)) + { + Currency const& cur = asset.get().currency; + + // Collect issuers that srcAccount_ has trust lines for. + hash_set validIssuers; + if (auto const lines = cache_->getRippleLines(srcAccount_, LineDirection::Outgoing)) + { + for (auto const& line : *lines) + { + if (line.getBalance().get().currency != cur) + continue; + validIssuers.insert(line.getAccountIDPeer()); + } + } + + std::vector vids; + for (auto const& [a, vid] : snap_->index) + { + if (!a.holds() || isXRP(a.get().currency)) + continue; + if (a.get().currency != cur) + continue; + if (!validIssuers.empty() && !validIssuers.contains(a.get().account)) + continue; + vids.push_back(vid); + } + return vids; + } + return {}; + }; + + auto findVIDsDst = [&](Asset const& asset) -> std::vector { + auto it = snap_->index.find(asset); + if (it != snap_->index.end()) + return {it->second}; + + // Unconstrained currency-only fallback for destination — materialise() + // suffix adds the intermediate trust-line hops to dstAccount_. + if (asset.holds() && !isXRP(asset.get().currency)) + { + Currency const& cur = asset.get().currency; + std::vector vids; + for (auto const& [a, vid] : snap_->index) + { + if (a.holds() && !isXRP(a.get().currency) && + a.get().currency == cur) + { + vids.push_back(vid); + } + } + return vids; + } + return {}; + }; + + auto const srcVIDs = findVIDsSrc(srcAsset); + auto const dstVIDs = findVIDsDst(dstAsset); + + // When the effective destination is the sender AND the source/destination + // assets are the same (a genuine self-loop on a single asset), Yen's would + // search for a path from a vertex back to itself — skip it. Cross-currency + // self-payments (e.g. bob holds XTS@gw and wants XXX@gw) still need + // PayGraph traversal to discover the gateway-side conversion route. + bool const repayToSelf = !isXRP(dstAmount_.asset()) && (effectiveDst_ == srcAccount_) && + (srcPathAsset_ == dstAmount_.asset()); + + if (repayToSelf || srcVIDs.empty() || dstVIDs.empty()) + { + JLOG(j_.debug()) << "GraphPathfinder: " + << (repayToSelf ? "repay-to-self, skipping PayGraph" + : "src or dst asset not in graph"); + } + else + { + // Yen's k-shortest paths between every (srcVID, dstVID) pair, sorted + // by ascending cumQuality. Materialise into concrete STPaths and + // dedupe. + // + // Light oversample: rankPaths() runs expensive rippleCalculate per + // candidate (and AMM overflows throw FlowException with stack dumps). + // Keep the probe set small so startup/updateAll stays bounded. + static constexpr int kOversample = 2; + std::vector candidates; + for (PayGraph::VID const vSrc : srcVIDs) + { + for (PayGraph::VID const vDst : dstVIDs) + { + if (vSrc == vDst) + continue; + auto paths = + PayGraph::kShortestPaths(*snap_, vSrc, vDst, kMaxK * kOversample, dstAmount_); + for (auto& p : paths) + candidates.push_back(std::move(p)); + } + } + + std::ranges::stable_sort( + candidates, [](auto const& a, auto const& b) { return a.cumQuality < b.cumQuality; }); + + int accepted = 0; + // Materialise at most kMaxK concrete paths; ranking will probe these. + int const acceptCap = kMaxK; + for (auto const& ap : candidates) + { + if (accepted >= acceptCap) + break; + if (continueCallback && !continueCallback()) + return !completePaths_.empty(); + + // path_find only: skip abstract paths that use a hop known to throw + // FlowException from a broken AMM (avoids re-paying Throw cost). + // Evaluated solely against PathRequestManager — never consensus. + if (assetPathTouchesFailedAmm(ap)) + continue; + + auto concrete = materialise(ap); + if (!concrete || concrete->empty()) + continue; + + bool dup = false; + for (auto const& existing : completePaths_) + { + if (existing == *concrete) + { + dup = true; + break; + } + } + if (dup) + continue; + + completePaths_.pushBack(*concrete); + ++accepted; + } + + JLOG(j_.debug()) << "GraphPathfinder: " << candidates.size() + << " abstract paths considered, " << accepted + << " concrete paths accepted (cap=" << acceptCap << ")"; + } + + // Also try the direct (no-bridge) path: src -> dst over trust lines. + // This covers same-currency IOUs (e.g. sender holds USD from issuer A, + // dest wants USD from issuer A) that don't go through any order book. + { + STPath const directPath; + // For non-XRP to non-XRP same-currency: no intermediate nodes needed; + // the path engine will use the default path. We still emit an empty + // path as a hint so rippleCalculate considers it. + // (An empty completePath_ entry signals "try the default path".) + if (srcPathAsset_ == dstAmount_.asset() && !isXRP(srcPathAsset_)) + { + // Direct ripple: no path nodes needed. + completePaths_.pushBack(directPath); + } + } + + // Phase 2: trust-line rippling paths. + // + // PayGraph only models order-book and AMM edges. Pure trust-line rippling + // paths (e.g. alice → gateway → bob all in the same currency) require a + // separate discovery step. + // + // Only applicable when the source payment asset is the same IOU currency + // as the destination — i.e. the sender already holds the target IOU and + // can ripple it through trust-line intermediaries. When the source asset + // is XRP or a different currency, the payment must cross an order book + // first; trust-line paths would be invalid. + // + // We use a bidirectional fan-out: load trust lines only for srcAccount_ and + // dstAccount_ (typically 5–20 lines each for regular users), then intersect + // their peer sets to find 1-hop intermediaries. For 2-hop paths we probe + // specific (A, B) pairs using O(log N) SHAMap point-lookups — we never + // iterate over a gateway account's trust lines, which can number in the + // millions. + bool const srcIsTargetCcy = srcPathAsset_.holds() && + !isXRP(srcPathAsset_.get()) && dstAmount_.asset().holds() && + !isXRP(dstAmount_.asset().get().currency) && + srcPathAsset_.get() == dstAmount_.asset().get().currency; + + if (srcIsTargetCcy) + { + Currency const& targetCcy = dstAmount_.asset().get().currency; + + auto const srcLines = cache_->getRippleLines(srcAccount_, LineDirection::Outgoing); + auto const dstLines = cache_->getRippleLines(dstAccount_, LineDirection::Outgoing); + + if (srcLines && dstLines) + { + // Collect src peers in targetCcy (exclude frozen lines and the + // src/dst accounts themselves). + hash_set srcPeers; + for (auto const& line : *srcLines) + { + if (line.getBalance().get().currency != targetCcy) + continue; + if (line.getFreeze() || line.getDeepFreeze()) + continue; + AccountID const& peer = line.getAccountIDPeer(); + if (peer != srcAccount_ && peer != dstAccount_) + srcPeers.insert(peer); + } + + // Collect dst peers in targetCcy. + hash_set dstPeers; + for (auto const& line : *dstLines) + { + if (line.getBalance().get().currency != targetCcy) + continue; + if (line.getFreeze() || line.getDeepFreeze()) + continue; + AccountID const& peer = line.getAccountIDPeer(); + if (peer != srcAccount_ && peer != dstAccount_) + dstPeers.insert(peer); + } + + // 1-hop: src → I → dst + // I must be in both srcPeers and dstPeers. + for (auto const& i : srcPeers) + { + if (continueCallback && !continueCallback()) + return !completePaths_.empty(); + if (dstPeers.contains(i)) + { + STPath path; + path.emplaceBack(STPathElement::TypeAccount, i, xrpCurrency(), xrpAccount()); + completePaths_.pushBack(path); + } + } + + // 2-hop: src → A → B → dst + // A ∈ srcPeers, B ∈ dstPeers, A ≠ B. + // We probe the A–B trust line via a single SHAMap point-lookup + // (O(log N)) rather than loading A's full trust-line list. + // Cap at kMaxHop2Probes to bound cost when src or dst is itself a + // gateway with many same-currency peers. + [&] { + static constexpr std::size_t kMaxHop2Probes = 100; + std::size_t probes = 0; + for (auto const& a : srcPeers) + { + if (continueCallback && !continueCallback()) + return; + for (auto const& b : dstPeers) + { + if (probes++ >= kMaxHop2Probes) + return; + if (a == b) + continue; + if (ledger_->read(keylet::trustLine(a, b, targetCcy))) + { + STPath path; + path.emplaceBack( + STPathElement::TypeAccount, a, xrpCurrency(), xrpAccount()); + path.emplaceBack( + STPathElement::TypeAccount, b, xrpCurrency(), xrpAccount()); + completePaths_.pushBack(path); + } + } + } + }(); + + // 3-hop: src → A → C → B → dst + // A ∈ srcPeers; C is a peer of A (loaded from cache, capped to + // avoid expanding large gateway trust-line lists); B ∈ dstPeers. + // This handles the common "market maker" topology where a liquidity + // provider C holds trust lines with two different gateways A and B. + // kMaxGatewayPeers caps the number of A's peers we expand; if A + // has more lines than this it is likely a large gateway and we skip + // to avoid loading millions of trust lines into memory. + [&] { + static constexpr std::size_t kMaxGatewayPeers = 50; + static constexpr std::size_t kMaxHop3Probes = 200; + std::size_t probes = 0; + for (auto const& a : srcPeers) + { + if (continueCallback && !continueCallback()) + return; + auto const aLines = cache_->getRippleLines(a, LineDirection::Outgoing); + if (!aLines || aLines->size() > kMaxGatewayPeers) + continue; + for (auto const& aLine : *aLines) + { + if (aLine.getBalance().get().currency != targetCcy) + continue; + if (aLine.getFreeze() || aLine.getDeepFreeze()) + continue; + AccountID const& c = aLine.getAccountIDPeer(); + if (c == srcAccount_ || c == dstAccount_) + continue; + if (dstPeers.contains(c)) + continue; // already covered by 1-hop + for (auto const& b : dstPeers) + { + if (probes++ >= kMaxHop3Probes) + return; + if (c == b || c == a) + continue; + if (ledger_->read(keylet::trustLine(c, b, targetCcy))) + { + STPath path; + path.emplaceBack( + STPathElement::TypeAccount, a, xrpCurrency(), xrpAccount()); + path.emplaceBack( + STPathElement::TypeAccount, c, xrpCurrency(), xrpAccount()); + path.emplaceBack( + STPathElement::TypeAccount, b, xrpCurrency(), xrpAccount()); + completePaths_.pushBack(path); + } + } + } + } + }(); + } + } + + JLOG(j_.debug()) << "GraphPathfinder: " << completePaths_.size() << " concrete paths"; + return true; +} + +//============================================================================== +// materialise — convert an abstract AssetPath into a concrete STPath. +// +// An AssetPath is a sequence of asset vertices in the exchange graph: +// assetPath.vids = [srcAsset, bridge1, bridge2, ..., dstAsset] +// +// For each consecutive pair (A, B): +// • If A -> B is an OrderBook edge: emit an offer node (currency+issuer only, +// no account). The XRPL payment engine will fill in the best offer. +// • If A -> B is an AMM edge: same structure as an order book node; +// the engine resolves AMM vs offer automatically. +// +// The path does NOT include the source or destination accounts; those are +// implicit in the payment. +//============================================================================== + +std::optional +GraphPathfinder::materialise(PayGraph::AssetPath const& assetPath) +{ + if (assetPath.vids.size() < 2) + return std::nullopt; + + PayGraph::VID const firstVID = assetPath.vids.front(); + PayGraph::VID const lastVID = assetPath.vids.back(); + if (firstVID >= snap_->assets.size() || lastVID >= snap_->assets.size()) + return std::nullopt; + + STPath path; + + // Prefix: when the PayGraph path's first vertex is issued by a gateway G + // that differs from both the sender and the caller's explicit srcIssuer, + // add G as an account node. This arises when srcIssuer_ refers to the + // sender (fallback) but the actual OB offer is in G's IOU — rippleCalc + // needs G explicit to ripple through it. + // Do NOT add the prefix when the first vertex issuer matches srcIssuer_ + // (the caller already holds that gateway's IOU directly). + { + Asset const& srcAsset = snap_->assets[firstVID]; + if (srcAsset.holds() && !isXRP(srcAsset.get().currency)) + { + AccountID const& g = srcAsset.get().account; + AccountID const expectedIssuer = srcIssuer_.value_or(srcAccount_); + if (g != srcAccount_ && g != xrpAccount() && g != expectedIssuer) + path.emplaceBack(STPathElement::TypeAccount, g, xrpCurrency(), xrpAccount()); + } + } + + for (std::size_t i = 0; i + 1 < assetPath.vids.size(); ++i) + { + PayGraph::VID const vFrom = assetPath.vids[i]; + PayGraph::VID const vTo = assetPath.vids[i + 1]; + + if (vFrom >= snap_->assets.size() || vTo >= snap_->assets.size()) + return std::nullopt; + + Asset const& toAsset = snap_->assets[vTo]; + + bool const toIsXRP = isXRP(toAsset); + + // Find the edge kind between these two vertices. + PayGraph::EdgeKind kind = PayGraph::EdgeKind::OrderBook; + if (vFrom < snap_->adj.size()) + { + for (auto const& e : snap_->adj[vFrom]) + { + if (e.to == vTo) + { + kind = e.kind; + break; + } + } + } + + if (kind == PayGraph::EdgeKind::OrderBook) + { + // Offer-book crossing: emit a node that has only the receiving + // asset's currency and issuer (TypeCurrency | TypeIssuer). + // The account field is set to the xrpAccount() sentinel per XRPL + // convention for offer nodes. + if (toIsXRP) + { + // Receiving XRP from an order book: emit XRP offer node. + path.emplaceBack( + STPathElement::TypeCurrency, xrpAccount(), xrpCurrency(), xrpAccount()); + } + else if (toAsset.holds()) + { + // Receiving an MPT from an order book. + auto const& mptIssue = toAsset.get(); + path.emplaceBack( + STPathElement::TypeMpt | STPathElement::TypeIssuer, + xrpAccount(), + mptIssue.getMptID(), + mptIssue.getIssuer()); + } + else + { + // Receiving an IOU from an order book. + auto const& toIssue = toAsset.get(); + path.emplaceBack( + STPathElement::TypeCurrency | STPathElement::TypeIssuer, + xrpAccount(), + toIssue.currency, + toIssue.account); + } + } + else // AMM pool + { + // AMM crossing: same structure as order book for path purposes. + // The engine resolves AMM vs offer automatically. + if (!toIsXRP) + { + if (toAsset.holds()) + { + auto const& mptIssue = toAsset.get(); + path.emplaceBack( + STPathElement::TypeMpt | STPathElement::TypeIssuer, + xrpAccount(), + mptIssue.getMptID(), + mptIssue.getIssuer()); + } + else + { + auto const& toIssue = toAsset.get(); + path.emplaceBack( + STPathElement::TypeCurrency | STPathElement::TypeIssuer, + xrpAccount(), + toIssue.currency, + toIssue.account); + } + } + else + { + path.emplaceBack( + STPathElement::TypeCurrency, xrpAccount(), xrpCurrency(), xrpAccount()); + } + } + } + + if (path.empty()) + return std::nullopt; + + JLOG(j_.info()) << "GraphPathfinder::materialise src=" << toBase58(srcAccount_) + << " dst=" << toBase58(dstAccount_) + << " srcAsset=" << snap_->assets[firstVID].getText() + << " dstAsset=" << snap_->assets[lastVID].getText() + << " path=" << json::Compact{path.getJson(JsonOptions::Values::None)}; + + // Suffix: when the path ends at an OB node whose asset is issued by a + // gateway G that is not the payment's effective destination, the engine + // needs account nodes to ripple from G to dstAccount_. We emit G plus + // (if G is not directly connected to dstAccount_) the intermediate node + // B that bridges G to dstAccount_ via a SHAMap point-lookup. + { + Asset const& dstAsset = snap_->assets[lastVID]; + if (dstAsset.holds() && !isXRP(dstAsset.get().currency)) + { + AccountID const& g = dstAsset.get().account; + if (g != effectiveDst_ && g != dstAccount_) + { + Currency const& ccy = dstAsset.get().currency; + // First add G itself. + path.emplaceBack(STPathElement::TypeAccount, g, xrpCurrency(), xrpAccount()); + // If dstAccount_ does not hold G's IOU directly, look for an + // intermediate account B that has trust lines with both G and + // dstAccount_. Load dstAccount_'s trust lines (cheap: these + // are the user's own lines, typically very few). + if (!ledger_->read(keylet::trustLine(g, dstAccount_, ccy))) + { + auto const dstLines = ledger_->read(keylet::account(dstAccount_)) + ? cache_->getRippleLines(dstAccount_, LineDirection::Outgoing) + : nullptr; + if (dstLines) + { + for (auto const& dl : *dstLines) + { + if (dl.getBalance().get().currency != ccy) + continue; + AccountID const& b = dl.getAccountIDPeer(); + if (b == g || b == dstAccount_ || b == srcAccount_) + continue; + if (ledger_->read(keylet::trustLine(g, b, ccy))) + { + path.emplaceBack( + STPathElement::TypeAccount, b, xrpCurrency(), xrpAccount()); + break; + } + } + } + } + } + } + } + + return path; +} + +//============================================================================== +// path_find-only failed-AMM hop tracking (PathRequestManager) +//============================================================================== + +namespace { + +Asset +assetFromPathElement(STPathElement const& pe) +{ + if (pe.hasMPT()) + return MPTIssue{pe.getMPTID()}; + if (isXRP(pe.getCurrency())) + return xrpIssue(); + return Issue{pe.getCurrency(), pe.getIssuerID()}; +} + +} // namespace + +bool +GraphPathfinder::assetPathTouchesFailedAmm(PayGraph::AssetPath const& assetPath) const +{ + auto& prm = app_.getPathRequestManager(); + for (std::size_t i = 0; i + 1 < assetPath.vids.size(); ++i) + { + auto const u = assetPath.vids[i]; + auto const v = assetPath.vids[i + 1]; + if (u >= snap_->assets.size() || v >= snap_->assets.size()) + continue; + if (prm.isFailedAmmHop(snap_->assets[u], snap_->assets[v])) + return true; + } + return false; +} + +bool +GraphPathfinder::stPathTouchesFailedAmm(STPath const& path) const +{ + if (path.empty()) + return false; + + auto& prm = app_.getPathRequestManager(); + Asset prev = srcAmount_.asset(); + for (auto const& pe : path) + { + Asset const next = assetFromPathElement(pe); + if (prm.isFailedAmmHop(prev, next)) + return true; + prev = next; + } + return false; +} + +void +GraphPathfinder::noteFailedAmmHopsFromPath(STPath const& path) const +{ + auto& prm = app_.getPathRequestManager(); + Asset prev = srcAmount_.asset(); + if (path.empty()) + { + prm.noteFailedAmmHop(prev, dstAmount_.asset()); + return; + } + for (auto const& pe : path) + { + Asset const next = assetFromPathElement(pe); + prm.noteFailedAmmHop(prev, next); + prev = next; + } +} + +void +GraphPathfinder::noteFailedAmmBook(Book const& book) const +{ + auto& prm = app_.getPathRequestManager(); + // Both directions so later ranking skips the broken pool either way. + prm.noteFailedAmmHop(book.in, book.out); + prm.noteFailedAmmHop(book.out, book.in); +} + +//============================================================================== +// getPathLiquidity — same logic as Pathfinder::getPathLiquidity +//============================================================================== + +TER +GraphPathfinder::getPathLiquidity( + STPath const& path, + STAmount const& minDstAmount, + STAmount& amountOut, + uint64_t& qualityOut) const +{ + // path_find only: cheap reject before expensive rippleCalculate / AMM Throw. + if (stPathTouchesFailedAmm(path)) + { + JLOG(j_.trace()) << "GraphPathfinder::getPathLiquidity skip known-failed AMM hop"; + return tefEXCEPTION; + } + + STPathSet pathSet; + pathSet.pushBack(path); + + path::RippleCalc::Input rcInput; + rcInput.defaultPathsAllowed = false; + + PaymentSandbox sandbox(&*ledger_, TapNone); + + try + { + if (convertAll_) + rcInput.partialPaymentAllowed = true; + + auto rc = path::RippleCalc::rippleCalculate( + sandbox, + srcAmount_, + minDstAmount, + dstAccount_, + srcAccount_, + pathSet, + domain_, + app_, + &rcInput); + + if (!isTesSuccess(rc.result())) + { + JLOG(j_.debug()) << "GraphPathfinder::getPathLiquidity failed: " + << transHuman(rc.result()); + return rc.result(); + } + + qualityOut = getRate(rc.actualAmountOut, rc.actualAmountIn); + amountOut = rc.actualAmountOut; + + // Second pass only when we still need more liquidity for fixed dst. + // Skip for convert_all — one probe is enough for ranking. + if (!convertAll_ && amountOut < minDstAmount) + { + rcInput.partialPaymentAllowed = true; + rc = path::RippleCalc::rippleCalculate( + sandbox, + srcAmount_, + dstAmount_ - amountOut, + dstAccount_, + srcAccount_, + pathSet, + domain_, + app_, + &rcInput); + + if (rc.result() == tesSUCCESS) + amountOut += rc.actualAmountOut; + } + + return tesSUCCESS; + } + catch (FlowException const& e) + { + // path_find only: record the failed hop for later ranking ticks. + // Prefer the specific AMM book when present; otherwise mark all hops. + if (e.ammBook) + noteFailedAmmBook(*e.ammBook); + else + noteFailedAmmHopsFromPath(path); + JLOG(j_.debug()) << "GraphPathfinder::getPathLiquidity FlowException: " << e.what(); + return tefEXCEPTION; + } + catch (std::exception const& e) + { + JLOG(j_.debug()) << "GraphPathfinder::getPathLiquidity exception: " << e.what(); + return tefEXCEPTION; + } +} + +//============================================================================== +// rankPaths +//============================================================================== + +namespace { + +STAmount +smallestUsefulAmount(STAmount const& amount, int maxPaths) +{ + return divide(amount, STAmount(maxPaths + 2), amount.asset()); +} + +} // namespace + +void +GraphPathfinder::rankPaths( + int maxPaths, + STPathSet const& paths, + std::vector& rankedPaths, + std::function const& continueCallback) +{ + rankedPaths.clear(); + rankedPaths.reserve(paths.size()); + + auto const saMinDstAmount = [&]() -> STAmount { + if (!convertAll_) + return smallestUsefulAmount(dstAmount_, maxPaths); + return largestAmount(dstAmount_); + }(); + + // Hard cap on expensive rippleCalculate probes. After the first + // FlowException for an AMM, BookStep skips that AMM so later evals should + // not re-throw; these caps still bound worst case. + int const maxProbes = std::max(maxPaths * 2, maxPaths + 3); + int consecutiveFailures = 0; + int probes = 0; + + for (int i = 0; i < static_cast(paths.size()); ++i) + { + if (continueCallback && !continueCallback()) + return; + + auto const& currentPath = paths[i]; + if (currentPath.empty()) + continue; + + // Enough ranked paths for the caller — stop probing. + if (static_cast(rankedPaths.size()) >= maxPaths) + break; + + if (++probes > maxProbes) + break; + + STAmount liquidity; + uint64_t quality = 0; + if (isTesSuccess(getPathLiquidity(currentPath, saMinDstAmount, liquidity, quality))) + { + consecutiveFailures = 0; + rankedPaths.push_back({quality, currentPath.size(), liquidity, i}); + } + else + { + // Bail after a short run of dry/overflow paths. Yen already + // ordered by graph quality, so later candidates rarely save us + // once several consecutive rippleCalcs fail. + if (++consecutiveFailures >= 3) + break; + } + } + + std::ranges::sort(rankedPaths, [&](PathRank const& a, PathRank const& b) { + if (!convertAll_ && a.quality != b.quality) + return a.quality < b.quality; + if (a.liquidity != b.liquidity) + return a.liquidity > b.liquidity; + if (a.length != b.length) + return a.length < b.length; + return a.index > b.index; + }); +} + +//============================================================================== +// computePathRanks +//============================================================================== + +void +GraphPathfinder::computePathRanks(int maxPaths, std::function const& continueCallback) +{ + remainingAmount_ = convertAmount(dstAmount_, convertAll_); + + // Subtract the default-path contribution (same as Pathfinder). + try + { + PaymentSandbox sandbox(&*ledger_, TapNone); + path::RippleCalc::Input rcInput; + rcInput.partialPaymentAllowed = true; + auto rc = path::RippleCalc::rippleCalculate( + sandbox, + srcAmount_, + remainingAmount_, + dstAccount_, + srcAccount_, + STPathSet(), + domain_, + app_, + &rcInput); + + if (rc.result() == tesSUCCESS) + remainingAmount_ -= rc.actualAmountOut; + } + catch (std::exception const&) + { + JLOG(j_.debug()) << "GraphPathfinder: default path exception"; + } + + rankPaths(maxPaths, completePaths_, pathRanks_, continueCallback); +} + +//============================================================================== +// getBestPaths — identical selection logic to Pathfinder::getBestPaths +//============================================================================== + +STPathSet +GraphPathfinder::getBestPaths( + int maxPaths, + STPathSet const& extraPaths, + AccountID const& srcIssuer, + std::function const& continueCallback) +{ + if (completePaths_.empty() && extraPaths.empty()) + return completePaths_; + + // GraphPathfinder materialises paths as offer/currency nodes only — it + // never prepends the issuer's account node the way Pathfinder does. + // The XRPL payment engine implicitly handles the sender→issuer trust-line + // traversal, so the issuer-first filtering that Pathfinder::getBestPaths + // uses does not apply here. Always treat issuerIsSender = true. + bool const issuerIsSender = true; + + // If all paths failed quality ranking (e.g. every route hits an AMM + // overflow), return the unranked concrete paths so processResult's + // safeCalc wrapper can attempt them with the real amounts. Any that + // still overflow will be caught there and silently skipped. + if (pathRanks_.empty() && !completePaths_.empty()) + { + STPathSet result; + for (auto const& path : completePaths_) + { + if (static_cast(result.size()) >= maxPaths) + break; + if (!path.empty()) + result.pushBack(path); + } + return result; + } + + std::vector extraRanks; + rankPaths(maxPaths, extraPaths, extraRanks, continueCallback); + + STPathSet bestPaths; + STAmount remaining = remainingAmount_; + + auto itA = pathRanks_.begin(); + auto itB = extraRanks.begin(); + + while (itA != pathRanks_.end() || itB != extraRanks.end()) + { + if (continueCallback && !continueCallback()) + break; + + bool usePath = false; + bool useExtra = false; + + if (itA == pathRanks_.end()) + { + useExtra = true; + } + else if (itB == extraRanks.end()) + { + usePath = true; + } + else if (itB->quality < itA->quality) + { + useExtra = true; + } + else if (itB->quality > itA->quality) + { + usePath = true; + } + else if (itB->liquidity > itA->liquidity) + { + useExtra = true; + } + else if (itB->liquidity < itA->liquidity) + { + usePath = true; + } + else + { + useExtra = true; + usePath = true; + } + + auto& rank = usePath ? *itA : *itB; + auto const& p = usePath ? completePaths_[rank.index] : extraPaths[rank.index]; + + if (useExtra) + ++itB; + if (usePath) + ++itA; + + int iPathsLeft = maxPaths - static_cast(bestPaths.size()); + if (iPathsLeft <= 0) + break; + + if (p.empty()) + continue; + + // Skip paths that don't match issuer constraints. + bool startsWithIssuer = false; + if (!issuerIsSender && usePath) + { + if (p.size() == 1 || p.front().getAccountID() != srcIssuer) + continue; + startsWithIssuer = true; + } + + STPath trimmed; + if (startsWithIssuer) + { + for (auto it = p.begin() + 1; it != p.end(); ++it) + trimmed.pushBack(*it); + } + STPath const& finalPath = startsWithIssuer ? trimmed : p; + + if (iPathsLeft > 0) + { + --iPathsLeft; + remaining -= rank.liquidity; + bestPaths.pushBack(finalPath); + } + } + + return bestPaths; +} + +} // namespace xrpl diff --git a/src/xrpld/rpc/detail/GraphPathfinder.h b/src/xrpld/rpc/detail/GraphPathfinder.h new file mode 100644 index 00000000000..231a96c3dad --- /dev/null +++ b/src/xrpld/rpc/detail/GraphPathfinder.h @@ -0,0 +1,226 @@ +#pragma once + +//------------------------------------------------------------------------------ +/* + GraphPathfinder — Drop-in replacement for Pathfinder that uses PayGraph. + + DESIGN + ------ + Instead of BFS over the combined account+asset space, GraphPathfinder: + + 1. Looks up the pre-built PayGraph for the current ledger. + 2. Runs Yen's K-Shortest (via PayGraph::kShortestPaths) on the tiny + asset-exchange graph to get ≤6 abstract asset-type paths. + This is O((V+E) log V) — microseconds. + + 3. For each abstract path, "materialises" it into a concrete STPath: + • Each hop that crosses an order book or AMM pool becomes a single + book node (currency + issuer, no account). The XRPL payment + engine fills in the best offer and handles trust-line rippling + implicitly. + This is O(hops) per path. + + 4. Passes the materialised STPathSet to rippleCalculate for final + liquidity confirmation and quality scoring. At most kMaxPaths (6) + paths are evaluated. + + 5. Results are returned immediately; each successive call (driven by + PathRequestManager) can augment the previous result with paths + discovered at higher search depth — maintaining the existing + progressive-refinement WebSocket contract. + + WEBSOCKET CONTRACT + ------------------ + The existing PathRequest/PathRequestManager pipeline already handles + the "emit fast then refine" pattern: + + • doUpdate(fast=true) → emit first result ASAP + • doUpdate(fast=false) called on each ledger → refine / confirm + + GraphPathfinder plugs in as the engine behind PathRequest::getPathFinder() + and is otherwise transparent to the rest of the stack. + + INTERFACE COMPATIBILITY + ----------------------- + GraphPathfinder exposes the same public interface as Pathfinder so it can + be swapped in with minimal changes to PathRequest.cpp. + + The key difference: findPaths() is O(μs) instead of O(ms–seconds) because + the graph traversal is pre-built and query time is trivially small. +*/ +//------------------------------------------------------------------------------ + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace xrpl { + +class Application; +class ReadView; + +class GraphPathfinder : public CountedObject +{ +public: + //-------------------------------------------------------------------------- + // Construction + // + // Matches the Pathfinder constructor signature so PathRequest can + // instantiate either class with the same code path. + //-------------------------------------------------------------------------- + + GraphPathfinder( + std::shared_ptr const& graph, + std::shared_ptr const& cache, + AccountID const& srcAccount, + AccountID const& dstAccount, + PathAsset const& srcPathAsset, + std::optional const& srcIssuer, + STAmount const& dstAmount, + std::optional const& srcAmount, + std::optional const& domain, + Application& app); + + GraphPathfinder(GraphPathfinder const&) = delete; + GraphPathfinder& + operator=(GraphPathfinder const&) = delete; + ~GraphPathfinder() = default; + + //-------------------------------------------------------------------------- + // findPaths — core entry point. + // + // Runs Yen's K-Shortest on the asset graph, materialises the abstract + // paths into concrete STPath objects, and stores them in completePaths_. + // + // continueCallback is checked between path materialisations; return false + // to abort early (the paths found so far remain valid). + // + // Returns true if at least one candidate path was found. + //-------------------------------------------------------------------------- + bool + findPaths(std::function const& continueCallback = {}); + + //-------------------------------------------------------------------------- + // computePathRanks — rank completePaths_ by quality and liquidity. + // + // Calls rippleCalculate on each candidate (same as the old Pathfinder). + // Populates pathRanks_ for use by getBestPaths(). + //-------------------------------------------------------------------------- + void + computePathRanks(int maxPaths, std::function const& continueCallback = {}); + + //-------------------------------------------------------------------------- + // getBestPaths — select up to maxPaths from the ranked candidates. + // + // Interface identical to Pathfinder::getBestPaths(). + //-------------------------------------------------------------------------- + STPathSet + getBestPaths( + int maxPaths, + STPathSet const& extraPaths, + AccountID const& srcIssuer, + std::function const& continueCallback = {}); + + //-------------------------------------------------------------------------- + // PathRank — identical layout to Pathfinder::PathRank so PathRequest + // can use the same ranking/selection code. + //-------------------------------------------------------------------------- + struct PathRank + { + std::uint64_t quality{}; + std::uint64_t length{}; + STAmount liquidity; + int index{}; + }; + +private: + //-------------------------------------------------------------------------- + // Materialise one abstract AssetPath into a concrete STPath. + // + // Each hop (consecutive VID pair) becomes a single book-node + // STPathElement with only currency/issuer set (no account). The XRPL + // payment engine resolves offers and trust-line rippling implicitly. + // + // Returns an empty optional if the path is degenerate (< 2 vertices). + //-------------------------------------------------------------------------- + std::optional + materialise(PayGraph::AssetPath const& assetPath); + + //-------------------------------------------------------------------------- + // Compute liquidity for a single path using rippleCalculate. + // Returns tesSUCCESS and fills amountOut/qualityOut on success. + //-------------------------------------------------------------------------- + TER + getPathLiquidity( + STPath const& path, + STAmount const& minDstAmount, + STAmount& amountOut, + uint64_t& qualityOut) const; + + //-------------------------------------------------------------------------- + // path_find-only failed-AMM hop helpers (PathRequestManager). + // Never consulted by payment / consensus flow. + //-------------------------------------------------------------------------- + [[nodiscard]] bool + assetPathTouchesFailedAmm(PayGraph::AssetPath const& assetPath) const; + + [[nodiscard]] bool + stPathTouchesFailedAmm(STPath const& path) const; + + void + noteFailedAmmHopsFromPath(STPath const& path) const; + + void + noteFailedAmmBook(Book const& book) const; + + //-------------------------------------------------------------------------- + // Rank paths (fills pathRanks_ from completePaths_). + //-------------------------------------------------------------------------- + void + rankPaths( + int maxPaths, + STPathSet const& paths, + std::vector& rankedPaths, + std::function const& continueCallback); + + //-------------------------------------------------------------------------- + // Member data + //-------------------------------------------------------------------------- + + std::shared_ptr graph_; + std::shared_ptr snap_; // stable view for this request + + AccountID srcAccount_; + AccountID dstAccount_; + AccountID effectiveDst_; + STAmount dstAmount_; + PathAsset srcPathAsset_; + std::optional srcIssuer_; + STAmount srcAmount_; + bool convertAll_; + std::optional domain_; + + std::shared_ptr ledger_; + std::shared_ptr cache_; + + STPathSet completePaths_; + std::vector pathRanks_; + STAmount remainingAmount_; + + Application& app_; + beast::Journal j_; +}; + +} // namespace xrpl diff --git a/src/xrpld/rpc/detail/PathRequest.cpp b/src/xrpld/rpc/detail/PathRequest.cpp index 3c09917dad0..8a83d0283ff 100644 --- a/src/xrpld/rpc/detail/PathRequest.cpp +++ b/src/xrpld/rpc/detail/PathRequest.cpp @@ -1,11 +1,11 @@ #include #include -#include #include +#include #include -#include #include +#include #include #include @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -38,6 +39,7 @@ #include #include +#include #include #include #include @@ -62,8 +64,6 @@ PathRequest::PathRequest( , jvStatus_(json::ValueType::Object) , lastIndex_(0) , inProgress_(false) - , iLevel_(0) - , bLastSuccess_(false) , iIdentifier_(id) , created_(std::chrono::steady_clock::now()) { @@ -72,7 +72,7 @@ PathRequest::PathRequest( PathRequest::PathRequest( Application& app, - std::function completion, + std::function const& completion, Resource::Consumer& consumer, int id, PathRequestManager& owner, @@ -85,8 +85,6 @@ PathRequest::PathRequest( , jvStatus_(json::ValueType::Object) , lastIndex_(0) , inProgress_(false) - , iLevel_(0) - , bLastSuccess_(false) , iIdentifier_(id) , created_(std::chrono::steady_clock::now()) { @@ -497,37 +495,44 @@ PathRequest::doAborting() const JLOG(journal_.info()) << iIdentifier_ << " aborting early"; } -std::unique_ptr const& -PathRequest::getPathFinder( +std::unique_ptr const& +PathRequest::getGraphPathFinder( + std::shared_ptr const& graph, std::shared_ptr const& cache, - hash_map>& currencyMap, + hash_map>& currencyMap, PathAsset const& currency, + std::optional const& srcIssuer, STAmount const& dstAmount, - int const level, + bool const fast, std::function const& continueCallback) { auto i = currencyMap.find(currency); if (i != currencyMap.end()) return i->second; // NOLINTBEGIN(bugprone-unchecked-optional-access) isValid() ensures both are set - auto pathfinder = std::make_unique( + auto pathfinder = std::make_unique( + graph, cache, *raSrcAccount_, *raDstAccount_, currency, - std::nullopt, + srcIssuer, dstAmount, saSendMax_, domain_, app_); // NOLINTEND(bugprone-unchecked-optional-access) - if (pathfinder->findPaths(level, continueCallback)) + if (pathfinder->findPaths(continueCallback)) { - pathfinder->computePathRanks(kMaxPaths, continueCallback); + // On the fast pass, skip ranking — unranked paths are returned + // immediately via getBestPaths' fallback, giving a near-instant + // first response. The full pass will rank them properly. + if (!fast) + pathfinder->computePathRanks(kMaxPaths, continueCallback); } else { - pathfinder.reset(); // It's a bad request - clear it. + pathfinder.reset(); } return currencyMap[currency] = std::move(pathfinder); } @@ -535,8 +540,8 @@ PathRequest::getPathFinder( bool PathRequest::findPaths( std::shared_ptr const& cache, - int const level, json::Value& jvArray, + bool const fast, std::function const& continueCallback) { auto sourceAssets = sciSourceAssets_; @@ -578,27 +583,16 @@ PathRequest::findPaths( } auto const dstAmount = convertAmount(saDstAmount_, convertAll_); - hash_map> currencyMap; - for (auto const& asset : sourceAssets) - { - if (continueCallback && !continueCallback()) - break; - JLOG(journal_.debug()) << iIdentifier_ - << " Trying to find paths: " << STAmount(asset, 1).getFullText(); - - auto& pathfinder = - getPathFinder(cache, currencyMap, PathAsset(asset), dstAmount, level, continueCallback); - if (!pathfinder) - { - JLOG(journal_.debug()) << iIdentifier_ << " No paths found"; - continue; - } - - STPath fullLiquidityPath; - auto ps = pathfinder->getBestPaths( - kMaxPaths, fullLiquidityPath, context_[asset], asset.getIssuer(), continueCallback); - context_[asset] = ps; + // Shared post-processing: run rippleCalc, append JSON. + // + // We deliberately do NOT carry paths across ticks. Yen's K-Shortest + // re-runs every tick on the current PayGraph snapshot, so the freshly + // discovered paths already reflect the latest order-book state. + // Feeding the previous tick's results back in as extraPaths would only + // re-price stale path shapes and let them out-rank current ones on a + // quality tie. + auto processResult = [&](STPathSet ps, Asset const& asset) { auto const& sourceAccount = [&] { if (!isXRP(asset.getIssuer())) return asset.getIssuer(); @@ -619,57 +613,42 @@ PathRequest::findPaths( [](MPTIssue const& issue) { return STAmount(issue, 1u, 0, true); }); }(); + JLOG(journal_.info()) << iIdentifier_ << " rippleCalc src=" << toBase58(*raSrcAccount_) + << " dst=" << toBase58(*raDstAccount_) + << " sendMax=" << saMaxAmount.getFullText() + << " dstAmt=" << dstAmount.getFullText() + << " paths=" << json::Compact{ps.getJson(JsonOptions::Values::None)}; JLOG(journal_.debug()) << iIdentifier_ << " Paths found, calling rippleCalc"; path::RippleCalc::Input rcInput; if (convertAll_) rcInput.partialPaymentAllowed = true; auto sandbox = std::make_unique(&*cache->getLedger(), TapNone); - auto rc = path::RippleCalc::rippleCalculate( - *sandbox, - saMaxAmount, // --> Amount to send is unlimited - // to get an estimate. - dstAmount, // --> Amount to deliver. - // NOLINTBEGIN(bugprone-unchecked-optional-access) isValid() ensures both are set - *raDstAccount_, // --> Account to deliver to. - *raSrcAccount_, // --> Account sending from. - // NOLINTEND(bugprone-unchecked-optional-access) - ps, // --> Path set. - domain_, // --> Domain. - app_, - &rcInput); - - if (!convertAll_ && !fullLiquidityPath.empty() && - (rc.result() == terNO_LINE || rc.result() == tecPATH_PARTIAL)) - { - JLOG(journal_.debug()) << iIdentifier_ << " Trying with an extra path element"; - - ps.pushBack(fullLiquidityPath); - sandbox = std::make_unique(&*cache->getLedger(), TapNone); - rc = path::RippleCalc::rippleCalculate( - *sandbox, - saMaxAmount, // --> Amount to send is unlimited - // to get an estimate. - dstAmount, // --> Amount to deliver. - // NOLINTBEGIN(bugprone-unchecked-optional-access) isValid() ensures both are set - *raDstAccount_, // --> Account to deliver to. - *raSrcAccount_, // --> Account sending from. - // NOLINTEND(bugprone-unchecked-optional-access) - ps, // --> Path set. - domain_, // --> Domain. - app_); - - if (!isTesSuccess(rc.result())) + + // rippleCalculate only catches FlowException internally; other exceptions + // (e.g. std::overflow_error from AMMLiquidity::generateFibSeqOffer) can + // propagate. Catch them here so one bad path doesn't abort the entire + // processResult and lose all alternatives. + auto safeCalc = [&](PaymentSandbox& sb, + STAmount const& maxSend, + STAmount const& dst, + STPathSet const& paths, + path::RippleCalc::Input const* inp) -> path::RippleCalc::Output { + try { - JLOG(journal_.warn()) - << iIdentifier_ << " Failed with covering path " << transHuman(rc.result()); + return path::RippleCalc::rippleCalculate( + sb, maxSend, dst, *raDstAccount_, *raSrcAccount_, paths, domain_, app_, inp); } - else + catch (std::exception const& e) { - JLOG(journal_.debug()) - << iIdentifier_ << " Extra path element gives " << transHuman(rc.result()); + JLOG(journal_.debug()) << iIdentifier_ << " rippleCalc exception: " << e.what(); + path::RippleCalc::Output out; + out.setResult(tefEXCEPTION); + return out; } - } + }; + + auto rc = safeCalc(*sandbox, saMaxAmount, dstAmount, ps, &rcInput); if (rc.result() == tesSUCCESS) { @@ -695,10 +674,66 @@ PathRequest::findPaths( } else { + JLOG(journal_.info()) << iIdentifier_ << " rippleCalc returns " + << transHuman(rc.result()); + } + }; + + // When a domain filter is active, build a domain-specific PayGraph so + // domain-only offers (stored in domainBooks_ rather than allBooks_) are + // visible to the pathfinder. The global PayGraph is built with nullopt + // and therefore cannot see them. + std::shared_ptr domainGraph; + if (domain_) + { + if (auto* ledger = cache->getLedger().get()) + { + domainGraph = PayGraph::build(app_.getOrderBookDB(), *ledger, domain_, journal_); + } + } + + auto baseGraph = domain_ ? domainGraph : owner_.getPayGraph(); + if (auto graph = baseGraph) + { + // Fast path: Yen's K-Shortest on the pre-built asset graph — O(μs). + hash_map> graphMap; + for (auto const& asset : sourceAssets) + { + if (continueCallback && !continueCallback()) + break; JLOG(journal_.debug()) - << iIdentifier_ << " rippleCalc returns " << transHuman(rc.result()); + << iIdentifier_ << " Trying to find paths: " << STAmount(asset, 1).getFullText(); + + // Extract the gateway issuer from the asset so GraphPathfinder + // can build srcAmount_ with the correct issuer account. + std::optional assetIssuer; + if (asset.holds() && !isXRP(asset.get().currency)) + assetIssuer = asset.get().account; + + auto& pf = getGraphPathFinder( + graph, + cache, + graphMap, + PathAsset(asset), + assetIssuer, + dstAmount, + fast, + continueCallback); + if (!pf) + { + JLOG(journal_.debug()) << iIdentifier_ << " No paths found"; + continue; + } + + auto ps = pf->getBestPaths(kMaxPaths, STPathSet{}, asset.getIssuer(), continueCallback); + processResult(std::move(ps), asset); } } + else + { + // PayGraph not yet ready — caller should surface RpcNotReady. + return false; + } /* The resource fee is based on the number of source currencies used. The minimum cost is 50 and the maximum is 400. The cost increases @@ -747,56 +782,23 @@ PathRequest::doUpdate( if (jvId_) newStatus[jss::id] = jvId_; - bool const loaded = app_.getFeeTrack().isLoadedLocal(); - - if (iLevel_ == 0) + if (!owner_.getPayGraph()) { - // first pass - if (loaded || fast) + JLOG(journal_.info()) << iIdentifier_ << " PayGraph not ready, returning notReady"; + newStatus = rpcError(RpcNotReady); + } + else + { + json::Value jvArray = json::ValueType::Array; + if (findPaths(cache, jvArray, fast, continueCallback)) { - iLevel_ = app_.config().pathSearchFast; + newStatus[jss::alternatives] = std::move(jvArray); } else { - iLevel_ = app_.config().pathSearch; + newStatus = rpcError(RpcInternal); } } - else if ((iLevel_ == app_.config().pathSearchFast) && !fast) - { - // leaving fast pathfinding - iLevel_ = app_.config().pathSearch; - if (loaded && (iLevel_ > app_.config().pathSearchFast)) - --iLevel_; - } - else if (bLastSuccess_) - { - // decrement, if possible - if (iLevel_ > app_.config().pathSearch || - (loaded && (iLevel_ > app_.config().pathSearchFast))) - --iLevel_; - } - else - { - // adjust as needed - if (!loaded && (iLevel_ < app_.config().pathSearchMax)) - ++iLevel_; - if (loaded && (iLevel_ > app_.config().pathSearchFast)) - --iLevel_; - } - - JLOG(journal_.debug()) << iIdentifier_ << " processing at level " << iLevel_; - - json::Value jvArray = json::ValueType::Array; - if (findPaths(cache, iLevel_, jvArray, continueCallback)) - { - bLastSuccess_ = jvArray.size() != 0; - newStatus[jss::alternatives] = std::move(jvArray); - } - else - { - bLastSuccess_ = false; - newStatus = rpcError(RpcInternal); - } if (fast && quickReply_ == steady_clock::time_point{}) { diff --git a/src/xrpld/rpc/detail/PathRequest.h b/src/xrpld/rpc/detail/PathRequest.h index d40d9c82d65..2bd226196da 100644 --- a/src/xrpld/rpc/detail/PathRequest.h +++ b/src/xrpld/rpc/detail/PathRequest.h @@ -1,31 +1,18 @@ #pragma once -#include #include -#include +#include -#include -#include #include -#include #include -#include -#include +#include #include -#include -#include -#include -#include +#include #include -#include -#include -#include -#include #include #include #include -#include namespace xrpl { @@ -63,7 +50,7 @@ class PathRequest final : public InfoSubRequest, // Completion function is called after path update is complete PathRequest( Application& app, - std::function completion, + std::function const& completion, Resource::Consumer& consumer, int id, PathRequestManager&, @@ -105,13 +92,15 @@ class PathRequest final : public InfoSubRequest, bool isValid(std::shared_ptr const& crCache); - std::unique_ptr const& - getPathFinder( + std::unique_ptr const& + getGraphPathFinder( + std::shared_ptr const&, std::shared_ptr const&, - hash_map>&, + hash_map>&, PathAsset const&, + std::optional const&, STAmount const&, - int const, + bool fast, std::function const&); /** @@ -121,8 +110,8 @@ class PathRequest final : public InfoSubRequest, bool findPaths( std::shared_ptr const&, - int const, json::Value&, + bool fast, std::function const&); int @@ -149,7 +138,6 @@ class PathRequest final : public InfoSubRequest, std::optional saSendMax_; std::set sciSourceAssets_; - std::map context_; std::optional domain_; @@ -159,16 +147,14 @@ class PathRequest final : public InfoSubRequest, LedgerIndex lastIndex_; bool inProgress_; - int iLevel_; - bool bLastSuccess_; - int const iIdentifier_; std::chrono::steady_clock::time_point const created_; std::chrono::steady_clock::time_point quickReply_; std::chrono::steady_clock::time_point fullReply_; - static unsigned int const kMaxPaths = 4; + // payments accept up to 6 paths + static unsigned int const kMaxPaths = 6; }; } // namespace xrpl diff --git a/src/xrpld/rpc/detail/PathRequestManager.cpp b/src/xrpld/rpc/detail/PathRequestManager.cpp index 4953634181e..c560ce456b8 100644 --- a/src/xrpld/rpc/detail/PathRequestManager.cpp +++ b/src/xrpld/rpc/detail/PathRequestManager.cpp @@ -3,24 +3,35 @@ #include #include #include +#include #include +#include +#include #include +#include #include #include #include #include +#include +#include #include +#include #include +#include +#include #include #include #include #include +#include #include #include #include #include +#include #include #include @@ -62,6 +73,51 @@ PathRequestManager::updateAll(std::shared_ptr const& inLedger) { auto event = app_.getJobQueue().makeLoadEvent(JtPathFind, "PathRequest::updateAll"); + // ------------------------------------------------------------------ + // Update the PayGraph incrementally from this ledger's tx metadata. + // Build it from scratch if it does not exist yet (startup / catchup). + // Skip entirely when path finding is disabled. + // ------------------------------------------------------------------ + if (!app_.config().pathSearch) + return; + + { + std::scoped_lock const sl(lock_); + // Prefer a graph built after OrderBookDB's first full scan. On + // networked nodes the scan is async; signalOrderBookReady builds once + // allBooks_ is populated. Standalone builds immediately. + bool const empty = !payGraph_ || payGraph_->currentStats().orderBooks == 0; + if (empty) + { + if (!orderBookReady_.load(std::memory_order_acquire) && !app_.config().standalone()) + return; + + payGraph_ = PayGraph::build( + app_.getOrderBookDB(), + *inLedger, + std::nullopt, // domain + journal_); + graphLedgerSeq_ = inLedger->seq(); + } + else + { + // Warm graph: only patch books that had offer activity this ledger. + std::vector changedBooks; + for (auto const& [stTx, stMeta] : inLedger->txs) + { + if (!stMeta) + continue; + if (stMeta->isFieldPresent(sfAffectedNodes)) + { + auto const& nodes = stMeta->getFieldArray(sfAffectedNodes); + mergeBooks(changedBooks, extractChangedBooks(nodes, std::nullopt)); + } + } + payGraph_->applyLedgerDelta(app_.getOrderBookDB(), *inLedger, changedBooks); + graphLedgerSeq_ = inLedger->seq(); + } + } + std::vector requests; std::shared_ptr cache; @@ -92,6 +148,7 @@ PathRequestManager::updateAll(std::shared_ptr const& inLedger) do { JLOG(journal_.trace()) << "updateAll looping"; + for (auto const& wr : requests) { if (app_.getJobQueue().isStopping()) @@ -156,6 +213,7 @@ PathRequestManager::updateAll(std::shared_ptr const& inLedger) if (r && r != request) return false; + ++removed; return true; }); @@ -171,6 +229,8 @@ PathRequestManager::updateAll(std::shared_ptr const& inLedger) break; } + mustBreak = !newRequests && app_.getLedgerMaster().isNewPathRequest(); + if (mustBreak) { // a new request came in while we were working newRequests = true; @@ -236,6 +296,11 @@ PathRequestManager::makePathRequest( std::shared_ptr const& inLedger, json::Value const& requestJson) { + // Ensure the PayGraph is built before doCreate runs its fast pass; the + // async updateAll() that normally builds it won't have run yet on this + // first call from a fresh subscriber. + ensurePayGraph(inLedger); + auto req = std::make_shared(app_, subscriber, ++lastIdentifier_, *this, journal_); auto [valid, jvRes] = req->doCreate(getAssetCache(inLedger, false), requestJson); @@ -258,6 +323,9 @@ PathRequestManager::makeLegacyPathRequest( std::shared_ptr const& inLedger, json::Value const& request) { + // Ensure the PayGraph is built before doCreate runs its fast pass. + ensurePayGraph(inLedger); + // This assignment must take place before the // completion function is called req = std::make_shared( @@ -291,6 +359,11 @@ PathRequestManager::doLegacyPathRequest( { auto cache = std::make_shared(inLedger, app_.getJournal("AssetCache")); + // Ensure the PayGraph is built/refreshed for this ledger before running + // the synchronous path-find pass; otherwise the first response would be + // empty when called before the async updateAll() job runs. + ensurePayGraph(inLedger); + auto req = std::make_shared(app_, [] {}, consumer, ++lastIdentifier_, *this, journal_); @@ -300,4 +373,46 @@ PathRequestManager::doLegacyPathRequest( return std::move(jvRes); } +STPathSet +PathRequestManager::findPaths( + std::shared_ptr const& ledger, + AccountID const& srcAccount, + AccountID const& dstAccount, + STAmount const& dstAmount, + PathAsset const& srcAsset, + std::optional const& srcIssuer, + std::optional const& domain, + int maxPaths) +{ + if (!ledger) + return {}; + + // Domain payments need a domain-scoped PayGraph because domain-only + // offers live in OrderBookDB::domainBooks_ rather than allBooks_. + auto graph = domain ? PayGraph::build(app_.getOrderBookDB(), *ledger, domain, journal_) + : ensurePayGraph(ledger); + if (!graph) + return {}; + + auto cache = std::make_shared(ledger, app_.getJournal("AssetCache")); + + GraphPathfinder pf( + graph, + cache, + srcAccount, + dstAccount, + srcAsset, + srcIssuer, + dstAmount, + std::nullopt, + domain, + app_); + + if (!pf.findPaths()) + return {}; + + pf.computePathRanks(maxPaths); + return pf.getBestPaths(maxPaths, STPathSet{}, srcIssuer.value_or(srcAccount)); +} + } // namespace xrpl diff --git a/src/xrpld/rpc/detail/PathRequestManager.h b/src/xrpld/rpc/detail/PathRequestManager.h index f6eb80d291e..9d19e452eee 100644 --- a/src/xrpld/rpc/detail/PathRequestManager.h +++ b/src/xrpld/rpc/detail/PathRequestManager.h @@ -3,19 +3,17 @@ #include #include #include +#include -#include -#include -#include -#include -#include -#include +#include +#include +#include +#include #include -#include -#include #include #include +#include #include namespace xrpl { @@ -89,6 +87,99 @@ class PathRequestManager full_.notify(ms); } + std::shared_ptr + getPayGraph() const + { + std::scoped_lock const sl(lock_); + return payGraph_; + } + + //---------------------------------------------------------------------- + // path_find ONLY — failed AMM book hops (in→out assets). + // When a path_find ranking probe hits FlowException from a broken AMM, + // remember that hop so later path_find ticks skip it instead of re-paying + // Throw/logThrow cost. Never read or written by payments / consensus. + // Cleared on full graph rebuild (signalOrderBookReady / empty rebuild). + //---------------------------------------------------------------------- + using AmmHop = std::pair; + + void + noteFailedAmmHop(Asset const& in, Asset const& out) + { + std::scoped_lock const sl(failedAmmLock_); + failedAmmHops_.insert(AmmHop{in, out}); + } + + [[nodiscard]] bool + isFailedAmmHop(Asset const& in, Asset const& out) const + { + std::scoped_lock const sl(failedAmmLock_); + return failedAmmHops_.contains(AmmHop{in, out}); + } + + void + clearFailedAmmHops() + { + std::scoped_lock const sl(failedAmmLock_); + failedAmmHops_.clear(); + } + + /// Return the warm in-memory PayGraph for Dijkstra path_find. + /// + /// Full rebuild only when: + /// - no graph yet, or + /// - graph has zero books (built before OrderBookDB finished scanning) + /// Otherwise return the existing snapshot. Per-ledger edge updates are + /// applyLedgerDelta from updateAll (~few changed books), not a full rebuild. + std::shared_ptr + ensurePayGraph(std::shared_ptr const& inLedger) + { + std::scoped_lock const sl(lock_); + if (!inLedger || !app_.config().pathSearch) + return payGraph_; + + bool const empty = !payGraph_ || payGraph_->currentStats().orderBooks == 0; + if (empty) + { + payGraph_ = PayGraph::build(app_.getOrderBookDB(), *inLedger, std::nullopt, journal_); + graphLedgerSeq_ = inLedger->seq(); + clearFailedAmmHops(); + } + return payGraph_; + } + + /// OrderBookDB finished a full scan and swapped allBooks_ in (rare — not + /// every ~3s ledger). Mark ready and rebuild once from the scanned set so + /// path_find is not stuck on an empty graph forever. + void + signalOrderBookReady(std::shared_ptr const& ledger) + { + orderBookReady_.store(true, std::memory_order_release); + if (!ledger || !app_.config().pathSearch) + return; + + std::scoped_lock const sl(lock_); + payGraph_ = PayGraph::build(app_.getOrderBookDB(), *ledger, std::nullopt, journal_); + graphLedgerSeq_ = ledger->seq(); + clearFailedAmmHops(); + } + + /// One-shot synchronous helper used by tx-signing autofill (build_path) + /// and jtx test helpers. Builds/borrows the PayGraph for `ledger`, + /// constructs a GraphPathfinder and returns up to `maxPaths` STPaths + /// from src→dst delivering `dstAmount`. Returns an empty STPathSet + /// when no paths are found (or no PayGraph is available). + STPathSet + findPaths( + std::shared_ptr const& ledger, + AccountID const& srcAccount, + AccountID const& dstAccount, + STAmount const& dstAmount, + PathAsset const& srcAsset, + std::optional const& srcIssuer, + std::optional const& domain, + int maxPaths); + private: void insertPathRequest(PathRequest::pointer const&); @@ -105,6 +196,27 @@ class PathRequestManager // Use a AssetCache std::weak_ptr assetCache_; + // Persistent asset-exchange graph. Built once at startup (after + // orderBookReady_ is set); mutated incrementally by applyLedgerDelta() + // at each subsequent ledger close. + std::shared_ptr payGraph_; + + // Ledger sequence of the last PayGraph build or incremental delta. + // Diagnostic / freshness only — ensurePayGraph must NOT full-rebuild + // solely because this lags the request ledger. + LedgerIndex graphLedgerSeq_{0}; + + // Set by signalOrderBookReady() when OrderBookDB finishes its first full + // ledger scan. Prevents PayGraph::build() from running against an empty + // allBooks_ on networked nodes where the scan is async (the race that + // causes the PayGraph to have no edges until restart). + std::atomic orderBookReady_{false}; + + // AMM hops that recently threw FlowException during ranking probes. + // Separate lock so path_find ranking does not contend with request list. + hash_set failedAmmHops_; + std::mutex mutable failedAmmLock_; + std::atomic lastIdentifier_; std::recursive_mutex mutable lock_; diff --git a/src/xrpld/rpc/detail/Pathfinder.cpp b/src/xrpld/rpc/detail/Pathfinder.cpp deleted file mode 100644 index 5b7f1415a22..00000000000 --- a/src/xrpld/rpc/detail/Pathfinder.cpp +++ /dev/null @@ -1,1436 +0,0 @@ -#include - -#include -#include -#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 -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace xrpl { -static std::ostream& -operator<<(std::ostream& os, Pathfinder::NodeType t) -{ - return os << static_cast(t); -} -static std::ostream& -operator<<(std::ostream& os, Pathfinder::PaymentType t) -{ - return os << static_cast(t); -} -} // namespace xrpl - -/* - -Core Pathfinding Engine - -The pathfinding request is identified by category, XRP to XRP, XRP to -non-XRP, non-XRP to XRP, same currency non-XRP to non-XRP, cross-currency -non-XRP to non-XRP. For each category, there is a table of paths that the -pathfinder searches for. Complete paths are collected. - -Each complete path is then rated and sorted. Paths with no or trivial -liquidity are dropped. Otherwise, paths are sorted based on quality, -liquidity, and path length. - -Path slots are filled in quality (ratio of out to in) order, with the -exception that the last path must have enough liquidity to complete the -payment (assuming no liquidity overlap). In addition, if no selected path -is capable of providing enough liquidity to complete the payment by itself, -an extra "covering" path is returned. - -The selected paths are then tested to determine if they can complete the -payment and, if so, at what cost. If they fail and a covering path was -found, the test is repeated with the covering path. If this succeeds, the -final paths and the estimated cost are returned. - -The engine permits the search depth to be selected and the paths table -includes the depth at which each path type is found. A search depth of zero -causes no searching to be done. Extra paths can also be injected, and this -should be used to preserve previously-found paths across invocations for the -same path request (particularly if the search depth may change). - -*/ - -namespace xrpl { - -namespace { - -// This is an arbitrary cutoff, and it might cause us to miss other -// good paths with this arbitrary cut off. -constexpr std::size_t kPathfinderMaxCompletePaths = 1000; - -struct AccountCandidate -{ - int priority; - AccountID account; - - static int const kHighPriority = 10000; -}; - -bool -compareAccountCandidate( - std::uint32_t seq, - AccountCandidate const& first, - AccountCandidate const& second) -{ - // Primary sort key: priority descending - if (first.priority != second.priority) - return first.priority > second.priority; - - // Secondary sort key: account descending - if (first.account != second.account) - return first.account > second.account; - - // Tertiary sort key (tie-breaker): (priority ^ seq) ascending - // Note: The primary and secondary keys are equal here. - return (first.priority ^ seq) < (second.priority ^ seq); -} - -using AccountCandidates = std::vector; - -struct CostedPath -{ - int searchLevel; - Pathfinder::PathType type; -}; - -using CostedPathList = std::vector; - -using PathTable = std::map; - -struct PathCost -{ - int cost; - char const* path; -}; -using PathCostList = std::vector; - -PathTable gPathTable; - -std::string -pathTypeToString(Pathfinder::PathType const& type) -{ - std::string ret; - - for (auto const& node : type) - { - switch (node) - { - case Pathfinder::NodeType::Source: - ret.append("s"); - break; - case Pathfinder::NodeType::Accounts: - ret.append("a"); - break; - case Pathfinder::NodeType::Books: - ret.append("b"); - break; - case Pathfinder::NodeType::XrpBook: - ret.append("x"); - break; - case Pathfinder::NodeType::DestBook: - ret.append("f"); - break; - case Pathfinder::NodeType::Destination: - ret.append("d"); - break; - } - } - - return ret; -} - -// Return the smallest amount of useful liquidity for a given amount, and the -// total number of paths we have to evaluate. -STAmount -smallestUsefulAmount(STAmount const& amount, int maxPaths) -{ - return divide(amount, STAmount(maxPaths + 2), amount.asset()); -} - -STAmount -amountFromPathAsset( - PathAsset const& pathAsset, - std::optional const& srcIssuer, - AccountID const& srcAccount) -{ - return pathAsset.visit( - [&](Currency const& currency) { - auto const& account = srcIssuer.value_or(isXRP(currency) ? xrpAccount() : srcAccount); - return STAmount(Issue{currency, account}, 1u, 0, true); - }, - [](MPTID const& mpt) { return STAmount(mpt, 1u, 0, true); }); -} - -Asset -assetFromPathAsset(PathAsset const& pathAsset, AccountID const& account) -{ - return pathAsset.visit( - [&](Currency const& currency) { return Asset{Issue{currency, account}}; }, - [](MPTID const& mpt) { return Asset{mpt}; }); -} - -} // namespace - -Pathfinder::Pathfinder( - std::shared_ptr const& cache, - AccountID const& uSrcAccount, - AccountID const& uDstAccount, - PathAsset const& uSrcPathAsset, - std::optional const& uSrcIssuer, - STAmount const& saDstAmount, - std::optional const& srcAmount, - std::optional const& domain, - Application& app) - : srcAccount_(uSrcAccount) - , dstAccount_(uDstAccount) - , effectiveDst_(isXRP(saDstAmount.getIssuer()) ? uDstAccount : saDstAmount.getIssuer()) - , dstAmount_(saDstAmount) - , srcPathAsset_(uSrcPathAsset) - , srcIssuer_(uSrcIssuer) - , srcAmount_(amountFromPathAsset(uSrcPathAsset, uSrcIssuer, uSrcAccount)) - , convertAll_(convertAllCheck(dstAmount_)) - , domain_(domain) - , ledger_(cache->getLedger()) - , rLCache_(cache) - , app_(app) - , j_(app.getJournal("Pathfinder")) -{ - XRPL_ASSERT( - !uSrcIssuer || uSrcPathAsset.isXRP() == isXRP(uSrcIssuer.value()), - "xrpl::Pathfinder::Pathfinder : valid inputs"); -} - -bool -Pathfinder::findPaths(int searchLevel, std::function const& continueCallback) -{ - JLOG(j_.trace()) << "findPaths start"; - if (dstAmount_ == beast::kZero) - { - // No need to send zero money. - JLOG(j_.debug()) << "Destination amount was zero."; - ledger_.reset(); - return false; - - // TODO(tom): why do we reset the ledger just in this case and the one - // below - why don't we do it each time we return false? - } - - if (srcAccount_ == dstAccount_ && dstAccount_ == effectiveDst_ && - srcPathAsset_ == dstAmount_.asset()) - { - // No need to send to same account with same currency. - JLOG(j_.debug()) << "Tried to send to same issuer"; - ledger_.reset(); - return false; - } - - if (srcAccount_ == effectiveDst_ && srcPathAsset_ == dstAmount_.asset()) - { - // Default path might work, but any path would loop - return true; - } - - loadEvent_ = app_.getJobQueue().makeLoadEvent(JtPathFind, "FindPath"); - auto currencyIsXRP = isXRP(srcPathAsset_); - - bool const useIssuerAccount = srcIssuer_ && !currencyIsXRP && !isXRP(*srcIssuer_); - auto& account = useIssuerAccount ? *srcIssuer_ : srcAccount_; - auto issuer = currencyIsXRP ? AccountID() : account; - source_ = STPathElement(account, srcPathAsset_, issuer); - auto issuerString = srcIssuer_ ? to_string(*srcIssuer_) : std::string("none"); - JLOG(j_.trace()) << "findPaths>" - << " srcAccount_=" << srcAccount_ << " dstAccount_=" << dstAccount_ - << " dstAmount_=" << dstAmount_.getFullText() - << " srcPathAsset_=" << srcPathAsset_ << " srcIssuer_=" << issuerString; - - if (!ledger_) - { - JLOG(j_.debug()) << "findPaths< no ledger"; - return false; - } - - bool const bSrcXrp = isXRP(srcPathAsset_); - bool const bDstXrp = isXRP(dstAmount_.asset()); - - if (!ledger_->exists(keylet::account(srcAccount_))) - { - // We can't even start without a source account. - JLOG(j_.debug()) << "invalid source account"; - return false; - } - - if ((effectiveDst_ != dstAccount_) && !ledger_->exists(keylet::account(effectiveDst_))) - { - JLOG(j_.debug()) << "Non-existent gateway"; - return false; - } - - if (!ledger_->exists(keylet::account(dstAccount_))) - { - // Can't find the destination account - we must be funding a new - // account. - if (!bDstXrp) - { - JLOG(j_.debug()) << "New account not being funded in XRP "; - return false; - } - - auto const reserve = STAmount(ledger_->fees().reserve); - if (dstAmount_ < reserve) - { - JLOG(j_.debug()) << "New account not getting enough funding: " << dstAmount_ << " < " - << reserve; - return false; - } - } - - // Now compute the payment type from the types of the source and destination - // currencies. - PaymentType paymentType = PaymentType::XrpToXrp; - if (bSrcXrp && bDstXrp) - { - // XRP -> XRP - JLOG(j_.debug()) << "XRP to XRP payment"; - paymentType = PaymentType::XrpToXrp; - } - else if (bSrcXrp) - { - // XRP -> non-XRP - JLOG(j_.debug()) << "XRP to non-XRP payment"; - paymentType = PaymentType::XrpToNonXrp; - } - else if (bDstXrp) - { - // non-XRP -> XRP - JLOG(j_.debug()) << "non-XRP to XRP payment"; - paymentType = PaymentType::NonXrpToXrp; - } - else if (srcPathAsset_ == dstAmount_.asset()) - { - // non-XRP -> non-XRP - Same currency - JLOG(j_.debug()) << "non-XRP to non-XRP - same currency"; - paymentType = PaymentType::NonXrpToSame; - } - else - { - // non-XRP to non-XRP - Different currency - JLOG(j_.debug()) << "non-XRP to non-XRP - cross currency"; - paymentType = PaymentType::NonXrpToNonXrp; - } - - // Now iterate over all paths for that paymentType. - for (auto const& costedPath : gPathTable[paymentType]) - { - if (continueCallback && !continueCallback()) - return false; - // Only use paths with at most the current search level. - if (costedPath.searchLevel <= searchLevel) - { - JLOG(j_.trace()) << "findPaths trying payment type " << paymentType; - addPathsForType(costedPath.type, continueCallback); - - if (completePaths_.size() > kPathfinderMaxCompletePaths) - break; - } - } - - JLOG(j_.debug()) << completePaths_.size() << " complete paths found"; - - // Even if we find no paths, default paths may work, and we don't check them - // currently. - return true; -} - -TER -Pathfinder::getPathLiquidity( - STPath const& path, // IN: The path to check. - STAmount const& minDstAmount, // IN: The minimum output this path must - // deliver to be worth keeping. - STAmount& amountOut, // OUT: The actual liquidity along the path. - uint64_t& qualityOut) const // OUT: The returned initial quality -{ - STPathSet pathSet; - pathSet.pushBack(path); - - path::RippleCalc::Input rcInput; - rcInput.defaultPathsAllowed = false; - - PaymentSandbox sandbox(&*ledger_, TapNone); - - try - { - // Compute a path that provides at least the minimum liquidity. - if (convertAll_) - rcInput.partialPaymentAllowed = true; - - auto rc = path::RippleCalc::rippleCalculate( - sandbox, - srcAmount_, - minDstAmount, - dstAccount_, - srcAccount_, - pathSet, - domain_, - app_, - &rcInput); - // If we can't get even the minimum liquidity requested, we're done. - if (!isTesSuccess(rc.result())) - return rc.result(); - - qualityOut = getRate(rc.actualAmountOut, rc.actualAmountIn); - amountOut = rc.actualAmountOut; - - if (!convertAll_) - { - // Now try to compute the remaining liquidity. - rcInput.partialPaymentAllowed = true; - rc = path::RippleCalc::rippleCalculate( - sandbox, - srcAmount_, - dstAmount_ - amountOut, - dstAccount_, - srcAccount_, - pathSet, - domain_, - app_, - &rcInput); - - // If we found further liquidity, add it into the result. - if (rc.result() == tesSUCCESS) - amountOut += rc.actualAmountOut; - } - - return tesSUCCESS; - } - catch (std::exception const& e) - { - JLOG(j_.info()) << "checkpath: exception (" << e.what() << ") " - << path.getJson(JsonOptions::Values::None); - return tefEXCEPTION; - } -} - -void -Pathfinder::computePathRanks(int maxPaths, std::function const& continueCallback) -{ - remainingAmount_ = convertAmount(dstAmount_, convertAll_); - - // Must subtract liquidity in default path from remaining amount. - try - { - PaymentSandbox sandbox(&*ledger_, TapNone); - - path::RippleCalc::Input rcInput; - rcInput.partialPaymentAllowed = true; - auto rc = path::RippleCalc::rippleCalculate( - sandbox, - srcAmount_, - remainingAmount_, - dstAccount_, - srcAccount_, - STPathSet(), - domain_, - app_, - &rcInput); - - if (rc.result() == tesSUCCESS) - { - JLOG(j_.debug()) << "Default path contributes: " << rc.actualAmountIn; - remainingAmount_ -= rc.actualAmountOut; - } - else - { - JLOG(j_.debug()) << "Default path fails: " << transToken(rc.result()); - } - } - catch (std::exception const&) - { - JLOG(j_.debug()) << "Default path causes exception"; - } - - rankPaths(maxPaths, completePaths_, pathRanks_, continueCallback); -} - -static bool -isDefaultPath(STPath const& path) -{ - // FIXME: default paths can consist of more than just an account: - // - // JoelKatz writes: - // So the test for whether a path is a default path is incorrect. I'm not - // sure it's worth the complexity of fixing though. If we are going to fix - // it, I'd suggest doing it this way: - // - // 1) Compute the default path, probably by using 'expandPath' to expand an - // empty path. 2) Chop off the source and destination nodes. - // - // 3) In the pathfinding loop, if the source issuer is not the sender, - // reject all paths that don't begin with the issuer's account node or match - // the path we built at step 2. - return path.size() == 1; -} - -static STPath -removeIssuer(STPath const& path) -{ - // This path starts with the issuer, which is already implied - // so remove the head node - STPath ret; - - for (auto it = path.begin() + 1; it != path.end(); ++it) - ret.pushBack(*it); - - return ret; -} - -// For each useful path in the input path set, -// create a ranking entry in the output vector of path ranks -void -Pathfinder::rankPaths( - int maxPaths, - STPathSet const& paths, - std::vector& rankedPaths, - std::function const& continueCallback) -{ - JLOG(j_.trace()) << "rankPaths with " << paths.size() << " candidates, and " << maxPaths - << " maximum"; - rankedPaths.clear(); - rankedPaths.reserve(paths.size()); - - auto const saMinDstAmount = [&]() -> STAmount { - if (!convertAll_) - { - // Ignore paths that move only very small amounts. - return smallestUsefulAmount(dstAmount_, maxPaths); - } - - // On convert_all_ partialPaymentAllowed will be set to true - // and requiring a huge amount will find the highest liquidity. - return largestAmount(dstAmount_); - }(); - - for (int i = 0; i < paths.size(); ++i) - { - if (continueCallback && !continueCallback()) - return; - auto const& currentPath = paths[i]; - if (!currentPath.empty()) - { - STAmount liquidity; - uint64_t uQuality = 0; - auto const resultCode = - getPathLiquidity(currentPath, saMinDstAmount, liquidity, uQuality); - if (!isTesSuccess(resultCode)) - { - JLOG(j_.debug()) << "findPaths: dropping : " << transToken(resultCode) << ": " - << currentPath.getJson(JsonOptions::Values::None); - } - else - { - JLOG(j_.debug()) << "findPaths: quality: " << uQuality << ": " - << currentPath.getJson(JsonOptions::Values::None); - - rankedPaths.push_back( - {.quality = uQuality, - .length = currentPath.size(), - .liquidity = liquidity, - .index = i}); - } - } - } - - // Sort paths by: - // cost of path (when considering quality) - // width of path - // length of path - // A better PathRank is lower, best are sorted to the beginning. - std::ranges::sort( - rankedPaths, [&](Pathfinder::PathRank const& a, Pathfinder::PathRank const& b) { - // 1) Higher quality (lower cost) is better - if (!convertAll_ && a.quality != b.quality) - return a.quality < b.quality; - - // 2) More liquidity (higher volume) is better - if (a.liquidity != b.liquidity) - return a.liquidity > b.liquidity; - - // 3) Shorter paths are better - if (a.length != b.length) - return a.length < b.length; - - // 4) Tie breaker - return a.index > b.index; - }); -} - -STPathSet -Pathfinder::getBestPaths( - int maxPaths, - STPath& fullLiquidityPath, - STPathSet const& extraPaths, - AccountID const& srcIssuer, - std::function const& continueCallback) -{ - JLOG(j_.debug()) << "findPaths: " << completePaths_.size() << " paths and " << extraPaths.size() - << " extras"; - - if (completePaths_.empty() && extraPaths.empty()) - return completePaths_; - - XRPL_ASSERT( - fullLiquidityPath.empty(), "xrpl::Pathfinder::getBestPaths : first empty path result"); - bool const issuerIsSender = isXRP(srcPathAsset_) || (srcIssuer == srcAccount_); - - std::vector extraPathRanks; - rankPaths(maxPaths, extraPaths, extraPathRanks, continueCallback); - - STPathSet bestPaths; - - // The best PathRanks are now at the start. Pull off enough of them to - // fill bestPaths, then look through the rest for the best individual - // path that can satisfy the entire liquidity - if one exists. - STAmount remaining = remainingAmount_; - - auto pathsIterator = pathRanks_.begin(); - auto extraPathsIterator = extraPathRanks.begin(); - - while (pathsIterator != pathRanks_.end() || extraPathsIterator != extraPathRanks.end()) - { - if (continueCallback && !continueCallback()) - break; - bool usePath = false; - bool useExtraPath = false; - - if (pathsIterator == pathRanks_.end()) - { - useExtraPath = true; - } - else if (extraPathsIterator == extraPathRanks.end()) - { - usePath = true; - } - else if (extraPathsIterator->quality != pathsIterator->quality) - { - // Prefer the lower (better) quality value - useExtraPath = extraPathsIterator->quality < pathsIterator->quality; - usePath = !useExtraPath; - } - else if (extraPathsIterator->liquidity != pathsIterator->liquidity) - { - // Equal quality: prefer the higher liquidity - useExtraPath = extraPathsIterator->liquidity > pathsIterator->liquidity; - usePath = !useExtraPath; - } - else - { - // Risk is high they have identical liquidity - useExtraPath = true; - usePath = true; - } - - auto& pathRank = usePath ? *pathsIterator : *extraPathsIterator; - - auto const& path = usePath ? completePaths_[pathRank.index] : extraPaths[pathRank.index]; - - if (useExtraPath) - ++extraPathsIterator; - - if (usePath) - ++pathsIterator; - - auto iPathsLeft = maxPaths - bestPaths.size(); - if (iPathsLeft <= 0 && !fullLiquidityPath.empty()) - break; - - if (path.empty()) - { - // LCOV_EXCL_START - UNREACHABLE("xrpl::Pathfinder::getBestPaths : path not found"); - continue; - // LCOV_EXCL_STOP - } - - bool startsWithIssuer = false; - - if (!issuerIsSender && usePath) - { - // Need to make sure path matches issuer constraints - if (isDefaultPath(path) || path.front().getAccountID() != srcIssuer) - { - continue; - } - - startsWithIssuer = true; - } - - if (iPathsLeft > 1 || (iPathsLeft > 0 && pathRank.liquidity >= remaining)) - // last path must fill - { - --iPathsLeft; - remaining -= pathRank.liquidity; - bestPaths.pushBack(startsWithIssuer ? removeIssuer(path) : path); - } - else if (iPathsLeft == 0 && pathRank.liquidity >= dstAmount_ && fullLiquidityPath.empty()) - { - // We found an extra path that can move the whole amount. - fullLiquidityPath = (startsWithIssuer ? removeIssuer(path) : path); - JLOG(j_.debug()) << "Found extra full path: " - << fullLiquidityPath.getJson(JsonOptions::Values::None); - } - else - { - JLOG(j_.debug()) << "Skipping a non-filling path: " - << path.getJson(JsonOptions::Values::None); - } - } - - if (remaining > beast::kZero) - { - XRPL_ASSERT( - fullLiquidityPath.empty(), "xrpl::Pathfinder::getBestPaths : second empty path result"); - JLOG(j_.info()) << "Paths could not send " << remaining << " of " << dstAmount_; - } - else - { - JLOG(j_.debug()) << "findPaths: RESULTS: " << bestPaths.getJson(JsonOptions::Values::None); - } - return bestPaths; -} - -bool -Pathfinder::issueMatchesOrigin(Asset const& asset) -{ - bool const matchingAsset = (asset == srcPathAsset_); - bool const matchingAccount = isXRP(asset) || (srcIssuer_ && asset.getIssuer() == srcIssuer_) || - asset.getIssuer() == srcAccount_; - - return matchingAsset && matchingAccount; -} - -int -Pathfinder::getPathsOut( - PathAsset const& pathAsset, - AccountID const& account, - LineDirection direction, - bool isDstAsset, - AccountID const& dstAccount, - std::function const& continueCallback) -{ - Asset const asset = assetFromPathAsset(pathAsset, account); - - auto [it, inserted] = pathsOutCountMap_.emplace(asset, 0); - - // If it was already present, return the stored number of paths - if (!inserted) - return it->second; - - auto sleAccount = ledger_->read(keylet::account(account)); - - if (!sleAccount) - return 0; - - auto const aFlags = sleAccount->getFieldU32(sfFlags); - bool const bAuthRequired = [&]() { - if (pathAsset.holds()) - return (aFlags & lsfRequireAuth) != 0; - return !isTesSuccess(requireAuth(*ledger_, asset.get(), account)); - }(); - bool const bFrozen = [&]() { - if (pathAsset.holds()) - return (aFlags & lsfGlobalFreeze) != 0; - return isGlobalFrozen(*ledger_, asset.get()); - }(); - - int count = 0; - - if (!bFrozen) - { - count = app_.getOrderBookDB().getBookSize(asset, domain_); - - asset.visit( - [&](Issue const&) { - if (auto const lines = rLCache_->getRippleLines(account, direction)) - { - for (auto const& rspEntry : *lines) - { - if (pathAsset.get() != rspEntry.getLimit().get().currency) - continue; - if (rspEntry.getBalance() <= beast::kZero && - (!rspEntry.getLimitPeer() || - -rspEntry.getBalance() >= rspEntry.getLimitPeer() || - (bAuthRequired && !rspEntry.getAuth()))) - continue; - if (isDstAsset && dstAccount == rspEntry.getAccountIDPeer()) - { - count += 10000; // count a path to the destination extra - continue; - } - if (rspEntry.getNoRipplePeer()) - continue; // This probably isn't a useful path out - if (rspEntry.getFreezePeer()) - continue; // Not a useful path out - ++count; - } - } - }, - [&](MPTIssue const&) { - if (auto const mpts = rLCache_->getMPTs(account)) - { - for (auto const& mpt : *mpts) - { - if (pathAsset.get() != mpt.getMptID() || mpt.isZeroBalance() || - mpt.isMaxedOut() || bAuthRequired) - continue; - if (isDstAsset && dstAccount == getMPTIssuer(mpt)) - { - count += 10000; - continue; - } - if (isIndividualFrozen(*ledger_, account, MPTIssue{mpt.getMptID()})) - continue; - ++count; - } - } - }); - } - it->second = count; - return count; -} - -void -Pathfinder::addLinks( - STPathSet const& currentPaths, // The paths to build from - STPathSet& incompletePaths, // The set of partial paths we add to - int addFlags, - std::function const& continueCallback) -{ - JLOG(j_.debug()) << "addLink< on " << currentPaths.size() << " source(s), flags=" << addFlags; - for (auto const& path : currentPaths) - { - if (continueCallback && !continueCallback()) - return; - addLink(path, incompletePaths, addFlags, continueCallback); - } -} - -STPathSet& -Pathfinder::addPathsForType( - PathType const& pathType, - std::function const& continueCallback) -{ - JLOG(j_.debug()) << "addPathsForType " << CollectionAndDelimiter(pathType, ", "); - // See if the set of paths for this type already exists. - auto it = paths_.find(pathType); - if (it != paths_.end()) - return it->second; - - // Otherwise, if the type has no nodes, return the empty path. - if (pathType.empty()) - return paths_[pathType]; - if (continueCallback && !continueCallback()) - return paths_[{}]; - - // Otherwise, get the paths for the parent PathType by calling - // addPathsForType recursively. - PathType parentPathType = pathType; - parentPathType.pop_back(); - - STPathSet const& parentPaths = addPathsForType(parentPathType, continueCallback); - STPathSet& pathsOut = paths_[pathType]; - - JLOG(j_.debug()) << "getPaths< adding onto '" << pathTypeToString(parentPathType) - << "' to get '" << pathTypeToString(pathType) << "'"; - - int const initialSize = completePaths_.size(); - - // Add the last NodeType to the lists. - auto nodeType = pathType.back(); - switch (nodeType) - { - case NodeType::Source: - // Source must always be at the start, so pathsOut has to be empty. - XRPL_ASSERT(pathsOut.empty(), "xrpl::Pathfinder::addPathsForType : empty paths"); - pathsOut.pushBack(STPath()); - break; - - case NodeType::Accounts: - addLinks(parentPaths, pathsOut, kAfAddAccounts, continueCallback); - break; - - case NodeType::Books: - addLinks(parentPaths, pathsOut, kAfAddBooks, continueCallback); - break; - - case NodeType::XrpBook: - addLinks(parentPaths, pathsOut, kAfAddBooks | kAfObXrp, continueCallback); - break; - - case NodeType::DestBook: - addLinks(parentPaths, pathsOut, kAfAddBooks | kAfObLast, continueCallback); - break; - - case NodeType::Destination: - // FIXME: What if a different issuer was specified on the - // destination amount? - // TODO(tom): what does this even mean? Should it be a JIRA? - addLinks(parentPaths, pathsOut, kAfAddAccounts | kAfAcLast, continueCallback); - break; - } - - if (completePaths_.size() != initialSize) - { - JLOG(j_.debug()) << (completePaths_.size() - initialSize) << " complete paths added"; - } - - JLOG(j_.debug()) << "getPaths> " << pathsOut.size() << " partial paths found"; - return pathsOut; -} - -bool -Pathfinder::isNoRipple( - AccountID const& fromAccount, - AccountID const& toAccount, - Currency const& currency) -{ - auto sleRipple = ledger_->read(keylet::trustLine(toAccount, fromAccount, currency)); - - auto const flag((toAccount > fromAccount) ? lsfHighNoRipple : lsfLowNoRipple); - - return sleRipple && sleRipple->isFlag(flag); -} - -// Does this path end on an account-to-account link whose last account has -// set "no ripple" on the link? -bool -Pathfinder::isNoRippleOut(STPath const& currentPath) -{ - // Must have at least one link. - if (currentPath.empty()) - return false; - - // Last link must be an account. - STPathElement const& endElement = currentPath.back(); - if ((endElement.getNodeType() & STPathElement::TypeAccount) == 0u) - return false; - - // If there's only one item in the path, return true if that item specifies - // no ripple on the output. A path with no ripple on its output can't be - // followed by a link with no ripple on its input. - auto const& fromAccount = - (currentPath.size() == 1) ? srcAccount_ : (currentPath.end() - 2)->getAccountID(); - auto const& toAccount = endElement.getAccountID(); - return endElement.hasCurrency() && isNoRipple(fromAccount, toAccount, endElement.getCurrency()); -} - -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 (p == path) - return; - } - pathSet.pushBack(path); -} - -void -Pathfinder::addLink( - STPath const& currentPath, // The path to build from - STPathSet& incompletePaths, // The set of partial paths we add to - int addFlags, - std::function const& continueCallback) -{ - auto const& pathEnd = currentPath.empty() ? source_ : currentPath.back(); - auto const& uEndPathAsset = pathEnd.getPathAsset(); - auto const& uEndIssuer = pathEnd.getIssuerID(); - auto const& uEndAccount = pathEnd.getAccountID(); - bool const bOnXRP = isXRP(uEndPathAsset); - - // Does pathfinding really need to get this to - // a gateway (the issuer of the destination amount) - // rather than the ultimate destination? - bool const hasEffectiveDestination = effectiveDst_ != dstAccount_; - - JLOG(j_.trace()) << "addLink< flags=" << addFlags << " onXRP=" << bOnXRP - << " completePaths size=" << completePaths_.size(); - JLOG(j_.trace()) << currentPath.getJson(JsonOptions::Values::None); - - if ((addFlags & kAfAddAccounts) != 0u) - { - // add accounts - if (bOnXRP) - { - if (dstAmount_.native() && !currentPath.empty()) - { // non-default path to XRP destination - JLOG(j_.trace()) << "complete path found ax: " - << currentPath.getJson(JsonOptions::Values::None); - addUniquePath(completePaths_, currentPath); - } - } - else - { - // search for accounts to add - auto const sleEnd = ledger_->read(keylet::account(uEndAccount)); - - if (sleEnd) - { - bool const bRequireAuth(sleEnd->isFlag(lsfRequireAuth)); - bool const bIsEndAsset(uEndPathAsset == dstAmount_.asset()); - bool const bIsNoRippleOut(isNoRippleOut(currentPath)); - bool const bDestOnly((addFlags & kAfAcLast) != 0u); - - AccountCandidates candidates; - - auto forAssets = [&](AssetType const& assets) { - candidates.reserve(assets.size()); - - static constexpr bool kIsLine = - std::is_same_v>; - static constexpr bool kIsMpt = - std::is_same_v>; - - for (auto const& asset : assets) - { - if (continueCallback && !continueCallback()) - return; - auto const& acct = [&]() constexpr { - if constexpr (kIsLine) - return asset.getAccountIDPeer(); - // Unlike trustline, MPT is not bidirectional - if constexpr (kIsMpt) - return getMPTIssuer(asset); - }(); - auto const direction = [&]() constexpr -> LineDirection { - if constexpr (kIsLine) - return asset.getDirectionPeer(); - // incoming for MPT since MPT doesn't support - // rippling (see LineDirection comments) - return LineDirection::Incoming; - }(); - - if (hasEffectiveDestination && (acct == dstAccount_)) - { - // We skipped the gateway - continue; - } - - bool const bToDestination = acct == effectiveDst_; - - if (bDestOnly && !bToDestination) - { - continue; - } - - auto const correctAsset = [&]() { - if constexpr (kIsLine) - { - return uEndPathAsset.get() == - asset.getLimit().template get().currency; - } - if constexpr (kIsMpt) - { - return uEndPathAsset.get() == asset.getMptID(); - } - }(); - auto checkAsset = [&]() { - if constexpr (kIsLine) - { - return ( - (asset.getBalance() <= beast::kZero && - (!asset.getLimitPeer() || - -asset.getBalance() >= asset.getLimitPeer() || - (bRequireAuth && !asset.getAuth()))) || - (bIsNoRippleOut && asset.getNoRipple())); - } - if constexpr (kIsMpt) - { - return asset.isZeroBalance() || asset.isMaxedOut() || - requireAuth(*ledger_, MPTIssue{asset}, acct); - } - }; - - if (correctAsset && !currentPath.hasSeen(acct, uEndPathAsset, acct)) - { - // path is for correct currency and has not been - // seen - if (checkAsset()) - { - // Can't leave on this path - continue; - } - if (bToDestination) - { - // destination is always worth trying - if (uEndPathAsset == dstAmount_.asset()) - { - // this is a complete path - if (!currentPath.empty()) - { - JLOG(j_.trace()) - << "complete path found ae: " - << currentPath.getJson(JsonOptions::Values::None); - addUniquePath(completePaths_, currentPath); - } - } - else if (!bDestOnly) - { - // this is a high-priority candidate - candidates.push_back({AccountCandidate::kHighPriority, acct}); - } - } - else if (acct == srcAccount_) - { - // going back to the source is bad - } - else - { - // save this candidate - int const out = getPathsOut( - uEndPathAsset, - acct, - direction, - bIsEndAsset, - effectiveDst_, - continueCallback); - if (out != 0) - candidates.push_back({out, acct}); - } - } - } - }; - - uEndPathAsset.visit( - [&](Currency const&) { - if (auto const lines = rLCache_->getRippleLines( - uEndAccount, - bIsNoRippleOut ? LineDirection::Incoming : LineDirection::Outgoing)) - { - forAssets(*lines); - } - }, - [&](MPTID const&) { - if (auto const mpts = rLCache_->getMPTs(uEndAccount)) - { - forAssets(*mpts); - } - }); - - if (!candidates.empty()) - { - std::ranges::sort( - candidates, - [seq = ledger_->seq()]( - AccountCandidate const& first, AccountCandidate const& second) { - return compareAccountCandidate(seq, first, second); - }); - - int count = candidates.size(); - // allow more paths from source - if ((count > 10) && (uEndAccount != srcAccount_)) - { - count = 10; - } - else if (count > 50) - { - count = 50; - } - - auto it = candidates.begin(); - while (count-- != 0) - { - if (continueCallback && !continueCallback()) - return; - // Add accounts to incompletePaths - STPathElement const pathElement( - STPathElement::TypeAccount, it->account, uEndPathAsset, it->account); - incompletePaths.assembleAdd(currentPath, pathElement); - ++it; - } - } - } - else - { - JLOG(j_.warn()) << "Path ends on non-existent issuer"; - } - } - } - if ((addFlags & kAfAddBooks) != 0u) - { - // add order books - if ((addFlags & kAfObXrp) != 0u) - { - // to XRP only - if (!bOnXRP && - app_.getOrderBookDB().isBookToXRP( - assetFromPathAsset(uEndPathAsset, uEndIssuer), domain_)) - { - STPathElement const pathElement( - STPathElement::TypeCurrency, xrpAccount(), xrpCurrency(), xrpAccount()); - incompletePaths.assembleAdd(currentPath, pathElement); - } - } - else - { - bool const bDestOnly = (addFlags & kAfObLast) != 0; - auto books = app_.getOrderBookDB().getBooksByTakerPays( - assetFromPathAsset(uEndPathAsset, uEndIssuer), domain_); - JLOG(j_.trace()) << books.size() << " books found from this currency/issuer"; - - for (auto const& book : books) - { - if (continueCallback && !continueCallback()) - return; - if (!currentPath.hasSeen(xrpAccount(), book.out, book.out.getIssuer()) && - !issueMatchesOrigin(book.out) && - (!bDestOnly || equalTokens(book.out, dstAmount_.asset()))) - { - STPath newPath(currentPath); - - if (isXRP(book.out)) - { // to XRP - - // add the order book itself - newPath.emplaceBack( - STPathElement::TypeCurrency, xrpAccount(), xrpCurrency(), xrpAccount()); - - if (isXRP(dstAmount_.asset())) - { - // destination is XRP, add account and path is - // complete - JLOG(j_.trace()) << "complete path found bx: " - << currentPath.getJson(JsonOptions::Values::None); - addUniquePath(completePaths_, newPath); - } - else - { - incompletePaths.pushBack(newPath); - } - } - else if (!currentPath.hasSeen( - book.out.getIssuer(), book.out, book.out.getIssuer())) - { - auto const assetType = book.out.holds() ? STPathElement::TypeCurrency - : STPathElement::TypeMpt; - // Don't want the book if we've already seen the issuer - // book -> account -> book - if ((newPath.size() >= 2) && (newPath.back().isAccount()) && - (newPath[newPath.size() - 2].isOffer())) - { - // replace the redundant account with the order book - newPath[newPath.size() - 1] = STPathElement( - assetType | STPathElement::TypeIssuer, - xrpAccount(), - book.out, - book.out.getIssuer()); - } - else - { - // add the order book - newPath.emplaceBack( - assetType | STPathElement::TypeIssuer, - xrpAccount(), - book.out, - book.out.getIssuer()); - } - - if (hasEffectiveDestination && book.out.getIssuer() == dstAccount_ && - equalTokens(book.out, dstAmount_.asset())) - { - // We skipped a required issuer - } - else if ( - book.out.getIssuer() == effectiveDst_ && - equalTokens(book.out, dstAmount_.asset())) - { // with the destination account, this path is - // complete - JLOG(j_.trace()) << "complete path found ba: " - << currentPath.getJson(JsonOptions::Values::None); - addUniquePath(completePaths_, newPath); - } - else - { - // add issuer's account, path still incomplete - incompletePaths.assembleAdd( - newPath, - STPathElement( - STPathElement::TypeAccount, - book.out.getIssuer(), - book.out, - book.out.getIssuer())); - } - } - } - } - } - } -} - -namespace { - -Pathfinder::PathType -makePath(char const* string) -{ - Pathfinder::PathType ret; - - while (true) - { - // NOLINTNEXTLINE(bugprone-switch-missing-default-case) - switch (*string++) - { - case 's': // source - ret.push_back(Pathfinder::NodeType::Source); - break; - - case 'a': // accounts - ret.push_back(Pathfinder::NodeType::Accounts); - break; - - case 'b': // books - ret.push_back(Pathfinder::NodeType::Books); - break; - - case 'x': // xrp book - ret.push_back(Pathfinder::NodeType::XrpBook); - break; - - case 'f': // book to final currency - ret.push_back(Pathfinder::NodeType::DestBook); - break; - - case 'd': - // Destination (with account, if required and not already - // present). - ret.push_back(Pathfinder::NodeType::Destination); - break; - - case 0: - return ret; - } - } -} - -void -fillPaths(Pathfinder::PaymentType type, PathCostList const& costs) -{ - auto& list = gPathTable[type]; - XRPL_ASSERT(list.empty(), "xrpl::fillPaths : empty paths"); - for (auto& cost : costs) - list.push_back({.searchLevel = cost.cost, .type = makePath(cost.path)}); -} - -} // namespace - -// Costs: -// 0 = minimum to make some payments possible -// 1 = include trivial paths to make common cases work -// 4 = normal fast search level -// 7 = normal slow search level -// 10 = most aggressive - -void -Pathfinder::initPathTable() -{ - // CAUTION: Do not include rules that build default paths - - gPathTable.clear(); - fillPaths(PaymentType::XrpToXrp, {}); - /* cspell: disable */ - - fillPaths( - PaymentType::XrpToNonXrp, - {{.cost = 1, .path = "sfd"}, // source -> book -> gateway - {.cost = 3, .path = "sfad"}, // source -> book -> account -> destination - {.cost = 5, .path = "sfaad"}, // source -> book -> account -> account -> destination - {.cost = 6, .path = "sbfd"}, // source -> book -> book -> destination - {.cost = 8, .path = "sbafd"}, // source -> book -> account -> book -> destination - {.cost = 9, .path = "sbfad"}, // source -> book -> book -> account -> destination - {.cost = 10, .path = "sbafad"}}); - - fillPaths( - PaymentType::NonXrpToXrp, - {{.cost = 1, .path = "sxd"}, // gateway buys XRP - {.cost = 2, .path = "saxd"}, // source -> gateway -> book(XRP) -> dest - {.cost = 6, .path = "saaxd"}, - {.cost = 7, .path = "sbxd"}, - {.cost = 8, .path = "sabxd"}, - {.cost = 9, .path = "sabaxd"}}); - - // non-XRP to non-XRP (same currency) - fillPaths( - PaymentType::NonXrpToSame, - { - {.cost = 1, .path = "sad"}, // source -> gateway -> destination - {.cost = 1, .path = "sfd"}, // source -> book -> destination - {.cost = 4, .path = "safd"}, // source -> gateway -> book -> destination - {.cost = 4, .path = "sfad"}, - {.cost = 5, .path = "saad"}, - {.cost = 5, .path = "sbfd"}, - {.cost = 6, .path = "sxfad"}, - {.cost = 6, .path = "safad"}, - {.cost = 6, .path = "saxfd"}, // source -> gateway -> book to XRP -> book -> - // destination - {.cost = 6, .path = "saxfad"}, - {.cost = 6, .path = "sabfd"}, // source -> gateway -> book -> book -> destination - {.cost = 7, .path = "saaad"}, - }); - - // non-XRP to non-XRP (different currency) - fillPaths( - PaymentType::NonXrpToNonXrp, - { - {.cost = 1, .path = "sfad"}, - {.cost = 1, .path = "safd"}, - {.cost = 3, .path = "safad"}, - {.cost = 4, .path = "sxfd"}, - {.cost = 5, .path = "saxfd"}, - {.cost = 5, .path = "sxfad"}, - {.cost = 5, .path = "sbfd"}, - {.cost = 6, .path = "saxfad"}, - {.cost = 6, .path = "sabfd"}, - {.cost = 7, .path = "saafd"}, - {.cost = 8, .path = "saafad"}, - {.cost = 9, .path = "safaad"}, - }); - /* cspell: enable */ -} - -} // namespace xrpl diff --git a/src/xrpld/rpc/detail/Pathfinder.h b/src/xrpld/rpc/detail/Pathfinder.h deleted file mode 100644 index aeacd218d29..00000000000 --- a/src/xrpld/rpc/detail/Pathfinder.h +++ /dev/null @@ -1,235 +0,0 @@ -#pragma once - -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include - -namespace xrpl { - -/** - * Calculates payment paths. - * - * The @ref RippleCalc determines the quality of the found paths. - * - * @see RippleCalc - */ -class Pathfinder : public CountedObject -{ -public: - /** - * Construct a pathfinder without an issuer. - */ - Pathfinder( - std::shared_ptr const& cache, - AccountID const& srcAccount, - AccountID const& dstAccount, - PathAsset const& uSrcPathAsset, - std::optional const& uSrcIssuer, - STAmount const& dstAmount, - std::optional const& srcAmount, - std::optional const& domain, - Application& app); - Pathfinder(Pathfinder const&) = delete; - Pathfinder& - operator=(Pathfinder const&) = delete; - ~Pathfinder() = default; - - static void - initPathTable(); - - bool - findPaths(int searchLevel, std::function const& continueCallback = {}); - - /** - * Compute the rankings of the paths. - */ - void - computePathRanks(int maxPaths, std::function const& continueCallback = {}); - - /* Get the best paths, up to maxPaths in number, from completePaths_. - - On return, if fullLiquidityPath is not empty, then it contains the best - additional single path which can consume all the liquidity. - */ - STPathSet - getBestPaths( - int maxPaths, - STPath& fullLiquidityPath, - STPathSet const& extraPaths, - AccountID const& srcIssuer, - std::function const& continueCallback = {}); - - enum class NodeType { - Source, // The source account: with an issuer account, if needed. - Accounts, // Accounts that connect from this source/currency. - Books, // Order books that connect to this currency. - XrpBook, // The order book from this currency to XRP. - DestBook, // The order book to the destination currency/issuer. - Destination // The destination account only. - }; - - // The PathType is a list of the NodeTypes for a path. - using PathType = std::vector; - - // PaymentType represents the types of the source and destination currencies - // in a path request. - enum class PaymentType { - XrpToXrp, - XrpToNonXrp, - NonXrpToXrp, - NonXrpToSame, // Destination currency is the same as source. - NonXrpToNonXrp // Destination currency is NOT the same as source. - }; - - struct PathRank - { - std::uint64_t quality{}; - std::uint64_t length{}; - STAmount liquidity; - int index{}; - }; - -private: - /* - Call graph of Pathfinder methods. - - findPaths: - addPathsForType: - addLinks: - addLink: - getPathsOut - issueMatchesOrigin - isNoRippleOut: - isNoRipple - - computePathRanks: - rippleCalculate - getPathLiquidity: - rippleCalculate - - getBestPaths - */ - - // Add all paths of one type to completePaths_. - STPathSet& - addPathsForType(PathType const& type, std::function const& continueCallback); - - bool - issueMatchesOrigin(Asset const&); - - int - getPathsOut( - PathAsset const& pathAsset, - AccountID const& account, - LineDirection direction, - bool isDestPathAsset, - AccountID const& dest, - std::function const& continueCallback); - - void - addLink( - STPath const& currentPath, - STPathSet& incompletePaths, - int addFlags, - std::function const& continueCallback); - - // Call addLink() for each path in currentPaths. - void - addLinks( - STPathSet const& currentPaths, - STPathSet& incompletePaths, - int addFlags, - std::function const& continueCallback); - - // Compute the liquidity for a path. Return tesSUCCESS if it has enough - // liquidity to be worth keeping, otherwise an error. - TER - getPathLiquidity( - STPath const& path, // IN: The path to check. - STAmount const& minDstAmount, // IN: The minimum output this path must - // deliver to be worth keeping. - STAmount& amountOut, // OUT: The actual liquidity on the path. - uint64_t& qualityOut) const; // OUT: The returned initial quality - - // Does this path end on an account-to-account link whose last account has - // set the "no ripple" flag on the link? - bool - isNoRippleOut(STPath const& currentPath); - - // Is the "no ripple" flag set from one account to another? - bool - isNoRipple(AccountID const& fromAccount, AccountID const& toAccount, Currency const& currency); - - void - rankPaths( - int maxPaths, - STPathSet const& paths, - std::vector& rankedPaths, - std::function const& continueCallback); - - AccountID srcAccount_; - AccountID dstAccount_; - AccountID effectiveDst_; // The account the paths need to end at - STAmount dstAmount_; - PathAsset srcPathAsset_; - std::optional srcIssuer_; - STAmount srcAmount_; - /** - * The amount remaining from srcAccount_ after the default liquidity has - * been removed. - */ - STAmount remainingAmount_; - bool convertAll_; - std::optional domain_; - - std::shared_ptr ledger_; - std::unique_ptr loadEvent_; - std::shared_ptr rLCache_; - - STPathElement source_; - STPathSet completePaths_; - std::vector pathRanks_; - std::map paths_; - - hash_map pathsOutCountMap_; - - Application& app_; - beast::Journal const j_; - - // Add ripple paths - static std::uint32_t const kAfAddAccounts = 0x001; - - // Add order books - static std::uint32_t const kAfAddBooks = 0x002; - - // Add order book to XRP only - static std::uint32_t const kAfObXrp = 0x010; - - // Must link to destination currency - static std::uint32_t const kAfObLast = 0x040; - - // Destination account only - static std::uint32_t const kAfAcLast = 0x080; -}; - -} // namespace xrpl diff --git a/src/xrpld/rpc/detail/PayGraph.cpp b/src/xrpld/rpc/detail/PayGraph.cpp new file mode 100644 index 00000000000..add46f4eb0c --- /dev/null +++ b/src/xrpld/rpc/detail/PayGraph.cpp @@ -0,0 +1,897 @@ +#include + +#include +#include +#include +#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 { + +namespace { + +// Apple libc++ has not yet shipped the C++20 std::atomic> +// specialisation, so we fall back to the (deprecated since C++20) free-function +// API. Wrap the calls in small helpers so the deprecation warning can be +// suppressed in exactly one place. +#if defined(__GNUC__) || defined(__clang__) +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +#endif + +template +inline std::shared_ptr +atomicLoad(std::shared_ptr const* p, std::memory_order order) noexcept +{ + return std::atomic_load_explicit(p, order); +} + +template +inline void +atomicStore(std::shared_ptr* p, std::shared_ptr v, std::memory_order order) noexcept +{ + std::atomic_store_explicit(p, std::move(v), order); +} + +#if defined(__GNUC__) || defined(__clang__) +#pragma GCC diagnostic pop +#endif + +} // namespace + +//============================================================================== +// Internal helpers +//============================================================================== + +namespace { + +// 16.16 fixed-point scale for log2(cost_ratio) edge weights. +static constexpr long double kLogWeightScale = 65536.0L; +static constexpr long double kLog2_10 = 3.3219280948873626L; // log2(10) + +// Sentinel path cost (unreachable). Fits in int64 and is far above any +// realistic sum of log-weights on a ~8-hop path. +static constexpr std::int64_t kCostInf = std::numeric_limits::max() / 4; + +// Empty-book traversal cost: worse than any real path of kMaxPathLength hops +// but still finite so structural edges remain traversable. +static constexpr std::int64_t kEmptyBookCost = kCostInf / 8; + +/// Order-preserving map int64 cost -> uint64 for min-heaps / cumQuality. +/// Flips the sign bit so lower signed costs sort as lower unsigned ranks. +inline std::uint64_t +costToRank(std::int64_t cost) noexcept +{ + return static_cast(cost) ^ (std::uint64_t{1} << 63); +} + +/// Recover signed log-weight bits stored in Edge::qualityFixed. +/// kNoLiquidity must be checked by the caller before calling this. +inline std::int64_t +signedLogWeight(std::uint32_t qualityFixed) noexcept +{ + return static_cast(qualityFixed); +} + +/// Convert a raw Quality uint64 (from getQuality()) into a log-space edge +/// weight that can be *added* during path search. +/// +/// XRPL quality is the taker cost ratio (takerPays / takerGets). Exchange +/// rates compose multiplicatively along a path: +/// total_cost = r1 * r2 * ... * rn +/// so the natural additive edge weight is: +/// log2(r1) + log2(r2) + ... + log2(rn) = log2(total_cost) +/// +/// We store log2(ratio) in 16.16 fixed-point as the int32 bit-pattern inside +/// a uint32 (kNoLiquidity remains the all-ones sentinel and is never a valid +/// log weight). Lower signed weight = cheaper rate. +/// +/// Note: log2(ratio) may be negative when ratio < 1. Query-time search uses +/// Dijkstra on Johnson-reweighted edges (potentials computed once per +/// snapshot) so weights are non-negative without a per-hop bias. +uint32_t +qualityToFixed(uint64_t rawQuality) +{ + if (rawQuality == 0) + return PayGraph::kNoLiquidity; + + // Quality packing (see amountFromQuality / getRate): + // ratio = mantissa * 10^exponent + // mantissa: low 56 bits, exponent: high 8 bits biased by +100 + uint64_t const mantissa = rawQuality & 0x00FFFFFFFFFFFFFFull; + int const exponent = static_cast(rawQuality >> 56) - 100; + + if (mantissa == 0) + return static_cast(static_cast(0)); + + // log2(mantissa * 10^exponent) = log2(mantissa) + exponent * log2(10) + long double const logValue = std::log2(static_cast(mantissa)) + + static_cast(exponent) * kLog2_10; + + long long const fixed = std::llround(logValue * kLogWeightScale); + + // Clamp into int32 so the bit-pattern fits in qualityFixed and never + // collides with kNoLiquidity (0xFFFFFFFF == -1 as int32 is a valid small + // weight; we only use kNoLiquidity as an explicit sentinel checked first). + constexpr long long kMin = + std::numeric_limits::min() + 1; // keep -1 free? not required + constexpr long long kMax = std::numeric_limits::max(); + long long const clamped = std::clamp(fixed, kMin, kMax); + + // Avoid storing the kNoLiquidity bit pattern by coincidence. + auto bits = static_cast(static_cast(clamped)); + if (bits == PayGraph::kNoLiquidity) + bits = static_cast(static_cast(clamped - 1)); + + return bits; +} + +// Bias so log2(amount) packs into a positive uint32 over a wide range. +// Encoding: liquidityLog = round((log2(|amount|) + kLogBias) * 65536) +// Must stay consistent between bookLiquidityLog() and edgeCost(). +static constexpr long double kLogBias = 64.0L; +static constexpr long double kFixedScale = 65536.0L; + +/// Pack log2(|amount|) into the Edge::liquidityLog encoding. +uint32_t +amountToLogFixed(STAmount const& amount) +{ + if (amount == beast::kZero || amount.mantissa() == 0) + return 0; + + long double const logValue = std::log2(static_cast(amount.mantissa())) + + static_cast(amount.exponent()) * kLog2_10; + + long long const fixed = std::llround((logValue + kLogBias) * kFixedScale); + if (fixed < 1) + return 1; + if (fixed > static_cast(PayGraph::kNoLiquidity - 1)) + return PayGraph::kNoLiquidity - 1; + return static_cast(fixed); +} + +/// True (signed) edge cost in log-space — used for path ranking and as the +/// base for Johnson reweighting: +/// cost = log2(rate) when depth covers the payment +/// cost = log2(rate) + log2(payment/depth) when the book is thin +std::int64_t +edgeCostRaw(PayGraph::Edge const& e, STAmount const* dstAmount) +{ + if (e.qualityFixed == PayGraph::kNoLiquidity) + return kEmptyBookCost; + + std::int64_t cost = signedLogWeight(e.qualityFixed); + + // No payment size, or no measured depth: pure rate ranking. + if (dstAmount == nullptr || *dstAmount == beast::kZero || e.liquidityLog == 0) + return cost; + + // liquidityLog / payLog are both biased by kLogBias; the difference is + // the unbiased log2(payment/depth) shortfall (or surplus). + uint32_t const payLog = amountToLogFixed(*dstAmount); + if (payLog > e.liquidityLog) + cost += static_cast(payLog - e.liquidityLog); + + return cost; +} + +/// Non-negative Dijkstra edge weight via Johnson reweighting: +/// w'(u,v) = w(u,v) + h[u] - h[v] +/// where h[] are Snapshot::potential from computePotentials(). +/// Path identity: sum w' = sum w + h[src] - h[dst] (no per-hop bias). +std::int64_t +edgeCostDijkstra( + PayGraph::Snapshot const& snap, + PayGraph::VID u, + PayGraph::Edge const& e, + STAmount const* dstAmount) +{ + std::int64_t const raw = edgeCostRaw(e, dstAmount); + if (snap.potential.empty()) + return raw > 0 ? raw : 0; + + std::int64_t const hu = (u < snap.potential.size()) ? snap.potential[u] : 0; + std::int64_t const hv = (e.to < snap.potential.size()) ? snap.potential[e.to] : 0; + + // w' = w + h(u) - h(v). Guard overflow around empty-book sentinels. + if (raw >= kEmptyBookCost / 2) + return kEmptyBookCost; + + std::int64_t const wp = raw + hu - hv; + // Numerical / incomplete-potential safety: Dijkstra requires >= 0. + return wp > 0 ? wp : 0; +} + +/// Convert Dijkstra reweighted distance into the true signed log-path cost. +inline std::int64_t +truePathCost( + PayGraph::Snapshot const& snap, + PayGraph::VID src, + PayGraph::VID dst, + std::int64_t dijkstraDist) noexcept +{ + if (dijkstraDist >= kCostInf / 2 || snap.potential.empty()) + return dijkstraDist; + + std::int64_t const hs = (src < snap.potential.size()) ? snap.potential[src] : 0; + std::int64_t const hd = (dst < snap.potential.size()) ? snap.potential[dst] : 0; + // sum w = sum w' - h[src] + h[dst] + return dijkstraDist - hs + hd; +} + +} // namespace + +//============================================================================== +// PayGraph — Private constructor +//============================================================================== + +PayGraph::PayGraph(std::optional const& domain, beast::Journal j) : domain_(domain), j_(j) +{ +} + +//============================================================================== +// PayGraph::snapshot() +//============================================================================== + +std::shared_ptr +PayGraph::snapshot() const +{ + return atomicLoad(&snap_, std::memory_order_acquire); +} + +//============================================================================== +// PayGraph::currentStats() +//============================================================================== + +PayGraph::Stats +PayGraph::currentStats() const +{ + auto s = snapshot(); + return s ? s->stats : Stats{}; +} + +//============================================================================== +// Static vertex / edge helpers +//============================================================================== + +PayGraph::VID +PayGraph::ensureVertex(Snapshot& snap, Asset const& asset) +{ + auto [it, inserted] = snap.index.emplace(asset, static_cast(snap.assets.size())); + if (inserted) + { + snap.assets.push_back(asset); + snap.adj.emplace_back(); // empty edge list for new vertex + ++snap.stats.vertices; + } + return it->second; +} + +PayGraph::Edge& +PayGraph::ensureEdge(Snapshot& snap, VID from, VID to, EdgeKind kind) +{ + assert(from < snap.adj.size()); + auto& list = snap.adj[from]; + for (auto& e : list) + { + if (e.to == to && e.kind == kind) + return e; + } + list.push_back(Edge{.to = to, .qualityFixed = kNoLiquidity, .kind = kind}); + ++snap.stats.edges; + return list.back(); +} + +//============================================================================== +// Static: query the top-of-book quality for a book from the ledger. +// +// The order-book directory is keyed by quality, and the first (lowest key) +// directory page is the best-quality (cheapest for the taker) entry. +// ReadView::succ() walks the SHAMap in ascending key order, so we find +// the smallest key >= bookBase and < qualityNext. That page's key encodes +// the quality directly via getQuality(). +//============================================================================== + +uint32_t +PayGraph::topOfBookQuality(ReadView const& ledger, Book const& book) +{ + uint256 const base = getBookBase(book); + uint256 const end = getQualityNext(base); + + auto const firstPage = ledger.succ(base, end); + if (!firstPage) + return kNoLiquidity; + + uint64_t const rawQ = getQuality(*firstPage); + return qualityToFixed(rawQ); +} + +//============================================================================== +// Static: single top-of-book offer size as biased log2 fixed-point. +// +// One SLE read only — used for a cheap depth signal. Never walk the whole +// book on the hot path (that made path_find multi-second on live books). +//============================================================================== + +uint32_t +PayGraph::bookLiquidityLog(ReadView const& ledger, Book const& book) +{ + BookDirs dirs(ledger, book); + for (auto const& sle : dirs) + { + if (!sle) + continue; + auto const gets = sle->getFieldAmount(sfTakerGets); + if (gets == beast::kZero) + continue; + return amountToLogFixed(gets); + } + return 0; +} + +//============================================================================== +// Static: build a fresh Snapshot +//============================================================================== + +std::shared_ptr +PayGraph::buildSnapshot( + OrderBookDB& bookDB, + ReadView const& ledger, + std::optional const& domain, + beast::Journal j) +{ + auto snap = std::make_shared(); + + // --- XRP vertex always exists ----------------------------------------- + ensureVertex(*snap, xrpIssue()); + + // --- Order books ------------------------------------------------------- + // OrderBookDB tracks every known (takerPays, takerGets) book pair. + // We seed ALL known takerPays assets up-front via getAllTakerPaysAssets(), + // then BFS from each to collect edges via getBooksByTakerPays(). + // XRP is always seeded first as the universal bridge asset. + + // We use a simple work-queue BFS over discovered assets. + std::vector workQueue; + hash_set visited; + workQueue.reserve(1024); // avoid reallocation while iterating by index + + auto enqueue = [&](Asset const& a) { + if (visited.insert(a).second) + workQueue.push_back(a); + }; + + enqueue(xrpIssue()); // seed + + // Also seed from every known takerPays asset so that non-XRP-rooted + // assets are discovered even if they have no direct XRP book. + // The BFS deduplicates via `visited`. + // Sort before enqueuing so VID assignment is deterministic across + // processes (hardened_hash iteration order is per-process random). + { + auto seeds = bookDB.getAllTakerPaysAssets(domain); + std::ranges::sort(seeds); + for (Asset const& a : seeds) + enqueue(a); + } + + for (std::size_t qi = 0; qi < workQueue.size(); ++qi) + { + Asset const src = + workQueue[qi]; // copy — enqueue() may realloc workQueue, invalidating refs + auto books = bookDB.getBooksByTakerPays(src, domain); + // Sort books so edge insertion order (and thus adj[] ordering) is + // deterministic across processes with different hash seeds. + std::ranges::sort(books, [](Book const& a, Book const& b) { return a.out < b.out; }); + for (Book const& book : books) + { + Asset const& dst = book.out; + + // Full build is rare (startup / full OB rescan), not per path_find. + // Quality: O(1) succ. Depth: first offer only (one SLE). + uint32_t const q = topOfBookQuality(ledger, book); + uint32_t const depth = bookLiquidityLog(ledger, book); + + VID const vSrc = ensureVertex(*snap, src); + VID const vDst = ensureVertex(*snap, dst); + Edge& e = ensureEdge(*snap, vSrc, vDst, EdgeKind::OrderBook); + e.qualityFixed = q; + e.liquidityLog = depth; + ++snap->stats.orderBooks; + + enqueue(dst); + } + } + + // One Johnson pass per full build only (not per path_find / not every delta). + computePotentials(*snap); + + JLOG(j.debug()) << "PayGraph::buildSnapshot: " << snap->stats.vertices << " vertices, " + << snap->stats.edges << " edges, " << snap->stats.orderBooks << " order books"; + + return snap; +} + +//============================================================================== +// PayGraph::build() — factory, called once at startup +//============================================================================== + +std::shared_ptr +PayGraph::build( + OrderBookDB& bookDB, + ReadView const& ledger, + std::optional const& domain, + beast::Journal j) +{ + // Private constructor accessible through this factory only. + auto pg = std::shared_ptr(new PayGraph(domain, j)); + auto snap = buildSnapshot(bookDB, ledger, domain, j); + atomicStore( + &pg->snap_, std::shared_ptr(std::move(snap)), std::memory_order_release); + return pg; +} + +//============================================================================== +// PayGraph::rebuild() — full rebuild, replaces snapshot atomically +//============================================================================== + +void +PayGraph::rebuild(OrderBookDB& bookDB, ReadView const& ledger, std::optional const& domain) +{ + auto snap = buildSnapshot(bookDB, ledger, domain, j_); + + std::scoped_lock const lk(writeMu_); + // Preserve cumulative counter from current snapshot. + if (auto cur = snapshot()) + snap->stats.totalDeltasCalled = cur->stats.totalDeltasCalled; + + atomicStore( + &snap_, std::shared_ptr(std::move(snap)), std::memory_order_release); +} + +//============================================================================== +// PayGraph::applyLedgerDelta() +// +// Called by PathRequestManager at each ledger close. changedBooks contains +// only the books that had offer activity in the just-closed ledger. +// We make a copy of the current snapshot (cheap: ~50 KB), patch each changed +// book's edge weight, then atomically publish the new snapshot. +//============================================================================== + +void +PayGraph::applyLedgerDelta( + OrderBookDB& bookDB, + ReadView const& newLedger, + std::vector const& changedBooks) +{ + if (changedBooks.empty()) + return; + + // ---------- acquire write lock ---------------------------------------- + std::scoped_lock const lk(writeMu_); + + // Shallow-copy the current snapshot. All vectors are value-copied. + auto cur = atomicLoad(&snap_, std::memory_order_acquire); + if (!cur) + { + // No snapshot yet — do a full build instead. + auto fresh = buildSnapshot(bookDB, newLedger, domain_, j_); + atomicStore( + &snap_, std::shared_ptr(std::move(fresh)), std::memory_order_release); + return; + } + + auto next = std::make_shared(*cur); // value copy + next->stats.lastDeltaBooks = static_cast(changedBooks.size()); + next->stats.totalDeltasCalled = cur->stats.totalDeltasCalled + 1; + + // ---------- patch changed edges --------------------------------------- + // Only books that had offer activity this ledger (usually << 100). + // Quality: O(1) succ. Depth: one top offer if present. No O(VE) + // potential recompute — Dijkstra clamps reweighted costs to >= 0 using + // the last full-build potentials (good enough for ranking). + for (Book const& book : changedBooks) + { + uint32_t const newQ = topOfBookQuality(newLedger, book); + uint32_t const newDepth = bookLiquidityLog(newLedger, book); + + VID const vSrc = ensureVertex(*next, book.in); + VID const vDst = ensureVertex(*next, book.out); + Edge& e = ensureEdge(*next, vSrc, vDst, EdgeKind::OrderBook); + e.qualityFixed = newQ; + e.liquidityLog = newDepth; + } + + // Keep existing potentials; do not recompute O(VE) every ~3s ledger. + + JLOG(j_.trace()) << "PayGraph::applyLedgerDelta: patched " << changedBooks.size() + << " books, delta #" << next->stats.totalDeltasCalled; + + // ---------- publish --------------------------------------------------- + atomicStore( + &snap_, std::shared_ptr(std::move(next)), std::memory_order_release); +} + +//============================================================================== +// Vertex helpers (operate on current snapshot) +//============================================================================== + +PayGraph::VID +PayGraph::vertexOf(Asset const& asset) const +{ + auto s = snapshot(); + if (!s) + return kNull; + auto it = s->index.find(asset); + return (it != s->index.end()) ? it->second : kNull; +} + +Asset const& +PayGraph::assetOf(VID v) const +{ + static Asset const kEmpty; + auto s = snapshot(); + if (!s || v >= s->assets.size()) + return kEmpty; + return s->assets[v]; +} + +//============================================================================== +// Johnson potentials — computed once per snapshot (build / ledger delta). +// +// Signed log2(rate) weights may be negative (cost_ratio < 1). Dijkstra needs +// non-negative weights, so we compute potentials h[v] such that +// w'(u,v) = w(u,v) + h[u] - h[v] >= 0 +// for every real edge. This is Bellman-Ford from a virtual super-source with +// 0-weight edges into every vertex (i.e. initialise h = 0 and relax). Cost is +// O(VE) once per snapshot — not per pathfind. +//============================================================================== + +void +PayGraph::computePotentials(Snapshot& snap) +{ + uint32_t const n = static_cast(snap.assets.size()); + snap.potential.assign(n, 0); + + if (n == 0) + return; + + // |V|-1 relaxation rounds. Early-exit when stable. + for (uint32_t pass = 0; pass + 1 < n; ++pass) + { + bool updated = false; + for (VID u = 0; u < n; ++u) + { + if (u >= snap.adj.size()) + continue; + for (Edge const& e : snap.adj[u]) + { + if (e.qualityFixed == kNoLiquidity) + continue; // structural empty — not a real rate + VID const v = e.to; + if (v >= n) + continue; + + std::int64_t const w = signedLogWeight(e.qualityFixed); + // h[v] > h[u] + w → improve + if (snap.potential[u] > kCostInf / 2 + w) + continue; // overflow guard + std::int64_t const cand = snap.potential[u] + w; + if (cand < snap.potential[v]) + { + snap.potential[v] = cand; + updated = true; + } + } + } + if (!updated) + break; + } +} + +//============================================================================== +// Single-source shortest paths (Dijkstra on Johnson-reweighted log-costs). +// +// True edge costs are signed log2(cost_ratio) (+ optional liquidity penalty). +// Query-time Dijkstra uses non-negative w' = w + h[u] - h[v]. True path cost +// is recovered as dist'[dst] - h[src] + h[dst]. +// +// blockedVerts/Edges: Yen's algorithm k-shortest enumeration +// dstAmount: thin-book log-shortfall penalty (request-scoped, >= 0) +//============================================================================== + +PayGraph::DijkResult +PayGraph::dijkstra( + Snapshot const& snap, + VID src, + std::vector const* blockedVerts, + BlockedEdges const* blockedEdges, + STAmount const* dstAmount) +{ + uint32_t const n = static_cast(snap.assets.size()); + + DijkResult res; + res.dist.assign(n, kCostInf); + res.prev.assign(n, kNull); + + if (src >= n) + return res; + if ((blockedVerts != nullptr) && src < blockedVerts->size() && (*blockedVerts)[src]) + return res; + + res.dist[src] = 0; + + // Min-heap: (reweighted cost, vertex) + using PQ = std::priority_queue< + std::pair, + std::vector>, + std::greater<>>; + + PQ pq; + pq.emplace(0, src); + + while (!pq.empty()) + { + auto [cost, u] = pq.top(); + pq.pop(); + + if (cost > res.dist[u]) + continue; // stale heap entry + + if (u >= snap.adj.size()) + continue; + + for (Edge const& e : snap.adj[u]) + { + VID const v = e.to; + if (v >= n) + continue; + if ((blockedVerts != nullptr) && v < blockedVerts->size() && (*blockedVerts)[v]) + continue; + + if (blockedEdges != nullptr) + { + bool edgeBlocked = false; + for (auto const& [bfrom, bto] : *blockedEdges) + { + if (bfrom == u && bto == v) + { + edgeBlocked = true; + break; + } + } + if (edgeBlocked) + continue; + } + + std::int64_t const w = edgeCostDijkstra(snap, u, e, dstAmount); + if (w >= kCostInf / 2 || res.dist[u] >= kCostInf - w) + continue; + + std::int64_t const newCost = res.dist[u] + w; + if (newCost < res.dist[v]) + { + res.dist[v] = newCost; + res.prev[v] = u; + pq.emplace(newCost, v); + } + } + } + + return res; +} + +std::vector +PayGraph::reconstructPath(DijkResult const& res, VID src, VID dst) +{ + if (dst >= res.dist.size() || res.dist[dst] >= kCostInf / 2) + return {}; // unreachable + + std::vector path; + for (VID v = dst; v != kNull; v = res.prev[v]) + { + path.push_back(v); + if (v == src) + break; + if (path.size() > res.dist.size()) + return {}; // cycle guard + } + + std::ranges::reverse(path); + return path; +} + +//============================================================================== +// Yen's K-Shortest Paths algorithm +// +// Finds up to k shortest simple paths from src to dst using Dijkstra as the +// shortest-path oracle. On a graph with V=1000, E=1000, k=6 this completes +// in well under 1 ms. +// +// Reference: Yen, J.Y. (1971). "Finding the K Shortest Loopless Paths in a +// Network". Management Science 17(11): 712–716. +//============================================================================== + +std::vector +PayGraph::kShortestPaths(Snapshot const& snap, VID src, VID dst, int k, STAmount const& dstAmount) +{ + uint32_t const n = static_cast(snap.assets.size()); + if (src >= n || dst >= n || k <= 0) + return {}; + + STAmount const* const pay = (dstAmount == beast::kZero) ? nullptr : &dstAmount; + + std::vector a; // confirmed k-shortest paths + a.reserve(k); + + // Candidate set: (rank, path) ordered by rank ascending (lower = better). + // rank = costToRank(signed log-sum) so negative costs order correctly. + using Candidate = std::pair>; + auto cmpCand = [](Candidate const& a, Candidate const& b) { + return a.first > b.first; // min-heap on rank + }; + std::priority_queue, decltype(cmpCand)> b(cmpCand); + + // Find the first (shortest) path. + { + auto res = dijkstra(snap, src, nullptr, nullptr, pay); + auto path = reconstructPath(res, src, dst); + if (path.empty()) + return {}; // no path at all + // Rank by true log-cost (unwrap Johnson reweighting). + b.emplace(costToRank(truePathCost(snap, src, dst, res.dist[dst])), std::move(path)); + } + + while (!b.empty() && static_cast(a.size()) < k) + { + auto [rank, prev] = b.top(); + b.pop(); + + // Deduplicate (same path may be inserted multiple times). + bool dup = false; + for (auto const& ap : a) + { + if (ap.vids == prev) + { + dup = true; + break; + } + } + if (dup) + continue; + + a.push_back({prev, rank}); + + if (static_cast(a.size()) == k) + break; + + // For each spur node along the accepted path (except the last node): + for (std::size_t i = 0; i + 1 < prev.size(); ++i) + { + VID const spurNode = prev[i]; + // Root path = prev[0..i] + std::vector const rootPath(prev.begin(), prev.begin() + i + 1); + + // Block vertices in the root path (except spurNode itself) to + // prevent spur paths from re-using the prefix (avoids cycles). + std::vector blockedVerts(n, false); + for (std::size_t j = 0; j < i; ++j) + blockedVerts[rootPath[j]] = true; + + // Block forward edges from spurNode that are already used by + // accepted paths sharing the same root prefix. This is the + // critical part of Yen's: without it, the oracle just finds the + // same path again instead of exploring alternatives. + BlockedEdges blockedEdges; + for (auto const& ap : a) + { + auto const& av = ap.vids; + if (av.size() > i + 1 && + std::equal( + av.begin(), + av.begin() + static_cast(i + 1), + rootPath.begin())) + { + blockedEdges.emplace_back(spurNode, av[i + 1]); + } + } + + auto res = dijkstra(snap, spurNode, &blockedVerts, &blockedEdges, pay); + auto spur = reconstructPath(res, spurNode, dst); + if (spur.empty()) + continue; + + // Full candidate path = rootPath + spur (excluding duplicate spurNode). + std::vector candidate = rootPath; + candidate.insert(candidate.end(), spur.begin() + 1, spur.end()); + + // Sum *true* signed log-costs (not reweighted) so ranking matches + // multiplicative rate composition across hop counts. + std::int64_t candidateCost = 0; + bool valid = true; + for (std::size_t j = 0; j + 1 < candidate.size(); ++j) + { + VID const u = candidate[j]; + VID const v = candidate[j + 1]; + if (u >= snap.adj.size()) + { + valid = false; + break; + } + std::int64_t best = kCostInf; + bool found = false; + for (auto const& e : snap.adj[u]) + { + if (e.to == v) + { + std::int64_t const w = edgeCostRaw(e, pay); + if (!found || w < best) + { + best = w; + found = true; + } + } + } + if (!found || best >= kEmptyBookCost / 2) + { + valid = false; + break; + } + if (candidateCost >= kCostInf - best) + { + valid = false; + break; + } + candidateCost += best; + } + if (valid) + b.emplace(costToRank(candidateCost), std::move(candidate)); + } + } + + return a; +} + +//============================================================================== +// PayGraph::findPaths() — convenience wrapper +//============================================================================== + +std::vector +PayGraph::findPaths(Asset const& src, Asset const& dst, int k, STAmount const& dstAmount) const +{ + auto s = snapshot(); + if (!s) + return {}; + + auto itSrc = s->index.find(src); + auto itDst = s->index.find(dst); + if (itSrc == s->index.end() || itDst == s->index.end()) + return {}; + + return kShortestPaths(*s, itSrc->second, itDst->second, k, dstAmount); +} + +} // namespace xrpl diff --git a/src/xrpld/rpc/detail/PayGraph.h b/src/xrpld/rpc/detail/PayGraph.h new file mode 100644 index 00000000000..61d741ba701 --- /dev/null +++ b/src/xrpld/rpc/detail/PayGraph.h @@ -0,0 +1,376 @@ +#pragma once + +//------------------------------------------------------------------------------ +/* + PayGraph — Persistent, incrementally-updated asset-exchange graph for + XRPL pathfinding. + + LIFETIME + -------- + PayGraph is created ONCE at startup (or after a long catchup) and lives + for the duration of the process. It is NOT rebuilt per ledger. Instead, + applyLedgerDelta() is called at each ledger close — typically updating + fewer than 100 edges in a few microseconds. + + ASSET-EXCHANGE GRAPH + -------------------- + Vertices = distinct assets (IOU {currency,issuer}, MPT IDs, XRP) + Edges = order books and AMM pools between asset pairs + Scale = ~500 vertices, ~1 000 edges on mainnet -> trivially small + + Trust-line rippling between issuers of the same currency is handled + implicitly by the XRPL payment engine (rippleCalculate) and does not + require explicit traversal here. + + WHY THIS HELPS + -------------- + The current Pathfinder BFS on the combined account+asset graph has + O(A^D) fanout (A ~ 20 trust-line neighbours, D ~ 7 hops) before any + pruning, then calls rippleCalculate on every one of up to 1 000 + candidates. + + New algorithm: + 1. Run Yen's K-Shortest on the tiny asset graph O((V+E) log V) + => microseconds, returns <=6 abstract asset-type paths + 2. Materialise each path into concrete offer-node STPath objects + 3. Call rippleCalculate only for the top <=6 candidates + 4. Emit first result to WebSocket subscriber immediately + + SNAPSHOT / COPY-ON-WRITE MODEL + -------------------------------- + Pathfinding threads and the ledger-close thread access the graph + concurrently. We use an atomic shared_ptr to an immutable Snapshot: + + Pathfinder thread: + auto snap = graph.snapshot(); + // Use snap freely -- no locks held. + + Ledger-close thread: + graph.applyLedgerDelta(newLedger, changedBooks); + // Copies current snapshot, patches changed edges O(C), + // atomically publishes new snapshot. Readers already + // holding the old snapshot are unaffected. + + Since the entire graph fits in ~50 KB, copying on each ledger close + is negligible. Pathfinders hold zero locks during search. + + INCREMENTAL UPDATE + ------------------ + applyLedgerDelta() receives the set of Book pairs that changed in the + ledger (derived from transaction metadata -- ltOFFER creates/consumed/ + cancelled and ltAMM changes). For each changed book it re-queries the + top-of-book offer in O(1) from the new ledger and updates qualityFixed + on the corresponding edge. No SHAMap walk is performed. + + Full rebuild is reserved for startup and long-catchup via rebuild(). +*/ +//------------------------------------------------------------------------------ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +class PayGraph +{ +public: + //-------------------------------------------------------------------------- + // Public types + //-------------------------------------------------------------------------- + + /// Opaque vertex identifier. Stable across incremental updates + /// (vertices are never removed once added; only edge weights change). + using VID = uint32_t; + static constexpr VID kNull = ~VID{0}; + + /// How value flows across this edge. + enum class EdgeKind : uint8_t { + OrderBook, ///< Best offer in an order book + AMM, ///< Constant-product AMM pool + }; + + /// One directed edge in the asset-exchange graph. + /// 16 bytes -- fits in one cache line. + struct Edge + { + VID to{}; ///< Receiving-asset vertex + uint32_t qualityFixed{}; ///< log2(cost_ratio) in 16.16 fixed-point, + /// stored as int32 bit-pattern (lower signed + /// value = cheaper). Path search *adds* + /// these so multi-hop cost = log2(∏ rates). + /// kNoLiquidity = structural empty book. + uint32_t liquidityLog{}; ///< log2(|sum(takerGets)|) as biased 16.16 + /// fixed-point: (log2(amount) + 64) * 65536. + /// Higher = more depth. 0 = empty/unknown. + EdgeKind kind{}; + }; + + static_assert(sizeof(Edge) == 16, "Edge must be 16 bytes"); + + /// Sentinel quality: edge structurally exists but has no current offers. + /// Dijkstra traverses these at maximum cost so they rank last; structural + /// presence is preserved because offers may exist in the ledger that + /// weren't visible when the snapshot was built. + static constexpr uint32_t kNoLiquidity = 0xFFFF'FFFFu; + + //-------------------------------------------------------------------------- + // Abstract path through the asset-exchange graph. + // vids[0] = source asset + // vids[1..n-2] = bridge assets + // vids[n-1] = destination asset + //-------------------------------------------------------------------------- + struct AssetPath + { + std::vector vids; + uint64_t cumQuality{}; ///< Rank of sum of log2 edge weights + ///< (lower = better). Encodes signed path + ///< cost via costToRank so multi-hop products + ///< compare correctly against direct paths. + }; + + //-------------------------------------------------------------------------- + // Diagnostic counters + //-------------------------------------------------------------------------- + struct Stats + { + uint32_t vertices{}; + uint32_t edges{}; + uint32_t orderBooks{}; + uint32_t ammPools{}; + uint32_t lastDeltaBooks{}; ///< Books patched in the most recent delta + uint32_t totalDeltasCalled{}; ///< Cumulative applyLedgerDelta() calls + }; + + //-------------------------------------------------------------------------- + // Immutable graph snapshot + // + // Pathfinder threads call graph.snapshot() to grab a shared_ptr to the + // current Snapshot and hold it for the duration of their search. + // The ledger-close thread atomically publishes a new Snapshot without + // blocking any in-flight readers. + // + // The Snapshot is tiny (~50 KB on mainnet): copying it at ledger-close + // is cheaper than any fine-grained locking scheme on per-edge data. + //-------------------------------------------------------------------------- + struct Snapshot + { + /// Adjacency list indexed by VID. adj[v] = outgoing edges from v. + std::vector> adj; + + /// Vertex -> Asset mapping (adj.size() == assets.size()). + std::vector assets; + + /// Asset -> VID for O(1) lookup. + hash_map index; + + /// Johnson potentials h[v] so reweighted edge costs + /// w'(u,v) = log2(rate) + h[u] - h[v] + /// are non-negative. Computed once per snapshot (build/delta), not + /// per pathfind, so Dijkstra stays O((V+E) log V) at query time. + std::vector potential; + + Stats stats; + }; + + //-------------------------------------------------------------------------- + // Lifecycle + //-------------------------------------------------------------------------- + + /// Full build from scratch. Called once at startup (or post-catchup). + /// Scans all order books known to bookDB and queries the ledger for AMM + /// pools. Complexity: O(B) where B = number of active books/pools. + static std::shared_ptr + build( + OrderBookDB& bookDB, + ReadView const& ledger, + std::optional const& domain, + beast::Journal j); + + PayGraph(PayGraph const&) = delete; + PayGraph& + operator=(PayGraph const&) = delete; + ~PayGraph() = default; + + //-------------------------------------------------------------------------- + // Incremental update -- call at each ledger close. + // + // changedBooks: Book pairs that had at least one offer created, consumed, + // or cancelled in the just-closed ledger. Callers derive this from + // AcceptedLedger transaction metadata: scan ltOFFER node changes and + // extract (takerPays.asset, takerGets.asset). Typically < 100 / ledger. + // + // For each changed book: + // * If offers remain: re-query top offer -> update qualityFixed. + // * If no offers remain: set qualityFixed = kNoLiquidity. + // * If the book / vertex did not exist yet: add it. + // + // A copy of the current Snapshot is made, patched, then atomically stored. + // Pathfinders holding the old Snapshot see a consistent graph throughout. + // + // Complexity: O(V + E) copy + O(C) book lookups + // where V, E ~ 1 000 and C ~ 100 -> well under 1 ms. + //-------------------------------------------------------------------------- + void + applyLedgerDelta( + OrderBookDB& bookDB, + ReadView const& newLedger, + std::vector const& changedBooks); + + /// Full rebuild. Safe to call at any time; replaces the snapshot + /// atomically like applyLedgerDelta() does. Prefer the delta path + /// for normal operation. + void + rebuild(OrderBookDB& bookDB, ReadView const& ledger, std::optional const& domain); + + //-------------------------------------------------------------------------- + // Snapshot access for pathfinding threads + // + // Grab ONCE at the start of a pathfinding request and keep it for the + // duration. The shared_ptr keeps the snapshot alive even if a new one + // is published mid-search. + //-------------------------------------------------------------------------- + std::shared_ptr + snapshot() const; + + //-------------------------------------------------------------------------- + // Vertex helpers (operate on current snapshot) + //-------------------------------------------------------------------------- + + VID + vertexOf(Asset const& asset) const; + + Asset const& + assetOf(VID v) const; + + //-------------------------------------------------------------------------- + // K-Shortest asset paths (Yen's algorithm over Dijkstra) + // + // snap -- snapshot obtained from snapshot() at the start of the request + // src/dst -- vertex IDs of source and destination assets + // k -- maximum number of paths to return + // dstAmount -- destination payment size for liquidity-aware ranking. + // When non-zero, edges whose book depth cannot cover this + // amount are penalized so thin top-of-book paths do not + // consume scarce k-shortest candidate slots. beast::kZero + // keeps pure top-of-book ranking (legacy behavior). + // + // Returns up to k paths ordered by ascending cumQuality (best first). + // Returns {} if no path exists between src and dst. + // + // Complexity: O(k * (V + E) log V) -- typically < 1 ms for k = 6. + //-------------------------------------------------------------------------- + static std::vector + kShortestPaths( + Snapshot const& snap, + VID src, + VID dst, + int k, + STAmount const& dstAmount = beast::kZero); + + /// Convenience: grab current snapshot and run kShortestPaths. + std::vector + findPaths(Asset const& src, Asset const& dst, int k, STAmount const& dstAmount = beast::kZero) + const; + + //-------------------------------------------------------------------------- + Stats + currentStats() const; + +private: + explicit PayGraph(std::optional const& domain, beast::Journal j); + + //-------------------------------------------------------------------------- + // Build / patch helpers + //-------------------------------------------------------------------------- + + /// Allocate a Snapshot populated from bookDB + ledger (no atomic store). + static std::shared_ptr + buildSnapshot( + OrderBookDB& bookDB, + ReadView const& ledger, + std::optional const& domain, + beast::Journal j); + + /// Ensure a vertex for 'asset' exists in snap. Returns its VID. + static VID + ensureVertex(Snapshot& snap, Asset const& asset); + + /// Find or create the directed edge (from -> to) of the given kind in snap. + /// Returns a reference into snap.adj so the caller can set qualityFixed. + static Edge& + ensureEdge(Snapshot& snap, VID from, VID to, EdgeKind kind); + + /// Query top-of-book quality from the ledger for a given order book. + /// Returns kNoLiquidity when no offers remain in the book. + static uint32_t + topOfBookQuality(ReadView const& ledger, Book const& book); + + /// Full-book depth as biased log2 fixed-point (see Edge::liquidityLog). + /// Sums takerGets across all offers so thin top-of-book rates can still + /// be scored against total available liquidity. + static uint32_t + bookLiquidityLog(ReadView const& ledger, Book const& book); + + //-------------------------------------------------------------------------- + // Shortest-path internals (Dijkstra over Johnson-reweighted log-weights) + //-------------------------------------------------------------------------- + struct DijkResult + { + std::vector dist; ///< min reweighted distance from src + std::vector prev; ///< predecessor (kNull = none) + }; + + /// A set of directed edges (from, to) to treat as absent during search. + using BlockedEdges = std::vector>; + + /// Recompute Snapshot::potential after edges change (build / ledger delta). + static void + computePotentials(Snapshot& snap); + + /// Binary-heap Dijkstra on non-negative reweighted log-costs. + static DijkResult + dijkstra( + Snapshot const& snap, + VID src, + std::vector const* blockedVerts = nullptr, + BlockedEdges const* blockedEdges = nullptr, + STAmount const* dstAmount = nullptr); + + static std::vector + reconstructPath(DijkResult const& res, VID src, VID dst); + + //-------------------------------------------------------------------------- + // State + //-------------------------------------------------------------------------- + + /// Current snapshot. Read via snapshot(); written only by writeMu_ holder. + /// Uses a plain shared_ptr + std::atomic_{load,store}_explicit with + /// explicit memory orders. C++20's std::atomic> would be + /// preferable but Apple libc++ has not yet implemented the specialisation, + /// so the deprecated free-function API is wrapped with a localised + /// diagnostic-suppression pragma in PayGraph.cpp. + mutable std::shared_ptr snap_; + + /// Serialises applyLedgerDelta() / rebuild() calls. + /// Never held during pathfinding. + std::mutex writeMu_; + + std::optional domain_; + beast::Journal j_; +}; + +} // namespace xrpl diff --git a/src/xrpld/rpc/detail/PayGraphDelta.h b/src/xrpld/rpc/detail/PayGraphDelta.h new file mode 100644 index 00000000000..1754ff96495 --- /dev/null +++ b/src/xrpld/rpc/detail/PayGraphDelta.h @@ -0,0 +1,135 @@ +#pragma once + +//------------------------------------------------------------------------------ +/* + PayGraphDelta — Extract changed order-book pairs from ledger metadata. + + Called by PathRequestManager at each ledger close to build the + changedBooks list passed to PayGraph::applyLedgerDelta(). + + For each transaction in the just-closed ledger, any ltOFFER node that + was created, modified, or deleted tells us that a specific order book + (takerPays.asset, takerGets.asset) may have changed its top-of-book + quality. We collect those Book pairs and deduplicate them. + + This replaces the full SHAMap walk that OrderBookDBImpl::update() + currently does — cost drops from O(ledger_size) to O(tx_count). + + The parsing logic mirrors OrderBookDBImpl::processTxn() which is the + canonical example of reading offer changes from transaction metadata. +*/ +//------------------------------------------------------------------------------ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl { + +/// Extract the set of order-book pairs that were touched by any transaction +/// metadata node array. The result is deduplicated. +/// +/// Matches the logic in OrderBookDBImpl::processTxn(). +inline std::vector +extractChangedBooks(STArray const& nodes, std::optional const& /*domain*/) +{ + std::vector result; + + for (STObject const& node : nodes) + { + try + { + if (node.getFieldU16(sfLedgerEntryType) != ltOFFER) + continue; + + // Determine which sub-field holds the TakerPays / TakerGets. + // sfCreatedNode -> sfNewFields + // sfModifiedNode -> sfPreviousFields (before the change) + // or sfFinalFields (after the change) + // sfDeletedNode -> sfFinalFields + // We want the *resulting* state to determine the new top-of-book + // after this ledger closes, so we prefer sfFinalFields / sfNewFields. + SField const* subField = nullptr; + SField const& nodeName = node.getFName(); + if (nodeName == sfCreatedNode) + { + subField = &sfNewFields; + } + else if (nodeName == sfModifiedNode || nodeName == sfDeletedNode) + { + subField = &sfFinalFields; + } + + if (subField == nullptr) + continue; + + auto const* data = dynamic_cast(node.peekAtPField(*subField)); + if (data == nullptr) + continue; + if (!data->isFieldPresent(sfTakerPays) || !data->isFieldPresent(sfTakerGets)) + continue; + + Book const book{ + data->getFieldAmount(sfTakerPays).asset(), + data->getFieldAmount(sfTakerGets).asset(), + std::nullopt}; + + // Deduplicate. + bool dup = false; + for (auto const& b : result) + { + if (b.in == book.in && b.out == book.out) + { + dup = true; + break; + } + } + if (!dup) + result.push_back(book); + } + catch (std::exception const&) // NOLINT(bugprone-empty-catch) + { + // Malformed metadata node — skip safely. + } + } + + return result; +} + +/// Convenience overload that accepts a TxMeta directly. +inline std::vector +extractChangedBooks(TxMeta const& meta, std::optional const& domain) +{ + return extractChangedBooks(meta.getNodes(), domain); +} + +/// Merge src into dest, deduplicating across both. +inline void +mergeBooks(std::vector& dest, std::vector const& src) +{ + for (auto const& b : src) + { + bool dup = false; + for (auto const& d : dest) + { + if (d.in == b.in && d.out == b.out) + { + dup = true; + break; + } + } + if (!dup) + dest.push_back(b); + } +} + +} // namespace xrpl diff --git a/src/xrpld/rpc/detail/TransactionSign.cpp b/src/xrpld/rpc/detail/TransactionSign.cpp index e1c5180b5c8..7ed9898b471 100644 --- a/src/xrpld/rpc/detail/TransactionSign.cpp +++ b/src/xrpld/rpc/detail/TransactionSign.cpp @@ -8,7 +8,7 @@ #include #include #include -#include +#include #include #include @@ -302,24 +302,16 @@ checkPayment( if (auto ledger = app.getOpenLedger().current()) { - Pathfinder pf( - std::make_shared(ledger, app.getJournal("AssetCache")), + // 4 is the maximum number of paths to return. + result = app.getPathRequestManager().findPaths( + ledger, srcAddressID, *dstAccountID, + amount, sendMax.asset(), sendMax.getIssuer(), - amount, - std::nullopt, domain, - app); - if (pf.findPaths(app.config().pathSearchOld)) - { - // 4 is the maximum paths - pf.computePathRanks(4); - STPath fullLiquidityPath; - STPathSet const paths; - result = pf.getBestPaths(4, fullLiquidityPath, paths, sendMax.getIssuer()); - } + 4); } auto j = app.getJournal("RPCHandler"); diff --git a/src/xrpld/rpc/handlers/orderbook/PathFind.cpp b/src/xrpld/rpc/handlers/orderbook/PathFind.cpp index 3a6b52ee985..29008b57e32 100644 --- a/src/xrpld/rpc/handlers/orderbook/PathFind.cpp +++ b/src/xrpld/rpc/handlers/orderbook/PathFind.cpp @@ -15,7 +15,7 @@ namespace xrpl { json::Value doPathFind(RPC::JsonContext& context) { - if (context.app.config().pathSearchMax == 0) + if (!context.app.config().pathSearch) return rpcError(RpcNotSupported); auto lpLedger = context.ledgerMaster.getClosedLedger(); diff --git a/src/xrpld/rpc/handlers/orderbook/RipplePathFind.cpp b/src/xrpld/rpc/handlers/orderbook/RipplePathFind.cpp index b7edfb6dbe3..0acd82b2f4c 100644 --- a/src/xrpld/rpc/handlers/orderbook/RipplePathFind.cpp +++ b/src/xrpld/rpc/handlers/orderbook/RipplePathFind.cpp @@ -23,7 +23,7 @@ namespace xrpl { json::Value doRipplePathFind(RPC::JsonContext& context) { - if (context.app.config().pathSearchMax == 0) + if (!context.app.config().pathSearch) return rpcError(RpcNotSupported); context.loadType = Resource::kFeeHeavyBurdenRpc;