diff --git a/include/xrpl/protocol/Book.h b/include/xrpl/protocol/Book.h index 0a04deb2771..ba5a2e8232f 100644 --- a/include/xrpl/protocol/Book.h +++ b/include/xrpl/protocol/Book.h @@ -21,6 +21,7 @@ #define RIPPLE_PROTOCOL_BOOK_H_INCLUDED #include +#include #include #include @@ -36,12 +37,17 @@ class Book final : public CountedObject public: Issue in; Issue out; + std::optional domain; Book() { } - Book(Issue const& in_, Issue const& out_) : in(in_), out(out_) + Book( + Issue const& in_, + Issue const& out_, + std::optional const& domain_ = std::nullopt) + : in(in_), out(out_), domain(domain_) { } }; @@ -61,6 +67,8 @@ hash_append(Hasher& h, Book const& b) { using beast::hash_append; hash_append(h, b.in, b.out); + if (b.domain) + hash_append(h, *(b.domain)); } Book @@ -71,7 +79,8 @@ reversed(Book const& book); [[nodiscard]] inline constexpr bool operator==(Book const& lhs, Book const& rhs) { - return (lhs.in == rhs.in) && (lhs.out == rhs.out); + return (lhs.in == rhs.in) && (lhs.out == rhs.out) && + (lhs.domain == rhs.domain); } /** @} */ @@ -82,7 +91,18 @@ operator<=>(Book const& lhs, Book const& rhs) { if (auto const c{lhs.in <=> rhs.in}; c != 0) return c; - return lhs.out <=> rhs.out; + if (auto const c{lhs.out <=> rhs.out}; c != 0) + return c; + + // Manually compare optionals + if (lhs.domain && rhs.domain) + return *lhs.domain <=> *rhs.domain; // Compare values if both exist + if (!lhs.domain && rhs.domain) + return std::weak_ordering::less; // Empty is considered less + if (lhs.domain && !rhs.domain) + return std::weak_ordering::greater; // Non-empty is greater + + return std::weak_ordering::equivalent; // Both are empty } /** @} */ @@ -126,9 +146,11 @@ template <> struct hash { private: - using hasher = std::hash; + using issue_hasher = std::hash; + using uint256_hasher = ripple::uint256::hasher; - hasher m_hasher; + issue_hasher m_issue_hasher; + uint256_hasher m_uint256_hasher; public: explicit hash() = default; @@ -139,8 +161,12 @@ struct hash value_type operator()(argument_type const& value) const { - value_type result(m_hasher(value.in)); - boost::hash_combine(result, m_hasher(value.out)); + value_type result(m_issue_hasher(value.in)); + boost::hash_combine(result, m_issue_hasher(value.out)); + + if (value.domain) + boost::hash_combine(result, m_uint256_hasher(*value.domain)); + return result; } }; diff --git a/include/xrpl/protocol/ErrorCodes.h b/include/xrpl/protocol/ErrorCodes.h index 2e7cb3b410f..19b70ebd1d7 100644 --- a/include/xrpl/protocol/ErrorCodes.h +++ b/include/xrpl/protocol/ErrorCodes.h @@ -154,7 +154,10 @@ enum error_code_i { // Simulate rpcTX_SIGNED = 96, - rpcLAST = rpcTX_SIGNED // rpcLAST should always equal the last code. + // Pathfinding + rpcDOMAIN_MALFORMED = 97, + + rpcLAST = rpcDOMAIN_MALFORMED // rpcLAST should always equal the last code. }; /** Codes returned in the `warnings` array of certain RPC commands. diff --git a/include/xrpl/protocol/LedgerFormats.h b/include/xrpl/protocol/LedgerFormats.h index 5f3cca53ac8..bb20878e18a 100644 --- a/include/xrpl/protocol/LedgerFormats.h +++ b/include/xrpl/protocol/LedgerFormats.h @@ -152,6 +152,7 @@ enum LedgerSpecificFlags { // ltOFFER lsfPassive = 0x00010000, lsfSell = 0x00020000, // True, offer was placed as a sell. + lsfHybrid = 0x00040000, // True, offer is hybrid. // ltRIPPLE_STATE lsfLowReserve = 0x00010000, // True, if entry counts toward reserve. diff --git a/include/xrpl/protocol/TxFlags.h b/include/xrpl/protocol/TxFlags.h index e94e79aee5e..56bde9f2040 100644 --- a/include/xrpl/protocol/TxFlags.h +++ b/include/xrpl/protocol/TxFlags.h @@ -97,8 +97,9 @@ constexpr std::uint32_t tfPassive = 0x00010000; constexpr std::uint32_t tfImmediateOrCancel = 0x00020000; constexpr std::uint32_t tfFillOrKill = 0x00040000; constexpr std::uint32_t tfSell = 0x00080000; +constexpr std::uint32_t tfHybrid = 0x00100000; constexpr std::uint32_t tfOfferCreateMask = - ~(tfUniversal | tfPassive | tfImmediateOrCancel | tfFillOrKill | tfSell); + ~(tfUniversal | tfPassive | tfImmediateOrCancel | tfFillOrKill | tfSell | tfHybrid); // Payment flags: constexpr std::uint32_t tfNoRippleDirect = 0x00010000; diff --git a/include/xrpl/protocol/detail/features.macro b/include/xrpl/protocol/detail/features.macro index ac393eae985..2e401416383 100644 --- a/include/xrpl/protocol/detail/features.macro +++ b/include/xrpl/protocol/detail/features.macro @@ -32,6 +32,7 @@ // If you add an amendment here, then do not forget to increment `numFeatures` // in include/xrpl/protocol/Feature.h. +XRPL_FEATURE(PermissionedDEX, Supported::yes, VoteBehavior::DefaultNo) XRPL_FIX (PayChanCancelAfter, Supported::yes, VoteBehavior::DefaultNo) // Check flags in Credential transactions XRPL_FIX (InvalidTxFlags, Supported::yes, VoteBehavior::DefaultNo) diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index 5a652baf4f7..35308de09a8 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -187,6 +187,7 @@ LEDGER_ENTRY(ltDIR_NODE, 0x0064, DirectoryNode, directory, ({ {sfNFTokenID, soeOPTIONAL}, {sfPreviousTxnID, soeOPTIONAL}, {sfPreviousTxnLgrSeq, soeOPTIONAL}, + {sfDomainID, soeOPTIONAL} })) /** The ledger object which lists details about amendments on the network. @@ -248,6 +249,8 @@ LEDGER_ENTRY(ltOFFER, 0x006f, Offer, offer, ({ {sfPreviousTxnID, soeREQUIRED}, {sfPreviousTxnLgrSeq, soeREQUIRED}, {sfExpiration, soeOPTIONAL}, + {sfDomainID, soeOPTIONAL}, + {sfAdditionalBooks, soeOPTIONAL}, })) /** A ledger object which describes a deposit preauthorization. diff --git a/include/xrpl/protocol/detail/sfields.macro b/include/xrpl/protocol/detail/sfields.macro index 3217bab9134..d63295a9ab3 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -327,6 +327,7 @@ UNTYPED_SFIELD(sfSignerEntry, OBJECT, 11) UNTYPED_SFIELD(sfNFToken, OBJECT, 12) UNTYPED_SFIELD(sfEmitDetails, OBJECT, 13) UNTYPED_SFIELD(sfHook, OBJECT, 14) +UNTYPED_SFIELD(sfBook, OBJECT, 15) // inner object (uncommon) UNTYPED_SFIELD(sfSigner, OBJECT, 16) @@ -362,6 +363,7 @@ UNTYPED_SFIELD(sfMemos, ARRAY, 9) UNTYPED_SFIELD(sfNFTokens, ARRAY, 10) UNTYPED_SFIELD(sfHooks, ARRAY, 11) UNTYPED_SFIELD(sfVoteSlots, ARRAY, 12) +UNTYPED_SFIELD(sfAdditionalBooks, ARRAY, 13) // array of objects (uncommon) UNTYPED_SFIELD(sfMajorities, ARRAY, 16) diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index dd3ac42325d..9bb123ddc27 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -38,6 +38,7 @@ TRANSACTION(ttPAYMENT, 0, Payment, ({ {sfDestinationTag, soeOPTIONAL}, {sfDeliverMin, soeOPTIONAL, soeMPTSupported}, {sfCredentialIDs, soeOPTIONAL}, + {sfDomainID, soeOPTIONAL}, })) /** This transaction type creates an escrow object. */ @@ -93,6 +94,7 @@ TRANSACTION(ttOFFER_CREATE, 7, OfferCreate, ({ {sfTakerGets, soeREQUIRED}, {sfExpiration, soeOPTIONAL}, {sfOfferSequence, soeOPTIONAL}, + {sfDomainID, soeOPTIONAL}, })) /** This transaction type cancels existing offers to trade one asset for another. */ @@ -499,4 +501,3 @@ TRANSACTION(ttUNL_MODIFY, 102, UNLModify, ({ {sfLedgerSequence, soeREQUIRED}, {sfUNLModifyValidator, soeREQUIRED}, })) - diff --git a/src/libxrpl/protocol/ErrorCodes.cpp b/src/libxrpl/protocol/ErrorCodes.cpp index 93e30f24bea..41cb671cc2b 100644 --- a/src/libxrpl/protocol/ErrorCodes.cpp +++ b/src/libxrpl/protocol/ErrorCodes.cpp @@ -115,7 +115,8 @@ constexpr static ErrorInfo unorderedErrorInfos[]{ {rpcUNKNOWN_COMMAND, "unknownCmd", "Unknown method.", 405}, {rpcORACLE_MALFORMED, "oracleMalformed", "Oracle request is malformed.", 400}, {rpcBAD_CREDENTIALS, "badCredentials", "Credentials do not exist, are not accepted, or have expired.", 400}, - {rpcTX_SIGNED, "transactionSigned", "Transaction should not be signed.", 400}}; + {rpcTX_SIGNED, "transactionSigned", "Transaction should not be signed.", 400}, + {rpcDOMAIN_MALFORMED, "domainMalformed", "Domain is malformed", 400}}; // clang-format on // Sort and validate unorderedErrorInfos at compile time. Should be diff --git a/src/libxrpl/protocol/Indexes.cpp b/src/libxrpl/protocol/Indexes.cpp index fe8f0c4778d..5f87aa4a625 100644 --- a/src/libxrpl/protocol/Indexes.cpp +++ b/src/libxrpl/protocol/Indexes.cpp @@ -115,12 +115,19 @@ getBookBase(Book const& book) XRPL_ASSERT( isConsistent(book), "ripple::getBookBase : input is consistent"); - auto const index = indexHash( - LedgerNameSpace::BOOK_DIR, - book.in.currency, - book.out.currency, - book.in.account, - book.out.account); + auto const index = book.domain ? indexHash( + LedgerNameSpace::BOOK_DIR, + book.in.currency, + book.out.currency, + book.in.account, + book.out.account, + *(book.domain)) + : indexHash( + LedgerNameSpace::BOOK_DIR, + book.in.currency, + book.out.currency, + book.in.account, + book.out.account); // Return with quality 0. auto k = keylet::quality({ltDIR_NODE, index}, 0); diff --git a/src/libxrpl/protocol/InnerObjectFormats.cpp b/src/libxrpl/protocol/InnerObjectFormats.cpp index 87abcc23516..fb9b9266f6f 100644 --- a/src/libxrpl/protocol/InnerObjectFormats.cpp +++ b/src/libxrpl/protocol/InnerObjectFormats.cpp @@ -154,6 +154,13 @@ InnerObjectFormats::InnerObjectFormats() {sfIssuer, soeREQUIRED}, {sfCredentialType, soeREQUIRED}, }); + + add(sfBook.jsonName, + sfBook.getCode(), + { + {sfBookDirectory, soeREQUIRED}, + {sfBookNode, soeREQUIRED}, + }); } InnerObjectFormats const& diff --git a/src/test/app/AMMExtended_test.cpp b/src/test/app/AMMExtended_test.cpp index a9d0514e3c5..1e862fe8c31 100644 --- a/src/test/app/AMMExtended_test.cpp +++ b/src/test/app/AMMExtended_test.cpp @@ -2156,6 +2156,7 @@ struct AMMExtended_test : public jtx::AMMTest OfferCrossing::no, std::nullopt, smax, + std::nullopt, flowJournal); }(); diff --git a/src/test/app/CrossingLimits_test.cpp b/src/test/app/CrossingLimits_test.cpp index 1e19a178c26..cef0b03399a 100644 --- a/src/test/app/CrossingLimits_test.cpp +++ b/src/test/app/CrossingLimits_test.cpp @@ -558,8 +558,11 @@ class CrossingLimits_test : public beast::unit_test::suite using namespace jtx; auto const sa = supported_amendments(); testAll(sa); - testAll(sa - featureFlowSortStrands); - testAll(sa - featureFlowCross - featureFlowSortStrands); + testAll(sa - featurePermissionedDEX); + testAll(sa - featureFlowSortStrands - featurePermissionedDEX); + testAll( + sa - featureFlowCross - featureFlowSortStrands - + featurePermissionedDEX); } }; diff --git a/src/test/app/DeliverMin_test.cpp b/src/test/app/DeliverMin_test.cpp index b079b936803..4ee7c9c72e0 100644 --- a/src/test/app/DeliverMin_test.cpp +++ b/src/test/app/DeliverMin_test.cpp @@ -143,7 +143,9 @@ class DeliverMin_test : public beast::unit_test::suite { using namespace jtx; auto const sa = supported_amendments(); - test_convert_all_of_an_asset(sa - featureFlowCross); + test_convert_all_of_an_asset( + sa - featureFlowCross - featurePermissionedDEX); + test_convert_all_of_an_asset(sa - featurePermissionedDEX); test_convert_all_of_an_asset(sa); } }; diff --git a/src/test/app/Discrepancy_test.cpp b/src/test/app/Discrepancy_test.cpp index 8e306282a73..bc72b2fd168 100644 --- a/src/test/app/Discrepancy_test.cpp +++ b/src/test/app/Discrepancy_test.cpp @@ -147,7 +147,8 @@ class Discrepancy_test : public beast::unit_test::suite { using namespace test::jtx; auto const sa = supported_amendments(); - testXRPDiscrepancy(sa - featureFlowCross); + testXRPDiscrepancy(sa - featureFlowCross - featurePermissionedDEX); + testXRPDiscrepancy(sa - featurePermissionedDEX); testXRPDiscrepancy(sa); } }; diff --git a/src/test/app/Flow_test.cpp b/src/test/app/Flow_test.cpp index ae65432ac7e..d0b8686db61 100644 --- a/src/test/app/Flow_test.cpp +++ b/src/test/app/Flow_test.cpp @@ -494,6 +494,7 @@ struct Flow_test : public beast::unit_test::suite OfferCrossing::no, std::nullopt, smax, + std::nullopt, flowJournal); }(); @@ -1475,7 +1476,8 @@ struct Flow_test : public beast::unit_test::suite using namespace jtx; auto const sa = supported_amendments(); - testWithFeats(sa - featureFlowCross); + testWithFeats(sa - featureFlowCross - featurePermissionedDEX); + testWithFeats(sa - featurePermissionedDEX); testWithFeats(sa); testEmptyStrand(sa); } @@ -1490,13 +1492,16 @@ struct Flow_manual_test : public Flow_test auto const all = supported_amendments(); FeatureBitset const flowCross{featureFlowCross}; FeatureBitset const f1513{fix1513}; + FeatureBitset const permDex{featurePermissionedDEX}; - testWithFeats(all - flowCross - f1513); - testWithFeats(all - flowCross); - testWithFeats(all - f1513); + testWithFeats(all - flowCross - f1513 - permDex); + testWithFeats(all - flowCross - permDex); + testWithFeats(all - f1513 - permDex); + testWithFeats(all - permDex); testWithFeats(all); - testEmptyStrand(all - f1513); + testEmptyStrand(all - f1513 - permDex); + testEmptyStrand(all - permDex); testEmptyStrand(all); } }; diff --git a/src/test/app/Freeze_test.cpp b/src/test/app/Freeze_test.cpp index 36578cbc6b3..b28e7946888 100644 --- a/src/test/app/Freeze_test.cpp +++ b/src/test/app/Freeze_test.cpp @@ -2020,9 +2020,11 @@ class Freeze_test : public beast::unit_test::suite }; using namespace test::jtx; auto const sa = supported_amendments(); - testAll(sa - featureFlowCross - featureDeepFreeze); - testAll(sa - featureFlowCross); - testAll(sa - featureDeepFreeze); + testAll( + sa - featureFlowCross - featureDeepFreeze - featurePermissionedDEX); + testAll(sa - featureFlowCross - featurePermissionedDEX); + testAll(sa - featureDeepFreeze - featurePermissionedDEX); + testAll(sa - featurePermissionedDEX); testAll(sa); } }; diff --git a/src/test/app/Offer_test.cpp b/src/test/app/Offer_test.cpp index 4da8d8101ef..0891b27df8b 100644 --- a/src/test/app/Offer_test.cpp +++ b/src/test/app/Offer_test.cpp @@ -5419,13 +5419,16 @@ class OfferBaseUtil_test : public beast::unit_test::suite static FeatureBitset const immediateOfferKilled{ featureImmediateOfferKilled}; FeatureBitset const fillOrKill{fixFillOrKill}; - - static std::array const feats{ - all - takerDryOffer - immediateOfferKilled, - all - flowCross - takerDryOffer - immediateOfferKilled, - all - flowCross - immediateOfferKilled, - all - rmSmallIncreasedQOffers - immediateOfferKilled - fillOrKill, - all - fillOrKill, + FeatureBitset const permDEX{featurePermissionedDEX}; + + static std::array const feats{ + all - takerDryOffer - immediateOfferKilled - permDEX, + all - flowCross - takerDryOffer - immediateOfferKilled - permDEX, + all - flowCross - immediateOfferKilled - permDEX, + all - rmSmallIncreasedQOffers - immediateOfferKilled - fillOrKill - + permDEX, + all - fillOrKill - permDEX, + all - permDEX, all}; if (BEAST_EXPECT(instance < feats.size())) @@ -5479,12 +5482,21 @@ class OfferWOFillOrKill_test : public OfferBaseUtil_test } }; +class OfferWOPermDEX_test : public OfferBaseUtil_test +{ + void + run() override + { + OfferBaseUtil_test::run(5); + } +}; + class OfferAllFeatures_test : public OfferBaseUtil_test { void run() override { - OfferBaseUtil_test::run(5, true); + OfferBaseUtil_test::run(6, true); } }; @@ -5500,14 +5512,16 @@ class Offer_manual_test : public OfferBaseUtil_test FeatureBitset const immediateOfferKilled{featureImmediateOfferKilled}; FeatureBitset const takerDryOffer{fixTakerDryOfferRemoval}; FeatureBitset const fillOrKill{fixFillOrKill}; + FeatureBitset const permDEX{featurePermissionedDEX}; - testAll(all - flowCross - f1513 - immediateOfferKilled); - testAll(all - flowCross - immediateOfferKilled); - testAll(all - immediateOfferKilled - fillOrKill); - testAll(all - fillOrKill); + testAll(all - flowCross - f1513 - immediateOfferKilled - permDEX); + testAll(all - flowCross - immediateOfferKilled - permDEX); + testAll(all - immediateOfferKilled - fillOrKill - permDEX); + testAll(all - fillOrKill - permDEX); + testAll(all - permDEX); testAll(all); - testAll(all - flowCross - takerDryOffer); + testAll(all - flowCross - takerDryOffer - permDEX); } }; @@ -5516,6 +5530,7 @@ BEAST_DEFINE_TESTSUITE_PRIO(OfferWOFlowCross, tx, ripple, 2); BEAST_DEFINE_TESTSUITE_PRIO(OfferWTakerDryOffer, tx, ripple, 2); BEAST_DEFINE_TESTSUITE_PRIO(OfferWOSmallQOffers, tx, ripple, 2); BEAST_DEFINE_TESTSUITE_PRIO(OfferWOFillOrKill, tx, ripple, 2); +BEAST_DEFINE_TESTSUITE_PRIO(OfferWOPermDEX, tx, ripple, 2); BEAST_DEFINE_TESTSUITE_PRIO(OfferAllFeatures, tx, ripple, 2); BEAST_DEFINE_TESTSUITE_MANUAL_PRIO(Offer_manual, tx, ripple, 20); diff --git a/src/test/app/Path_test.cpp b/src/test/app/Path_test.cpp index f325b0d2be9..6ff22a5dc76 100644 --- a/src/test/app/Path_test.cpp +++ b/src/test/app/Path_test.cpp @@ -18,11 +18,12 @@ //============================================================================== #include +#include +#include #include +#include -#include #include -#include #include #include #include @@ -34,7 +35,12 @@ #include #include +#include +#include #include +#include +#include +#include namespace ripple { namespace test { @@ -126,7 +132,8 @@ class Path_test : public beast::unit_test::suite jtx::Account const& dst, STAmount const& saDstAmount, std::optional const& saSendMax = std::nullopt, - std::optional const& saSrcCurrency = std::nullopt) + std::optional const& saSrcCurrency = std::nullopt, + std::optional const& domain = std::nullopt) { using namespace jtx; @@ -163,6 +170,8 @@ class Path_test : public beast::unit_test::suite j[jss::currency] = to_string(saSrcCurrency.value()); sc.append(j); } + if (domain) + params[jss::domain] = to_string(*domain); Json::Value result; gate g; @@ -187,10 +196,11 @@ class Path_test : public beast::unit_test::suite jtx::Account const& dst, STAmount const& saDstAmount, std::optional const& saSendMax = std::nullopt, - std::optional const& saSrcCurrency = std::nullopt) + std::optional const& saSrcCurrency = std::nullopt, + std::optional const& domain = std::nullopt) { Json::Value result = find_paths_request( - env, src, dst, saDstAmount, saSendMax, saSrcCurrency); + env, src, dst, saDstAmount, saSendMax, saSrcCurrency, domain); BEAST_EXPECT(!result.isMember(jss::error)); STAmount da; @@ -363,9 +373,11 @@ class Path_test : public beast::unit_test::suite } void - path_find() + path_find(bool const domainEnabled) { - testcase("path find"); + testcase( + std::string("path find") + (domainEnabled ? " w/ " : " w/o ") + + "domain"); using namespace jtx; Env env = pathTestEnv(); auto const gw = Account("gateway"); @@ -377,31 +389,50 @@ class Path_test : public beast::unit_test::suite env(pay(gw, "alice", USD(70))); env(pay(gw, "bob", USD(50))); + std::optional domainID; + if (domainEnabled) + domainID = setupDomain(env, {"alice", "bob", gw}); + STPathSet st; STAmount sa; - std::tie(st, sa, std::ignore) = - find_paths(env, "alice", "bob", Account("bob")["USD"](5)); + std::tie(st, sa, std::ignore) = find_paths( + env, + "alice", + "bob", + Account("bob")["USD"](5), + std::nullopt, + std::nullopt, + domainID); BEAST_EXPECT(same(st, stpath("gateway"))); BEAST_EXPECT(equal(sa, Account("alice")["USD"](5))); } void - xrp_to_xrp() + xrp_to_xrp(bool const domainEnabled) { using namespace jtx; - testcase("XRP to XRP"); + testcase( + std::string("XRP to XRP") + (domainEnabled ? " w/ " : " w/o ") + + "domain"); Env env = pathTestEnv(); env.fund(XRP(10000), "alice", "bob"); env.close(); - auto const result = find_paths(env, "alice", "bob", XRP(5)); + std::optional domainID; + if (domainEnabled) + domainID = setupDomain(env, {"alice", "bob"}); + + auto const result = find_paths( + env, "alice", "bob", XRP(5), std::nullopt, std::nullopt, domainID); BEAST_EXPECT(std::get<0>(result).empty()); } void - path_find_consume_all() + path_find_consume_all(bool const domainEnabled) { - testcase("path find consume all"); + testcase( + std::string("path find consume all") + + (domainEnabled ? " w/ " : " w/o ") + "domain"); using namespace jtx; { @@ -414,11 +445,22 @@ class Path_test : public beast::unit_test::suite env.trust(Account("alice")["USD"](100), "dan"); env.trust(Account("dan")["USD"](100), "edward"); + std::optional domainID; + if (domainEnabled) + domainID = setupDomain( + env, {"alice", "bob", "carol", "dan", "edward"}); + STPathSet st; STAmount sa; STAmount da; std::tie(st, sa, da) = find_paths( - env, "alice", "edward", Account("edward")["USD"](-1)); + env, + "alice", + "edward", + Account("edward")["USD"](-1), + std::nullopt, + std::nullopt, + domainID); BEAST_EXPECT(same(st, stpath("dan"), stpath("bob", "carol"))); BEAST_EXPECT(equal(sa, Account("alice")["USD"](110))); BEAST_EXPECT(equal(da, Account("edward")["USD"](110))); @@ -431,8 +473,22 @@ class Path_test : public beast::unit_test::suite env.fund(XRP(10000), "alice", "bob", "carol", gw); env.close(); env.trust(USD(100), "bob", "carol"); + env.close(); env(pay(gw, "carol", USD(100))); - env(offer("carol", XRP(100), USD(100))); + env.close(); + + std::optional domainID; + if (domainEnabled) + { + domainID = + setupDomain(env, {"alice", "bob", "carol", "gateway"}); + env(offer("carol", XRP(100), USD(100)), domain(*domainID)); + } + else + { + env(offer("carol", XRP(100), USD(100))); + } + env.close(); STPathSet st; STAmount sa; @@ -442,23 +498,44 @@ class Path_test : public beast::unit_test::suite "alice", "bob", Account("bob")["AUD"](-1), - std::optional(XRP(100000000))); + std::optional(XRP(1000000)), + std::nullopt, + domainID); BEAST_EXPECT(st.empty()); std::tie(st, sa, da) = find_paths( env, "alice", "bob", Account("bob")["USD"](-1), - std::optional(XRP(100000000))); + std::optional(XRP(1000000)), + std::nullopt, + domainID); BEAST_EXPECT(sa == XRP(100)); BEAST_EXPECT(equal(da, Account("bob")["USD"](100))); + + // if domain is used, finding path in the open offerbook will return + // empty result + if (domainEnabled) + { + std::tie(st, sa, da) = find_paths( + env, + "alice", + "bob", + Account("bob")["USD"](-1), + std::optional(XRP(1000000)), + std::nullopt, + std::nullopt); // not specifying a domain + BEAST_EXPECT(st.empty()); + } } } void - alternative_path_consume_both() + alternative_path_consume_both(bool const domainEnabled) { - testcase("alternative path consume both"); + testcase( + std::string("alternative path consume both") + + (domainEnabled ? " w/ " : " w/o ") + "domain"); using namespace jtx; Env env = pathTestEnv(); auto const gw = Account("gateway"); @@ -471,10 +548,26 @@ class Path_test : public beast::unit_test::suite env.trust(gw2_USD(800), "alice"); env.trust(USD(700), "bob"); env.trust(gw2_USD(900), "bob"); - env(pay(gw, "alice", USD(70))); - env(pay(gw2, "alice", gw2_USD(70))); - env(pay("alice", "bob", Account("bob")["USD"](140)), - paths(Account("alice")["USD"])); + + std::optional domainID; + if (domainEnabled) + { + domainID = + setupDomain(env, {"alice", "bob", "gateway", "gateway2"}); + env(pay(gw, "alice", USD(70)), domain(*domainID)); + env(pay(gw2, "alice", gw2_USD(70)), domain(*domainID)); + env(pay("alice", "bob", Account("bob")["USD"](140)), + paths(Account("alice")["USD"]), + domain(*domainID)); + } + else + { + env(pay(gw, "alice", USD(70))); + env(pay(gw2, "alice", gw2_USD(70))); + env(pay("alice", "bob", Account("bob")["USD"](140)), + paths(Account("alice")["USD"])); + } + env.require(balance("alice", USD(0))); env.require(balance("alice", gw2_USD(0))); env.require(balance("bob", USD(70))); @@ -486,9 +579,11 @@ class Path_test : public beast::unit_test::suite } void - alternative_paths_consume_best_transfer() + alternative_paths_consume_best_transfer(bool const domainEnabled) { - testcase("alternative paths consume best transfer"); + testcase( + std::string("alternative paths consume best transfer") + + (domainEnabled ? " w/ " : " w/o ") + "domain"); using namespace jtx; Env env = pathTestEnv(); auto const gw = Account("gateway"); @@ -502,9 +597,22 @@ class Path_test : public beast::unit_test::suite env.trust(gw2_USD(800), "alice"); env.trust(USD(700), "bob"); env.trust(gw2_USD(900), "bob"); - env(pay(gw, "alice", USD(70))); - env(pay(gw2, "alice", gw2_USD(70))); - env(pay("alice", "bob", USD(70))); + + std::optional domainID; + if (domainEnabled) + { + domainID = + setupDomain(env, {"alice", "bob", "gateway", "gateway2"}); + env(pay(gw, "alice", USD(70)), domain(*domainID)); + env(pay(gw2, "alice", gw2_USD(70)), domain(*domainID)); + env(pay("alice", "bob", USD(70)), domain(*domainID)); + } + else + { + env(pay(gw, "alice", USD(70))); + env(pay(gw2, "alice", gw2_USD(70))); + env(pay("alice", "bob", USD(70))); + } env.require(balance("alice", USD(0))); env.require(balance("alice", gw2_USD(70))); env.require(balance("bob", USD(70))); @@ -548,9 +656,13 @@ class Path_test : public beast::unit_test::suite } void - alternative_paths_limit_returned_paths_to_best_quality() + alternative_paths_limit_returned_paths_to_best_quality( + bool const domainEnabled) { - testcase("alternative paths - limit returned paths to best quality"); + testcase( + std::string( + "alternative paths - limit returned paths to best quality") + + (domainEnabled ? " w/ " : " w/o ") + "domain"); using namespace jtx; Env env = pathTestEnv(); auto const gw = Account("gateway"); @@ -566,14 +678,31 @@ class Path_test : public beast::unit_test::suite env.trust(gw2_USD(800), "alice", "bob"); env.trust(Account("alice")["USD"](800), "dan"); env.trust(Account("bob")["USD"](800), "dan"); + env.close(); env(pay(gw2, "alice", gw2_USD(100))); + env.close(); env(pay("carol", "alice", Account("carol")["USD"](100))); + env.close(); env(pay(gw, "alice", USD(100))); + env.close(); + + std::optional domainID; + if (domainEnabled) + { + domainID = + setupDomain(env, {"alice", "bob", "carol", "dan", gw, gw2}); + } STPathSet st; STAmount sa; - std::tie(st, sa, std::ignore) = - find_paths(env, "alice", "bob", Account("bob")["USD"](5)); + std::tie(st, sa, std::ignore) = find_paths( + env, + "alice", + "bob", + Account("bob")["USD"](5), + std::nullopt, + std::nullopt, + domainID); BEAST_EXPECT(same( st, stpath("gateway"), @@ -584,9 +713,11 @@ class Path_test : public beast::unit_test::suite } void - issues_path_negative_issue() + issues_path_negative_issue(bool const domainEnabled) { - testcase("path negative: Issue #5"); + testcase( + std::string("path negative: Issue #5") + + (domainEnabled ? " w/ " : " w/o ") + "domain"); using namespace jtx; Env env = pathTestEnv(); env.fund(XRP(10000), "alice", "bob", "carol", "dan"); @@ -597,14 +728,35 @@ class Path_test : public beast::unit_test::suite env(pay("bob", "carol", Account("bob")["USD"](75))); env.require(balance("bob", Account("carol")["USD"](-75))); env.require(balance("carol", Account("bob")["USD"](75))); + env.close(); - auto result = - find_paths(env, "alice", "bob", Account("bob")["USD"](25)); + std::optional domainID; + if (domainEnabled) + { + domainID = setupDomain(env, {"alice", "bob", "carol", "dan"}); + } + + auto result = find_paths( + env, + "alice", + "bob", + Account("bob")["USD"](25), + std::nullopt, + std::nullopt, + domainID); BEAST_EXPECT(std::get<0>(result).empty()); env(pay("alice", "bob", Account("alice")["USD"](25)), ter(tecPATH_DRY)); + env.close(); - result = find_paths(env, "alice", "bob", Account("alice")["USD"](25)); + result = find_paths( + env, + "alice", + "bob", + Account("alice")["USD"](25), + std::nullopt, + std::nullopt, + domainID); BEAST_EXPECT(std::get<0>(result).empty()); env.require(balance("alice", Account("bob")["USD"](0))); @@ -671,9 +823,11 @@ class Path_test : public beast::unit_test::suite // bob will hold gateway AUD // alice pays bob gateway AUD using XRP void - via_offers_via_gateway() + via_offers_via_gateway(bool const domainEnabled) { - testcase("via gateway"); + testcase( + std::string("via gateway") + (domainEnabled ? " w/ " : " w/o ") + + "domain"); using namespace jtx; Env env = pathTestEnv(); auto const gw = Account("gateway"); @@ -681,15 +835,43 @@ class Path_test : public beast::unit_test::suite env.fund(XRP(10000), "alice", "bob", "carol", gw); env.close(); env(rate(gw, 1.1)); + env.close(); env.trust(AUD(100), "bob", "carol"); + env.close(); env(pay(gw, "carol", AUD(50))); - env(offer("carol", XRP(50), AUD(50))); - env(pay("alice", "bob", AUD(10)), sendmax(XRP(100)), paths(XRP)); + env.close(); + + std::optional domainID; + if (domainEnabled) + { + domainID = setupDomain(env, {"alice", "bob", "carol", gw}); + env(offer("carol", XRP(50), AUD(50)), domain(*domainID)); + env.close(); + env(pay("alice", "bob", AUD(10)), + sendmax(XRP(100)), + paths(XRP), + domain(*domainID)); + env.close(); + } + else + { + env(offer("carol", XRP(50), AUD(50))); + env.close(); + env(pay("alice", "bob", AUD(10)), sendmax(XRP(100)), paths(XRP)); + env.close(); + } + env.require(balance("bob", AUD(10))); env.require(balance("carol", AUD(39))); - auto const result = - find_paths(env, "alice", "bob", Account("bob")["USD"](25)); + auto const result = find_paths( + env, + "alice", + "bob", + Account("bob")["USD"](25), + std::nullopt, + std::nullopt, + domainID); BEAST_EXPECT(std::get<0>(result).empty()); } @@ -865,9 +1047,11 @@ class Path_test : public beast::unit_test::suite } void - path_find_01() + path_find_01(bool const domainEnabled) { - testcase("Path Find: XRP -> XRP and XRP -> IOU"); + testcase( + std::string("Path Find: XRP -> XRP and XRP -> IOU") + + (domainEnabled ? " w/ " : " w/o ") + "domain"); using namespace jtx; Env env = pathTestEnv(); Account A1{"A1"}; @@ -899,16 +1083,28 @@ class Path_test : public beast::unit_test::suite env(pay(G3, M1, G3["ABC"](25000))); env.close(); - env(offer(M1, G1["XYZ"](1000), G2["XYZ"](1000))); - env(offer(M1, XRP(10000), G3["ABC"](1000))); + std::optional domainID; + if (domainEnabled) + { + domainID = setupDomain(env, {A1, A2, A3, G1, G2, G3, M1}); + env(offer(M1, G1["XYZ"](1000), G2["XYZ"](1000)), domain(*domainID)); + env(offer(M1, XRP(10000), G3["ABC"](1000)), domain(*domainID)); + env.close(); + } + else + { + env(offer(M1, G1["XYZ"](1000), G2["XYZ"](1000))); + env(offer(M1, XRP(10000), G3["ABC"](1000))); + env.close(); + } STPathSet st; STAmount sa, da; { auto const& send_amt = XRP(10); - std::tie(st, sa, da) = - find_paths(env, A1, A2, send_amt, std::nullopt, xrpCurrency()); + std::tie(st, sa, da) = find_paths( + env, A1, A2, send_amt, std::nullopt, xrpCurrency(), domainID); BEAST_EXPECT(equal(da, send_amt)); BEAST_EXPECT(st.empty()); } @@ -918,15 +1114,21 @@ class Path_test : public beast::unit_test::suite // does not exist. auto const& send_amt = XRP(200); std::tie(st, sa, da) = find_paths( - env, A1, Account{"A0"}, send_amt, std::nullopt, xrpCurrency()); + env, + A1, + Account{"A0"}, + send_amt, + std::nullopt, + xrpCurrency(), + domainID); BEAST_EXPECT(equal(da, send_amt)); BEAST_EXPECT(st.empty()); } { auto const& send_amt = G3["ABC"](10); - std::tie(st, sa, da) = - find_paths(env, A2, G3, send_amt, std::nullopt, xrpCurrency()); + std::tie(st, sa, da) = find_paths( + env, A2, G3, send_amt, std::nullopt, xrpCurrency(), domainID); BEAST_EXPECT(equal(da, send_amt)); BEAST_EXPECT(equal(sa, XRP(100))); BEAST_EXPECT(same(st, stpath(IPE(G3["ABC"])))); @@ -934,8 +1136,8 @@ class Path_test : public beast::unit_test::suite { auto const& send_amt = A2["ABC"](1); - std::tie(st, sa, da) = - find_paths(env, A1, A2, send_amt, std::nullopt, xrpCurrency()); + std::tie(st, sa, da) = find_paths( + env, A1, A2, send_amt, std::nullopt, xrpCurrency(), domainID); BEAST_EXPECT(equal(da, send_amt)); BEAST_EXPECT(equal(sa, XRP(10))); BEAST_EXPECT(same(st, stpath(IPE(G3["ABC"]), G3))); @@ -943,8 +1145,8 @@ class Path_test : public beast::unit_test::suite { auto const& send_amt = A3["ABC"](1); - std::tie(st, sa, da) = - find_paths(env, A1, A3, send_amt, std::nullopt, xrpCurrency()); + std::tie(st, sa, da) = find_paths( + env, A1, A3, send_amt, std::nullopt, xrpCurrency(), domainID); BEAST_EXPECT(equal(da, send_amt)); BEAST_EXPECT(equal(sa, XRP(10))); BEAST_EXPECT(same(st, stpath(IPE(G3["ABC"]), G3, A2))); @@ -952,9 +1154,11 @@ class Path_test : public beast::unit_test::suite } void - path_find_02() + path_find_02(bool const domainEnabled) { - testcase("Path Find: non-XRP -> XRP"); + testcase( + std::string("Path Find: non-XRP -> XRP") + + (domainEnabled ? " w/ " : " w/o ") + "domain"); using namespace jtx; Env env = pathTestEnv(); Account A1{"A1"}; @@ -975,23 +1179,53 @@ class Path_test : public beast::unit_test::suite env(pay(G3, M1, G3["ABC"](1200))); env.close(); - env(offer(M1, G3["ABC"](1000), XRP(10000))); + std::optional domainID; + if (domainEnabled) + { + domainID = setupDomain(env, {A1, A2, G3, M1}); + env(offer(M1, G3["ABC"](1000), XRP(10000)), domain(*domainID)); + } + else + { + env(offer(M1, G3["ABC"](1000), XRP(10000))); + } STPathSet st; STAmount sa, da; - auto const& send_amt = XRP(10); - std::tie(st, sa, da) = - find_paths(env, A1, A2, send_amt, std::nullopt, A2["ABC"].currency); - BEAST_EXPECT(equal(da, send_amt)); - BEAST_EXPECT(equal(sa, A1["ABC"](1))); - BEAST_EXPECT(same(st, stpath(G3, IPE(xrpIssue())))); + + { + std::tie(st, sa, da) = find_paths( + env, + A1, + A2, + send_amt, + std::nullopt, + A2["ABC"].currency, + domainID); + BEAST_EXPECT(equal(da, send_amt)); + BEAST_EXPECT(equal(sa, A1["ABC"](1))); + BEAST_EXPECT(same(st, stpath(G3, IPE(xrpIssue())))); + } + + // domain offer will not be considered in pathfinding for non-domain + // paths + if (domainEnabled) + { + std::tie(st, sa, da) = find_paths( + env, A1, A2, send_amt, std::nullopt, A2["ABC"].currency); + BEAST_EXPECT(equal(da, send_amt)); + BEAST_EXPECT(st.empty()); + } } void - path_find_04() + path_find_04(bool const domainEnabled) { - testcase("Path Find: Bitstamp and SnapSwap, liquidity with no offers"); + testcase( + std::string( + "Path Find: Bitstamp and SnapSwap, liquidity with no offers") + + (domainEnabled ? " w/ " : " w/o ") + "domain"); using namespace jtx; Env env = pathTestEnv(); Account A1{"A1"}; @@ -1019,13 +1253,23 @@ class Path_test : public beast::unit_test::suite env(pay(G2SW, M1, G2SW["HKD"](5000))); env.close(); + std::optional domainID; + if (domainEnabled) + domainID = setupDomain(env, {A1, A2, G1BS, G2SW, M1}); + STPathSet st; STAmount sa, da; { auto const& send_amt = A2["HKD"](10); std::tie(st, sa, da) = find_paths( - env, A1, A2, send_amt, std::nullopt, A2["HKD"].currency); + env, + A1, + A2, + send_amt, + std::nullopt, + A2["HKD"].currency, + domainID); BEAST_EXPECT(equal(da, send_amt)); BEAST_EXPECT(equal(sa, A1["HKD"](10))); BEAST_EXPECT(same(st, stpath(G1BS, M1, G2SW))); @@ -1034,7 +1278,13 @@ class Path_test : public beast::unit_test::suite { auto const& send_amt = A1["HKD"](10); std::tie(st, sa, da) = find_paths( - env, A2, A1, send_amt, std::nullopt, A1["HKD"].currency); + env, + A2, + A1, + send_amt, + std::nullopt, + A1["HKD"].currency, + domainID); BEAST_EXPECT(equal(da, send_amt)); BEAST_EXPECT(equal(sa, A2["HKD"](10))); BEAST_EXPECT(same(st, stpath(G2SW, M1, G1BS))); @@ -1043,7 +1293,13 @@ class Path_test : public beast::unit_test::suite { auto const& send_amt = A2["HKD"](10); std::tie(st, sa, da) = find_paths( - env, G1BS, A2, send_amt, std::nullopt, A1["HKD"].currency); + env, + G1BS, + A2, + send_amt, + std::nullopt, + A1["HKD"].currency, + domainID); BEAST_EXPECT(equal(da, send_amt)); BEAST_EXPECT(equal(sa, G1BS["HKD"](10))); BEAST_EXPECT(same(st, stpath(M1, G2SW))); @@ -1052,7 +1308,13 @@ class Path_test : public beast::unit_test::suite { auto const& send_amt = M1["HKD"](10); std::tie(st, sa, da) = find_paths( - env, M1, G1BS, send_amt, std::nullopt, A1["HKD"].currency); + env, + M1, + G1BS, + send_amt, + std::nullopt, + A1["HKD"].currency, + domainID); BEAST_EXPECT(equal(da, send_amt)); BEAST_EXPECT(equal(sa, M1["HKD"](10))); BEAST_EXPECT(st.empty()); @@ -1061,7 +1323,13 @@ class Path_test : public beast::unit_test::suite { auto const& send_amt = A1["HKD"](10); std::tie(st, sa, da) = find_paths( - env, G2SW, A1, send_amt, std::nullopt, A1["HKD"].currency); + env, + G2SW, + A1, + send_amt, + std::nullopt, + A1["HKD"].currency, + domainID); BEAST_EXPECT(equal(da, send_amt)); BEAST_EXPECT(equal(sa, G2SW["HKD"](10))); BEAST_EXPECT(same(st, stpath(M1, G1BS))); @@ -1069,9 +1337,11 @@ class Path_test : public beast::unit_test::suite } void - path_find_05() + path_find_05(bool const domainEnabled) { - testcase("Path Find: non-XRP -> non-XRP, same currency"); + testcase( + std::string("Path Find: non-XRP -> non-XRP, same currency") + + (domainEnabled ? " w/ " : " w/o ") + "domain"); using namespace jtx; Env env = pathTestEnv(); Account A1{"A1"}; @@ -1108,9 +1378,21 @@ class Path_test : public beast::unit_test::suite env(pay(G2, M2, G2["HKD"](5000))); env.close(); - env(offer(M1, G1["HKD"](1000), G2["HKD"](1000))); - env(offer(M2, XRP(10000), G2["HKD"](1000))); - env(offer(M2, G1["HKD"](1000), XRP(10000))); + std::optional domainID; + if (domainEnabled) + { + domainID = + setupDomain(env, {A1, A2, A3, A4, G1, G2, G3, G4, M1, M2}); + env(offer(M1, G1["HKD"](1000), G2["HKD"](1000)), domain(*domainID)); + env(offer(M2, XRP(10000), G2["HKD"](1000)), domain(*domainID)); + env(offer(M2, G1["HKD"](1000), XRP(10000)), domain(*domainID)); + } + else + { + env(offer(M1, G1["HKD"](1000), G2["HKD"](1000))); + env(offer(M2, XRP(10000), G2["HKD"](1000))); + env(offer(M2, G1["HKD"](1000), XRP(10000))); + } STPathSet st; STAmount sa, da; @@ -1120,7 +1402,13 @@ class Path_test : public beast::unit_test::suite // Source -> Destination (repay source issuer) auto const& send_amt = G1["HKD"](10); std::tie(st, sa, da) = find_paths( - env, A1, G1, send_amt, std::nullopt, G1["HKD"].currency); + env, + A1, + G1, + send_amt, + std::nullopt, + G1["HKD"].currency, + domainID); BEAST_EXPECT(st.empty()); BEAST_EXPECT(equal(da, send_amt)); BEAST_EXPECT(equal(sa, A1["HKD"](10))); @@ -1131,7 +1419,13 @@ class Path_test : public beast::unit_test::suite // Source -> Destination (repay destination issuer) auto const& send_amt = A1["HKD"](10); std::tie(st, sa, da) = find_paths( - env, A1, G1, send_amt, std::nullopt, G1["HKD"].currency); + env, + A1, + G1, + send_amt, + std::nullopt, + G1["HKD"].currency, + domainID); BEAST_EXPECT(st.empty()); BEAST_EXPECT(equal(da, send_amt)); BEAST_EXPECT(equal(sa, A1["HKD"](10))); @@ -1142,7 +1436,13 @@ class Path_test : public beast::unit_test::suite // Source -> AC -> Destination auto const& send_amt = A3["HKD"](10); std::tie(st, sa, da) = find_paths( - env, A1, A3, send_amt, std::nullopt, G1["HKD"].currency); + env, + A1, + A3, + send_amt, + std::nullopt, + G1["HKD"].currency, + domainID); BEAST_EXPECT(equal(da, send_amt)); BEAST_EXPECT(equal(sa, A1["HKD"](10))); BEAST_EXPECT(same(st, stpath(G1))); @@ -1153,7 +1453,13 @@ class Path_test : public beast::unit_test::suite // Source -> OB -> Destination auto const& send_amt = G2["HKD"](10); std::tie(st, sa, da) = find_paths( - env, G1, G2, send_amt, std::nullopt, G1["HKD"].currency); + env, + G1, + G2, + send_amt, + std::nullopt, + G1["HKD"].currency, + domainID); BEAST_EXPECT(equal(da, send_amt)); BEAST_EXPECT(equal(sa, G1["HKD"](10))); BEAST_EXPECT(same( @@ -1169,7 +1475,13 @@ class Path_test : public beast::unit_test::suite // Source -> AC -> OB -> Destination auto const& send_amt = G2["HKD"](10); std::tie(st, sa, da) = find_paths( - env, A1, G2, send_amt, std::nullopt, G1["HKD"].currency); + env, + A1, + G2, + send_amt, + std::nullopt, + G1["HKD"].currency, + domainID); BEAST_EXPECT(equal(da, send_amt)); BEAST_EXPECT(equal(sa, A1["HKD"](10))); BEAST_EXPECT(same( @@ -1182,10 +1494,17 @@ class Path_test : public beast::unit_test::suite { // I4) XRP bridge" -- - // Source -> AC -> OB to XRP -> OB from XRP -> AC -> Destination + // Source -> AC -> OB to XRP -> OB from XRP -> AC -> + // Destination auto const& send_amt = A2["HKD"](10); std::tie(st, sa, da) = find_paths( - env, A1, A2, send_amt, std::nullopt, G1["HKD"].currency); + env, + A1, + A2, + send_amt, + std::nullopt, + G1["HKD"].currency, + domainID); BEAST_EXPECT(equal(da, send_amt)); BEAST_EXPECT(equal(sa, A1["HKD"](10))); BEAST_EXPECT(same( @@ -1198,9 +1517,11 @@ class Path_test : public beast::unit_test::suite } void - path_find_06() + path_find_06(bool const domainEnabled) { - testcase("Path Find: non-XRP -> non-XRP, same currency)"); + testcase( + std::string("Path Find: non-XRP -> non-XRP, same currency)") + + (domainEnabled ? " w/ " : " w/o ") + "domain"); using namespace jtx; Env env = pathTestEnv(); Account A1{"A1"}; @@ -1227,24 +1548,36 @@ class Path_test : public beast::unit_test::suite env(pay(G2, M1, G2["HKD"](5000))); env.close(); - env(offer(M1, G1["HKD"](1000), G2["HKD"](1000))); + std::optional domainID; + if (domainEnabled) + { + domainID = setupDomain(env, {A1, A2, A3, G1, G2, M1}); + env(offer(M1, G1["HKD"](1000), G2["HKD"](1000)), domain(*domainID)); + } + else + { + env(offer(M1, G1["HKD"](1000), G2["HKD"](1000))); + } // E) Gateway to user // Source -> OB -> AC -> Destination auto const& send_amt = A2["HKD"](10); STPathSet st; STAmount sa, da; - std::tie(st, sa, da) = - find_paths(env, G1, A2, send_amt, std::nullopt, G1["HKD"].currency); + std::tie(st, sa, da) = find_paths( + env, G1, A2, send_amt, std::nullopt, G1["HKD"].currency, domainID); BEAST_EXPECT(equal(da, send_amt)); BEAST_EXPECT(equal(sa, G1["HKD"](10))); BEAST_EXPECT(same(st, stpath(M1, G2), stpath(IPE(G2["HKD"]), G2))); } void - receive_max() + receive_max(bool const domainEnabled) { - testcase("Receive max"); + testcase( + std::string("Receive max") + (domainEnabled ? " w/ " : " w/o ") + + "domain"); + using namespace jtx; auto const alice = Account("alice"); auto const bob = Account("bob"); @@ -1260,10 +1593,28 @@ class Path_test : public beast::unit_test::suite env.close(); env(pay(gw, charlie, USD(10))); env.close(); - env(offer(charlie, XRP(10), USD(10))); - env.close(); - auto [st, sa, da] = - find_paths(env, alice, bob, USD(-1), XRP(100).value()); + + std::optional domainID; + if (domainEnabled) + { + domainID = setupDomain(env, {alice, bob, charlie, gw}); + env(offer(charlie, XRP(10), USD(10)), domain(*domainID)); + env.close(); + } + else + { + env(offer(charlie, XRP(10), USD(10))); + env.close(); + } + + auto [st, sa, da] = find_paths( + env, + alice, + bob, + USD(-1), + XRP(100).value(), + std::nullopt, + domainID); BEAST_EXPECT(sa == XRP(10)); BEAST_EXPECT(equal(da, USD(10))); if (BEAST_EXPECT(st.size() == 1 && st[0].size() == 1)) @@ -1283,10 +1634,28 @@ class Path_test : public beast::unit_test::suite env.close(); env(pay(gw, alice, USD(10))); env.close(); - env(offer(charlie, USD(10), XRP(10))); - env.close(); - auto [st, sa, da] = - find_paths(env, alice, bob, drops(-1), USD(100).value()); + + std::optional domainID; + if (domainEnabled) + { + domainID = setupDomain(env, {alice, bob, charlie, gw}); + env(offer(charlie, USD(10), XRP(10)), domain(*domainID)); + env.close(); + } + else + { + env(offer(charlie, USD(10), XRP(10))); + env.close(); + } + + auto [st, sa, da] = find_paths( + env, + alice, + bob, + drops(-1), + USD(100).value(), + std::nullopt, + domainID); BEAST_EXPECT(sa == USD(10)); BEAST_EXPECT(equal(da, XRP(10))); if (BEAST_EXPECT(st.size() == 1 && st[0].size() == 1)) @@ -1363,6 +1732,360 @@ class Path_test : public beast::unit_test::suite test("no ripple -> no ripple", false, false, false); } + void + hybrid_offer_path() + { + testcase("Hybrid offer path"); + using namespace jtx; + + // test cases copied from path_find_05 and ensures path results for + // different combinations of open/domain/hybrid offers. `func` is a + // lambda param that creates different types of offers + auto testPathfind = [&](auto func, bool const domainEnabled = false) { + Env env = pathTestEnv(); + Account A1{"A1"}; + Account A2{"A2"}; + Account A3{"A3"}; + Account A4{"A4"}; + Account G1{"G1"}; + Account G2{"G2"}; + Account G3{"G3"}; + Account G4{"G4"}; + Account M1{"M1"}; + Account M2{"M2"}; + + env.fund(XRP(1000), A1, A2, A3, G1, G2, G3, G4); + env.fund(XRP(10000), A4); + env.fund(XRP(11000), M1, M2); + env.close(); + + env.trust(G1["HKD"](2000), A1); + env.trust(G2["HKD"](2000), A2); + env.trust(G1["HKD"](2000), A3); + env.trust(G1["HKD"](100000), M1); + env.trust(G2["HKD"](100000), M1); + env.trust(G1["HKD"](100000), M2); + env.trust(G2["HKD"](100000), M2); + env.close(); + + env(pay(G1, A1, G1["HKD"](1000))); + env(pay(G2, A2, G2["HKD"](1000))); + env(pay(G1, A3, G1["HKD"](1000))); + env(pay(G1, M1, G1["HKD"](1200))); + env(pay(G2, M1, G2["HKD"](5000))); + env(pay(G1, M2, G1["HKD"](1200))); + env(pay(G2, M2, G2["HKD"](5000))); + env.close(); + + std::optional domainID = + setupDomain(env, {A1, A2, A3, A4, G1, G2, G3, G4, M1, M2}); + BEAST_EXPECT(domainID); + + func(env, M1, M2, G1, G2, *domainID); + + STPathSet st; + STAmount sa, da; + + { + // A) Borrow or repay -- + // Source -> Destination (repay source issuer) + auto const& send_amt = G1["HKD"](10); + std::tie(st, sa, da) = find_paths( + env, + A1, + G1, + send_amt, + std::nullopt, + G1["HKD"].currency, + domainEnabled ? domainID : std::nullopt); + BEAST_EXPECT(st.empty()); + BEAST_EXPECT(equal(da, send_amt)); + BEAST_EXPECT(equal(sa, A1["HKD"](10))); + } + + { + // A2) Borrow or repay -- + // Source -> Destination (repay destination issuer) + auto const& send_amt = A1["HKD"](10); + std::tie(st, sa, da) = find_paths( + env, + A1, + G1, + send_amt, + std::nullopt, + G1["HKD"].currency, + domainEnabled ? domainID : std::nullopt); + BEAST_EXPECT(st.empty()); + BEAST_EXPECT(equal(da, send_amt)); + BEAST_EXPECT(equal(sa, A1["HKD"](10))); + } + + { + // B) Common gateway -- + // Source -> AC -> Destination + auto const& send_amt = A3["HKD"](10); + std::tie(st, sa, da) = find_paths( + env, + A1, + A3, + send_amt, + std::nullopt, + G1["HKD"].currency, + domainEnabled ? domainID : std::nullopt); + BEAST_EXPECT(equal(da, send_amt)); + BEAST_EXPECT(equal(sa, A1["HKD"](10))); + BEAST_EXPECT(same(st, stpath(G1))); + } + + { + // C) Gateway to gateway -- + // Source -> OB -> Destination + auto const& send_amt = G2["HKD"](10); + std::tie(st, sa, da) = find_paths( + env, + G1, + G2, + send_amt, + std::nullopt, + G1["HKD"].currency, + domainEnabled ? domainID : std::nullopt); + BEAST_EXPECT(equal(da, send_amt)); + BEAST_EXPECT(equal(sa, G1["HKD"](10))); + BEAST_EXPECT(same( + st, + stpath(IPE(G2["HKD"])), + stpath(M1), + stpath(M2), + stpath(IPE(xrpIssue()), IPE(G2["HKD"])))); + } + + { + // D) User to unlinked gateway via order book -- + // Source -> AC -> OB -> Destination + auto const& send_amt = G2["HKD"](10); + std::tie(st, sa, da) = find_paths( + env, + A1, + G2, + send_amt, + std::nullopt, + G1["HKD"].currency, + domainEnabled ? domainID : std::nullopt); + BEAST_EXPECT(equal(da, send_amt)); + BEAST_EXPECT(equal(sa, A1["HKD"](10))); + BEAST_EXPECT(same( + st, + stpath(G1, M1), + stpath(G1, M2), + stpath(G1, IPE(G2["HKD"])), + stpath(G1, IPE(xrpIssue()), IPE(G2["HKD"])))); + } + + { + // I4) XRP bridge" -- + // Source -> AC -> OB to XRP -> OB from XRP -> AC -> + // Destination + auto const& send_amt = A2["HKD"](10); + std::tie(st, sa, da) = find_paths( + env, + A1, + A2, + send_amt, + std::nullopt, + G1["HKD"].currency, + domainEnabled ? domainID : std::nullopt); + BEAST_EXPECT(equal(da, send_amt)); + BEAST_EXPECT(equal(sa, A1["HKD"](10))); + BEAST_EXPECT(same( + st, + stpath(G1, M1, G2), + stpath(G1, M2, G2), + stpath(G1, IPE(G2["HKD"]), G2), + stpath(G1, IPE(xrpIssue()), IPE(G2["HKD"]), G2))); + } + }; + + // the following tests exercise different combinations of open/hybrid + // offers to make sure that hybrid offers work in pathfinding for open + // order book + { + testPathfind([](Env& env, + Account M1, + Account M2, + Account G1, + Account G2, + uint256 domainID) { + env(offer(M1, G1["HKD"](1000), G2["HKD"](1000)), + domain(domainID), + txflags(tfHybrid)); + env(offer(M2, XRP(10000), G2["HKD"](1000))); + env(offer(M2, G1["HKD"](1000), XRP(10000))); + }); + + testPathfind([](Env& env, + Account M1, + Account M2, + Account G1, + Account G2, + uint256 domainID) { + env(offer(M1, G1["HKD"](1000), G2["HKD"](1000)), + domain(domainID), + txflags(tfHybrid)); + env(offer(M2, XRP(10000), G2["HKD"](1000)), + domain(domainID), + txflags(tfHybrid)); + env(offer(M2, G1["HKD"](1000), XRP(10000))); + }); + + testPathfind([](Env& env, + Account M1, + Account M2, + Account G1, + Account G2, + uint256 domainID) { + env(offer(M1, G1["HKD"](1000), G2["HKD"](1000)), + domain(domainID), + txflags(tfHybrid)); + env(offer(M2, XRP(10000), G2["HKD"](1000)), + domain(domainID), + txflags(tfHybrid)); + env(offer(M2, G1["HKD"](1000), XRP(10000)), + domain(domainID), + txflags(tfHybrid)); + }); + + testPathfind([](Env& env, + Account M1, + Account M2, + Account G1, + Account G2, + uint256 domainID) { + env(offer(M1, G1["HKD"](1000), G2["HKD"](1000))); + env(offer(M2, XRP(10000), G2["HKD"](1000))); + env(offer(M2, G1["HKD"](1000), XRP(10000)), + domain(domainID), + txflags(tfHybrid)); + }); + + testPathfind([](Env& env, + Account M1, + Account M2, + Account G1, + Account G2, + uint256 domainID) { + env(offer(M1, G1["HKD"](1000), G2["HKD"](1000))); + env(offer(M2, XRP(10000), G2["HKD"](1000)), + domain(domainID), + txflags(tfHybrid)); + env(offer(M2, G1["HKD"](1000), XRP(10000)), + domain(domainID), + txflags(tfHybrid)); + }); + } + + // the following tests exercise different combinations of domain/hybrid + // offers to make sure that hybrid offers work in pathfinding for domain + // order book + { + testPathfind( + [](Env& env, + Account M1, + Account M2, + Account G1, + Account G2, + uint256 domainID) { + env(offer(M1, G1["HKD"](1000), G2["HKD"](1000)), + domain(domainID), + txflags(tfHybrid)); + env(offer(M2, XRP(10000), G2["HKD"](1000)), + domain(domainID)); + env(offer(M2, G1["HKD"](1000), XRP(10000)), + domain(domainID)); + }, + true); + + testPathfind( + [](Env& env, + Account M1, + Account M2, + Account G1, + Account G2, + uint256 domainID) { + env(offer(M1, G1["HKD"](1000), G2["HKD"](1000)), + domain(domainID), + txflags(tfHybrid)); + env(offer(M2, XRP(10000), G2["HKD"](1000)), + domain(domainID), + txflags(tfHybrid)); + env(offer(M2, G1["HKD"](1000), XRP(10000)), + domain(domainID)); + }, + true); + + testPathfind( + [](Env& env, + Account M1, + Account M2, + Account G1, + Account G2, + uint256 domainID) { + env(offer(M1, G1["HKD"](1000), G2["HKD"](1000)), + domain(domainID)); + env(offer(M2, XRP(10000), G2["HKD"](1000)), + domain(domainID)); + env(offer(M2, G1["HKD"](1000), XRP(10000)), + domain(domainID), + txflags(tfHybrid)); + }, + true); + + testPathfind( + [](Env& env, + Account M1, + Account M2, + Account G1, + Account G2, + uint256 domainID) { + env(offer(M1, G1["HKD"](1000), G2["HKD"](1000)), + domain(domainID)); + env(offer(M2, XRP(10000), G2["HKD"](1000)), + domain(domainID), + txflags(tfHybrid)); + env(offer(M2, G1["HKD"](1000), XRP(10000)), + domain(domainID), + txflags(tfHybrid)); + }, + true); + } + } + + void + amm_domain_path() + { + testcase("AMM not used in domain path"); + using namespace jtx; + Env env = pathTestEnv(); + PermissionedDEX permDex(env); + auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] = + permDex; + AMM amm(env, alice, XRP(10), USD(50)); + + STPathSet st; + STAmount sa, da; + + auto const& send_amt = XRP(1); + + // doing pathfind with domain won't include amm + std::tie(st, sa, da) = find_paths( + env, bob, carol, send_amt, std::nullopt, USD.currency, domainID); + BEAST_EXPECT(st.empty()); + + // a non-domain pathfind returns amm in the path + std::tie(st, sa, da) = + find_paths(env, bob, carol, send_amt, std::nullopt, USD.currency); + BEAST_EXPECT(same(st, stpath(gw, IPE(xrpIssue())))); + } + void run() override { @@ -1370,35 +2093,43 @@ class Path_test : public beast::unit_test::suite no_direct_path_no_intermediary_no_alternatives(); direct_path_no_intermediary(); payment_auto_path_find(); - path_find(); - path_find_consume_all(); - alternative_path_consume_both(); - alternative_paths_consume_best_transfer(); + indirect_paths_path_find(); alternative_paths_consume_best_transfer_first(); - alternative_paths_limit_returned_paths_to_best_quality(); - issues_path_negative_issue(); issues_path_negative_ripple_client_issue_23_smaller(); issues_path_negative_ripple_client_issue_23_larger(); - via_offers_via_gateway(); - indirect_paths_path_find(); quality_paths_quality_set_and_test(); trust_auto_clear_trust_normal_clear(); trust_auto_clear_trust_auto_clear(); - xrp_to_xrp(); - receive_max(); noripple_combinations(); - // 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. - - path_find_01(); - path_find_02(); - path_find_04(); - path_find_05(); - path_find_06(); + for (bool const domainEnabled : {false, true}) + { + path_find(domainEnabled); + path_find_consume_all(domainEnabled); + alternative_path_consume_both(domainEnabled); + alternative_paths_consume_best_transfer(domainEnabled); + alternative_paths_limit_returned_paths_to_best_quality( + domainEnabled); + issues_path_negative_issue(domainEnabled); + via_offers_via_gateway(domainEnabled); + xrp_to_xrp(domainEnabled); + receive_max(domainEnabled); + + // 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. + + path_find_01(domainEnabled); + path_find_02(domainEnabled); + path_find_04(domainEnabled); + path_find_05(domainEnabled); + path_find_06(domainEnabled); + } + + hybrid_offer_path(); + amm_domain_path(); } }; diff --git a/src/test/app/PayStrand_test.cpp b/src/test/app/PayStrand_test.cpp index 4d743d9d7c8..6f653d2bf08 100644 --- a/src/test/app/PayStrand_test.cpp +++ b/src/test/app/PayStrand_test.cpp @@ -27,6 +27,9 @@ #include #include #include +#include + +#include namespace ripple { namespace test { @@ -656,6 +659,7 @@ struct PayStrand_test : public beast::unit_test::suite true, OfferCrossing::no, ammContext, + std::nullopt, env.app().logs().journal("Flow")); BEAST_EXPECT(ter == expTer); if (sizeof...(expSteps) != 0) @@ -684,6 +688,7 @@ struct PayStrand_test : public beast::unit_test::suite true, OfferCrossing::no, ammContext, + std::nullopt, env.app().logs().journal("Flow")); (void)_; BEAST_EXPECT(ter == tesSUCCESS); @@ -701,6 +706,7 @@ struct PayStrand_test : public beast::unit_test::suite true, OfferCrossing::no, ammContext, + std::nullopt, env.app().logs().journal("Flow")); (void)_; BEAST_EXPECT(ter == tesSUCCESS); @@ -821,6 +827,7 @@ struct PayStrand_test : public beast::unit_test::suite true, OfferCrossing::no, ammContext, + std::nullopt, flowJournal); BEAST_EXPECT(r.first == temBAD_PATH); } @@ -837,6 +844,7 @@ struct PayStrand_test : public beast::unit_test::suite true, OfferCrossing::no, ammContext, + std::nullopt, flowJournal); BEAST_EXPECT(r.first == temBAD_PATH); } @@ -853,6 +861,7 @@ struct PayStrand_test : public beast::unit_test::suite true, OfferCrossing::no, ammContext, + std::nullopt, flowJournal); BEAST_EXPECT(r.first == temBAD_PATH); } @@ -990,6 +999,7 @@ struct PayStrand_test : public beast::unit_test::suite true, OfferCrossing::no, ammContext, + std::nullopt, env.app().logs().journal("Flow")); BEAST_EXPECT(ter == tesSUCCESS); BEAST_EXPECT(equal(strand, D{alice, gw, usdC})); @@ -1017,6 +1027,7 @@ struct PayStrand_test : public beast::unit_test::suite false, OfferCrossing::no, ammContext, + std::nullopt, env.app().logs().journal("Flow")); BEAST_EXPECT(ter == tesSUCCESS); BEAST_EXPECT(equal( @@ -1201,6 +1212,7 @@ struct PayStrand_test : public beast::unit_test::suite dstAcc, noAccount(), pathSet, + std::nullopt, env.app().logs(), &inputs); BEAST_EXPECT(r.result() == temBAD_PATH); @@ -1213,6 +1225,7 @@ struct PayStrand_test : public beast::unit_test::suite noAccount(), srcAcc, pathSet, + std::nullopt, env.app().logs(), &inputs); BEAST_EXPECT(r.result() == temBAD_PATH); @@ -1225,6 +1238,7 @@ struct PayStrand_test : public beast::unit_test::suite dstAcc, srcAcc, pathSet, + std::nullopt, env.app().logs(), &inputs); BEAST_EXPECT(r.result() == temBAD_PATH); @@ -1237,6 +1251,7 @@ struct PayStrand_test : public beast::unit_test::suite dstAcc, srcAcc, pathSet, + std::nullopt, env.app().logs(), &inputs); BEAST_EXPECT(r.result() == temBAD_PATH); @@ -1253,13 +1268,16 @@ struct PayStrand_test : public beast::unit_test::suite { using namespace jtx; auto const sa = supported_amendments(); - testToStrand(sa - featureFlowCross); + testToStrand(sa - featureFlowCross - featurePermissionedDEX); + testToStrand(sa - featurePermissionedDEX); testToStrand(sa); - testRIPD1373(sa - featureFlowCross); + testRIPD1373(sa - featureFlowCross - featurePermissionedDEX); + testRIPD1373(sa - featurePermissionedDEX); testRIPD1373(sa); - testLoop(sa - featureFlowCross); + testLoop(sa - featureFlowCross - featurePermissionedDEX); + testLoop(sa - featurePermissionedDEX); testLoop(sa); testNoAccount(sa); diff --git a/src/test/app/PermissionedDEX_test.cpp b/src/test/app/PermissionedDEX_test.cpp new file mode 100644 index 00000000000..6b1f3e33fd9 --- /dev/null +++ b/src/test/app/PermissionedDEX_test.cpp @@ -0,0 +1,1558 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2025 Ripple Labs Inc. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include +#include +#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 ripple { +namespace test { + +using namespace jtx; + +class PermissionedDEX_test : public beast::unit_test::suite +{ + [[nodiscard]] bool + offerExists(Env const& env, Account const& account, std::uint32_t offerSeq) + { + return static_cast(env.le(keylet::offer(account.id(), offerSeq))); + } + + [[nodiscard]] bool + checkOffer( + Env const& env, + Account const& account, + std::uint32_t offerSeq, + STAmount const& takerPays, + STAmount const& takerGets, + uint32_t const flags = 0, + bool const domainOffer = false) + { + auto offerInDir = [&](uint256 const& directory, + uint64_t const pageIndex, + std::optional domain = + std::nullopt) -> bool { + auto const page = env.le(keylet::page(directory, pageIndex)); + if (!page) + return false; + + if (domain != (*page)[~sfDomainID]) + return false; + + auto const& indexes = page->getFieldV256(sfIndexes); + for (auto const& index : indexes) + { + if (index == keylet::offer(account, offerSeq).key) + return true; + } + + return false; + }; + + auto const sle = env.le(keylet::offer(account.id(), offerSeq)); + if (!sle) + return false; + if (sle->getFieldAmount(sfTakerGets) != takerGets) + return false; + if (sle->getFieldAmount(sfTakerPays) != takerPays) + return false; + if (sle->getFlags() != flags) + return false; + if (domainOffer && !sle->isFieldPresent(sfDomainID)) + return false; + if (!domainOffer && sle->isFieldPresent(sfDomainID)) + return false; + if (!offerInDir( + sle->getFieldH256(sfBookDirectory), + sle->getFieldU64(sfBookNode), + (*sle)[~sfDomainID])) + return false; + + if (sle->isFlag(lsfHybrid)) + { + if (!sle->isFieldPresent(sfDomainID)) + return false; + if (!sle->isFieldPresent(sfAdditionalBooks)) + return false; + if (sle->getFieldArray(sfAdditionalBooks).size() != 1) + return false; + + auto const& additionalBookDirs = + sle->getFieldArray(sfAdditionalBooks); + + for (auto const& bookDir : additionalBookDirs) + { + auto const& dirIndex = bookDir.getFieldH256(sfBookDirectory); + auto const& dirNode = bookDir.getFieldU64(sfBookNode); + + // the directory is for the open order book, so the dir + // doesn't have domainID + if (!offerInDir(dirIndex, dirNode, std::nullopt)) + return false; + } + } + else + { + if (sle->isFieldPresent(sfAdditionalBooks)) + return false; + } + + return true; + } + + uint256 + getBookDirKey( + Book const& book, + STAmount const& takerPays, + STAmount const& takerGets) + { + return keylet::quality( + keylet::book(book), getRate(takerGets, takerPays)) + .key; + } + + std::optional + getDefaultOfferDirKey( + Env const& env, + Account const& account, + std::uint32_t offerSeq) + { + if (auto const sle = env.le(keylet::offer(account.id(), offerSeq))) + return Keylet(ltDIR_NODE, (*sle)[sfBookDirectory]).key; + + return {}; + } + + [[nodiscard]] bool + checkDirectorySize(Env const& env, uint256 directory, std::uint32_t dirSize) + { + std::optional pageIndex{0}; + std::uint32_t dirCnt = 0; + + do + { + auto const page = env.le(keylet::page(directory, *pageIndex)); + if (!page) + break; + + pageIndex = (*page)[~sfIndexNext]; + dirCnt += (*page)[sfIndexes].size(); + + } while (pageIndex.value_or(0)); + + return dirCnt == dirSize; + } + + void + testOfferCreate(FeatureBitset features) + { + testcase("OfferCreate"); + + // test preflight + { + Env env(*this, features - featurePermissionedDEX); + auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] = + PermissionedDEX(env); + + env(offer(bob, XRP(10), USD(10)), + domain(domainID), + ter(temDISABLED)); + env.close(); + + env.enableFeature(featurePermissionedDEX); + env.close(); + env(offer(bob, XRP(10), USD(10)), domain(domainID)); + env.close(); + } + + // test preflight: permissioned dex cannot be used without enable + // flowcross + { + Env env(*this, features - featureFlowCross); + auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] = + PermissionedDEX(env); + + env(offer(bob, XRP(10), USD(10)), + domain(domainID), + ter(temDISABLED)); + env.close(); + + env.enableFeature(featureFlowCross); + env.close(); + env(offer(bob, XRP(10), USD(10)), domain(domainID)); + env.close(); + } + + // preclaim - someone outside of the domain cannot create domain offer + { + Env env(*this, features); + auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] = + PermissionedDEX(env); + + // create devin account who is not part of the domain + Account devin("devin"); + env.fund(XRP(1000), devin); + env.close(); + env.trust(USD(1000), devin); + env.close(); + env(pay(gw, devin, USD(100))); + env.close(); + + env(offer(devin, XRP(10), USD(10)), + domain(domainID), + ter(tecNO_PERMISSION)); + env.close(); + + // domain owner also issues a credential for devin + env(credentials::create(devin, domainOwner, credType)); + env.close(); + + // devin still cannot create offer since he didn't accept credential + env(offer(devin, XRP(10), USD(10)), + domain(domainID), + ter(tecNO_PERMISSION)); + env.close(); + + env(credentials::accept(devin, domainOwner, credType)); + env.close(); + + env(offer(devin, XRP(10), USD(10)), domain(domainID)); + env.close(); + } + + // preclaim - someone with expired cred cannot create domain offer + { + Env env(*this, features); + auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] = + PermissionedDEX(env); + + // create devin account who is not part of the domain + Account devin("devin"); + env.fund(XRP(1000), devin); + env.close(); + env.trust(USD(1000), devin); + env.close(); + env(pay(gw, devin, USD(100))); + env.close(); + + auto jv = credentials::create(devin, domainOwner, credType); + uint32_t const t = env.current() + ->info() + .parentCloseTime.time_since_epoch() + .count(); + jv[sfExpiration.jsonName] = t + 20; + env(jv); + + env(credentials::accept(devin, domainOwner, credType)); + env.close(); + + // devin can still create offer while his cred is not expired + env(offer(devin, XRP(10), USD(10)), domain(domainID)); + env.close(); + + // time advance + env.close(std::chrono::seconds(20)); + + // devin cannot create offer with expired cred + env(offer(devin, XRP(10), USD(10)), + domain(domainID), + ter(tecNO_PERMISSION)); + env.close(); + } + + // preclaim - cannot create an offer in a non existent domain + { + Env env(*this, features); + auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] = + PermissionedDEX(env); + uint256 const badDomain{ + "F10D0CC9A0F9A3CBF585B80BE09A186483668FDBDD39AA7E3370F3649CE134" + "E5"}; + + env(offer(bob, XRP(10), USD(10)), + domain(badDomain), + ter(tecNO_PERMISSION)); + env.close(); + } + + // apply - offer can be created even if takergets issuer is not in + // domain + { + Env env(*this, features); + auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] = + PermissionedDEX(env); + + env(credentials::deleteCred( + domainOwner, gw, domainOwner, credType)); + env.close(); + + auto const bobOfferSeq{env.seq(bob)}; + env(offer(bob, XRP(10), USD(10)), domain(domainID)); + env.close(); + + BEAST_EXPECT( + checkOffer(env, bob, bobOfferSeq, XRP(10), USD(10), 0, true)); + } + + // apply - offer can be created even if takerpays issuer is not in + // domain + { + Env env(*this, features); + auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] = + PermissionedDEX(env); + + env(credentials::deleteCred( + domainOwner, gw, domainOwner, credType)); + env.close(); + + auto const bobOfferSeq{env.seq(bob)}; + env(offer(bob, USD(10), XRP(10)), domain(domainID)); + env.close(); + + BEAST_EXPECT( + checkOffer(env, bob, bobOfferSeq, USD(10), XRP(10), 0, true)); + } + + // apply - two domain offers cross with each other + { + Env env(*this, features); + auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] = + PermissionedDEX(env); + + auto const bobOfferSeq{env.seq(bob)}; + env(offer(bob, XRP(10), USD(10)), domain(domainID)); + env.close(); + + BEAST_EXPECT( + checkOffer(env, bob, bobOfferSeq, XRP(10), USD(10), 0, true)); + BEAST_EXPECT(ownerCount(env, bob) == 3); + + // a non domain offer cannot cross with domain offer + env(offer(carol, USD(10), XRP(10))); + env.close(); + + BEAST_EXPECT( + checkOffer(env, bob, bobOfferSeq, XRP(10), USD(10), 0, true)); + + auto const aliceOfferSeq{env.seq(alice)}; + env(offer(alice, USD(10), XRP(10)), domain(domainID)); + env.close(); + + BEAST_EXPECT(!offerExists(env, alice, aliceOfferSeq)); + BEAST_EXPECT(!offerExists(env, bob, bobOfferSeq)); + BEAST_EXPECT(ownerCount(env, alice) == 2); + } + + // apply - create lots of domain offers + { + Env env(*this, features); + auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] = + PermissionedDEX(env); + + std::vector offerSeqs; + offerSeqs.reserve(100); + + for (size_t i = 0; i <= 100; i++) + { + auto const bobOfferSeq{env.seq(bob)}; + offerSeqs.emplace_back(bobOfferSeq); + + env(offer(bob, XRP(10), USD(10)), domain(domainID)); + env.close(); + BEAST_EXPECT(checkOffer( + env, bob, bobOfferSeq, XRP(10), USD(10), 0, true)); + } + + for (auto const offerSeq : offerSeqs) + { + env(offer_cancel(bob, offerSeq)); + env.close(); + BEAST_EXPECT(!offerExists(env, bob, offerSeq)); + } + } + } + + void + testPayment(FeatureBitset features) + { + testcase("Payment"); + + // test preflight - without enabling featurePermissionedDEX amendment + { + Env env(*this, features - featurePermissionedDEX); + auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] = + PermissionedDEX(env); + + env(pay(bob, alice, USD(10)), + path(~USD), + sendmax(XRP(10)), + domain(domainID), + ter(temDISABLED)); + env.close(); + + env.enableFeature(featurePermissionedDEX); + env.close(); + + env(offer(bob, XRP(10), USD(10)), domain(domainID)); + env.close(); + + env(pay(bob, alice, USD(10)), + path(~USD), + sendmax(XRP(10)), + domain(domainID)); + env.close(); + } + + // preclaim - cannot send payment with non existent domain + { + Env env(*this, features); + auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] = + PermissionedDEX(env); + uint256 const badDomain{ + "F10D0CC9A0F9A3CBF585B80BE09A186483668FDBDD39AA7E3370F3649CE134" + "E5"}; + + env(pay(bob, alice, USD(10)), + path(~USD), + sendmax(XRP(10)), + domain(badDomain), + ter(tecNO_PERMISSION)); + env.close(); + } + + // preclaim - payment with non-domain destination fails + { + Env env(*this, features); + auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] = + PermissionedDEX(env); + + env(offer(bob, XRP(10), USD(10)), domain(domainID)); + env.close(); + + // create devin account who is not part of the domain + Account devin("devin"); + env.fund(XRP(1000), devin); + env.close(); + env.trust(USD(1000), devin); + env.close(); + env(pay(gw, devin, USD(100))); + env.close(); + + // devin is not part of domain + env(pay(alice, devin, USD(10)), + path(~USD), + sendmax(XRP(10)), + domain(domainID), + ter(tecNO_PERMISSION)); + env.close(); + + // domain owner also issues a credential for devin + env(credentials::create(devin, domainOwner, credType)); + env.close(); + + // devin has not yet accepted cred + env(pay(alice, devin, USD(10)), + path(~USD), + sendmax(XRP(10)), + domain(domainID), + ter(tecNO_PERMISSION)); + env.close(); + + env(credentials::accept(devin, domainOwner, credType)); + env.close(); + + // devin can now receive payment after he is in domain + env(pay(alice, devin, USD(10)), + path(~USD), + sendmax(XRP(10)), + domain(domainID)); + env.close(); + } + + // preclaim - non-domain sender cannot send payment + { + Env env(*this, features); + auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] = + PermissionedDEX(env); + + env(offer(bob, XRP(10), USD(10)), domain(domainID)); + env.close(); + + // create devin account who is not part of the domain + Account devin("devin"); + env.fund(XRP(1000), devin); + env.close(); + env.trust(USD(1000), devin); + env.close(); + env(pay(gw, devin, USD(100))); + env.close(); + + // devin tries to send domain payment + env(pay(devin, alice, USD(10)), + path(~USD), + sendmax(XRP(10)), + domain(domainID), + ter(tecNO_PERMISSION)); + env.close(); + + // domain owner also issues a credential for devin + env(credentials::create(devin, domainOwner, credType)); + env.close(); + + // devin has not yet accepted cred + env(pay(devin, alice, USD(10)), + path(~USD), + sendmax(XRP(10)), + domain(domainID), + ter(tecNO_PERMISSION)); + env.close(); + + env(credentials::accept(devin, domainOwner, credType)); + env.close(); + + // devin can now send payment after he is in domain + env(pay(devin, alice, USD(10)), + path(~USD), + sendmax(XRP(10)), + domain(domainID)); + env.close(); + } + + // apply - domain owner can always send and receive domain payment + { + Env env(*this, features); + auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] = + PermissionedDEX(env); + + env(offer(bob, XRP(10), USD(10)), domain(domainID)); + env.close(); + + // domain owner can always be destination + env(pay(alice, domainOwner, USD(10)), + path(~USD), + sendmax(XRP(10)), + domain(domainID)); + env.close(); + + env(offer(bob, XRP(10), USD(10)), domain(domainID)); + env.close(); + + // domain owner can send + env(pay(domainOwner, alice, USD(10)), + path(~USD), + sendmax(XRP(10)), + domain(domainID)); + env.close(); + } + } + + void + testBookStep(FeatureBitset features) + { + testcase("Book step"); + + // test domain cross currency payment consuming one offer + { + Env env(*this, features); + auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] = + PermissionedDEX(env); + + // create a regular offer without domain + auto const regularOfferSeq{env.seq(bob)}; + env(offer(bob, XRP(10), USD(10))); + env.close(); + BEAST_EXPECT( + checkOffer(env, bob, regularOfferSeq, XRP(10), USD(10))); + + auto const regularDirKey = + getDefaultOfferDirKey(env, bob, regularOfferSeq); + BEAST_EXPECT(regularDirKey); + BEAST_EXPECT(checkDirectorySize(env, *regularDirKey, 1)); + + // a domain payment cannot consume regular offers + env(pay(alice, carol, USD(10)), + path(~USD), + sendmax(XRP(10)), + domain(domainID), + ter(tecPATH_PARTIAL)); + env.close(); + + // create a domain offer + auto const domainOfferSeq{env.seq(bob)}; + env(offer(bob, XRP(10), USD(10)), domain(domainID)); + env.close(); + + BEAST_EXPECT(checkOffer( + env, bob, domainOfferSeq, XRP(10), USD(10), 0, true)); + + auto const domainDirKey = + getDefaultOfferDirKey(env, bob, domainOfferSeq); + BEAST_EXPECT(domainDirKey); + BEAST_EXPECT(checkDirectorySize(env, *domainDirKey, 1)); + + // cross-currency permissioned payment consumed + // domain offer instead of regular offer + env(pay(alice, carol, USD(10)), + path(~USD), + sendmax(XRP(10)), + domain(domainID)); + env.close(); + BEAST_EXPECT(!offerExists(env, bob, domainOfferSeq)); + BEAST_EXPECT( + checkOffer(env, bob, regularOfferSeq, XRP(10), USD(10))); + + // domain directory is empty + BEAST_EXPECT(checkDirectorySize(env, *domainDirKey, 0)); + BEAST_EXPECT(checkDirectorySize(env, *regularDirKey, 1)); + } + + // test domain payment consuming two offers in the path + { + Env env(*this, features); + auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] = + PermissionedDEX(env); + + auto const EUR = gw["EUR"]; + env.trust(EUR(1000), alice); + env.close(); + env.trust(EUR(1000), bob); + env.close(); + env.trust(EUR(1000), carol); + env.close(); + env(pay(gw, bob, EUR(100))); + env.close(); + + // create XRP/USD domain offer + auto const usdOfferSeq{env.seq(bob)}; + env(offer(bob, XRP(10), USD(10)), domain(domainID)); + env.close(); + + BEAST_EXPECT( + checkOffer(env, bob, usdOfferSeq, XRP(10), USD(10), 0, true)); + + // payment fail because there isn't eur offer + env(pay(alice, carol, EUR(10)), + path(~USD, ~EUR), + sendmax(XRP(10)), + domain(domainID), + ter(tecPATH_PARTIAL)); + env.close(); + BEAST_EXPECT( + checkOffer(env, bob, usdOfferSeq, XRP(10), USD(10), 0, true)); + + // bob creates a regular USD/EUR offer + auto const regularOfferSeq{env.seq(bob)}; + env(offer(bob, USD(10), EUR(10))); + env.close(); + BEAST_EXPECT( + checkOffer(env, bob, regularOfferSeq, USD(10), EUR(10))); + + // alice tries to pay again, but still fails because the regular + // offer cannot be consumed + env(pay(alice, carol, EUR(10)), + path(~USD, ~EUR), + sendmax(XRP(10)), + domain(domainID), + ter(tecPATH_PARTIAL)); + env.close(); + + // bob creates a domain USD/EUR offer + auto const eurOfferSeq{env.seq(bob)}; + env(offer(bob, USD(10), EUR(10)), domain(domainID)); + env.close(); + BEAST_EXPECT( + checkOffer(env, bob, eurOfferSeq, USD(10), EUR(10), 0, true)); + + // alice successfully consume two domain offers: xrp/usd and usd/eur + env(pay(alice, carol, EUR(5)), + sendmax(XRP(5)), + domain(domainID), + path(~USD, ~EUR)); + env.close(); + + BEAST_EXPECT( + checkOffer(env, bob, usdOfferSeq, XRP(5), USD(5), 0, true)); + BEAST_EXPECT( + checkOffer(env, bob, eurOfferSeq, USD(5), EUR(5), 0, true)); + + // alice successfully consume two domain offers and deletes them + // we compute path this time using `paths` + env(pay(alice, carol, EUR(5)), + sendmax(XRP(5)), + domain(domainID), + paths(XRP)); + env.close(); + + BEAST_EXPECT(!offerExists(env, bob, usdOfferSeq)); + BEAST_EXPECT(!offerExists(env, bob, eurOfferSeq)); + + // regular offer is not consumed + BEAST_EXPECT( + checkOffer(env, bob, regularOfferSeq, USD(10), EUR(10))); + } + + // domain payment cannot consume offer from another domain + { + Env env(*this, features); + auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] = + PermissionedDEX(env); + + // Fund devin and create USD trustline + Account badDomainOwner("badDomainOwner"); + Account devin("devin"); + env.fund(XRP(1000), badDomainOwner, devin); + env.close(); + env.trust(USD(1000), devin); + env.close(); + env(pay(gw, devin, USD(100))); + env.close(); + + auto const badCredType = "badCred"; + pdomain::Credentials credentials{{badDomainOwner, badCredType}}; + env(pdomain::setTx(badDomainOwner, credentials)); + + auto objects = pdomain::getObjects(badDomainOwner, env); + auto const badDomainID = objects.begin()->first; + + env(credentials::create(devin, badDomainOwner, badCredType)); + env.close(); + env(credentials::accept(devin, badDomainOwner, badCredType)); + + // devin creates a domain offer in another domain + env(offer(devin, XRP(10), USD(10)), domain(badDomainID)); + env.close(); + + // domain payment can't consume an offer from another domain + env(pay(alice, carol, USD(10)), + path(~USD), + sendmax(XRP(10)), + domain(domainID), + ter(tecPATH_PARTIAL)); + env.close(); + + // bob creates an offer under the right domain + auto const bobOfferSeq{env.seq(bob)}; + env(offer(bob, XRP(10), USD(10)), domain(domainID)); + env.close(); + BEAST_EXPECT( + checkOffer(env, bob, bobOfferSeq, XRP(10), USD(10), 0, true)); + + // domain payment now consumes from the right domain + env(pay(alice, carol, USD(10)), + path(~USD), + sendmax(XRP(10)), + domain(domainID)); + env.close(); + + BEAST_EXPECT(!offerExists(env, bob, bobOfferSeq)); + } + + // sanity check: devin, who is part of the domain but doesn't have a + // trustline with USD issuer, can successfully make a payment using + // offer + { + Env env(*this, features); + auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] = + PermissionedDEX(env); + + env(offer(bob, XRP(10), USD(10)), domain(domainID)); + env.close(); + + // fund devin but don't create a USD trustline with gateway + Account devin("devin"); + env.fund(XRP(1000), devin); + env.close(); + + // domain owner also issues a credential for devin + env(credentials::create(devin, domainOwner, credType)); + env.close(); + + env(credentials::accept(devin, domainOwner, credType)); + env.close(); + + // successful payment because offer is consumed + env(pay(devin, alice, USD(10)), sendmax(XRP(10)), domain(domainID)); + env.close(); + } + + // offer becomes unfunded when offer owner's cred expires + { + Env env(*this, features); + auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] = + PermissionedDEX(env); + + // create devin account who is not part of the domain + Account devin("devin"); + env.fund(XRP(1000), devin); + env.close(); + env.trust(USD(1000), devin); + env.close(); + env(pay(gw, devin, USD(100))); + env.close(); + + auto jv = credentials::create(devin, domainOwner, credType); + uint32_t const t = env.current() + ->info() + .parentCloseTime.time_since_epoch() + .count(); + jv[sfExpiration.jsonName] = t + 20; + env(jv); + + env(credentials::accept(devin, domainOwner, credType)); + env.close(); + + // devin can still create offer while his cred is not expired + auto const offerSeq{env.seq(devin)}; + env(offer(devin, XRP(10), USD(10)), domain(domainID)); + env.close(); + + // devin's offer can still be consumed while his cred isn't expired + env(pay(alice, carol, USD(5)), + path(~USD), + sendmax(XRP(5)), + domain(domainID)); + env.close(); + BEAST_EXPECT( + checkOffer(env, devin, offerSeq, XRP(5), USD(5), 0, true)); + + // advance time + env.close(std::chrono::seconds(20)); + + // devin's offer is unfunded now due to expired cred + env(pay(alice, carol, USD(5)), + path(~USD), + sendmax(XRP(5)), + domain(domainID), + ter(tecPATH_PARTIAL)); + env.close(); + BEAST_EXPECT( + checkOffer(env, devin, offerSeq, XRP(5), USD(5), 0, true)); + } + + // offer becomes unfunded when offer owner's cred is removed + { + Env env(*this, features); + auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] = + PermissionedDEX(env); + + auto const offerSeq{env.seq(bob)}; + env(offer(bob, XRP(10), USD(10)), domain(domainID)); + env.close(); + + // bob's offer can still be consumed while his cred exists + env(pay(alice, carol, USD(5)), + path(~USD), + sendmax(XRP(5)), + domain(domainID)); + env.close(); + BEAST_EXPECT( + checkOffer(env, bob, offerSeq, XRP(5), USD(5), 0, true)); + + // remove bob's cred + env(credentials::deleteCred( + domainOwner, bob, domainOwner, credType)); + env.close(); + + // bob's offer is unfunded now due to expired cred + env(pay(alice, carol, USD(5)), + path(~USD), + sendmax(XRP(5)), + domain(domainID), + ter(tecPATH_PARTIAL)); + env.close(); + BEAST_EXPECT( + checkOffer(env, bob, offerSeq, XRP(5), USD(5), 0, true)); + } + } + + void + testRippling(FeatureBitset features) + { + testcase("Rippling"); + + // test a non-domain account can still be part of rippling in a domain + // payment. If the domain wishes to control who is allowed to ripple + // through, they should set the rippling individually + Env env(*this, features); + auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] = + PermissionedDEX(env); + + auto const EURA = alice["EUR"]; + auto const EURB = bob["EUR"]; + + env.trust(EURA(100), bob); + env.trust(EURB(100), carol); + env.close(); + + // remove bob from domain + env(credentials::deleteCred(domainOwner, bob, domainOwner, credType)); + env.close(); + + // alice can still ripple through bob even though he's not part + // of the domain, this is intentional + env(pay(alice, carol, EURB(10)), paths(EURA), domain(domainID)); + env.close(); + env.require(balance(bob, EURA(10)), balance(carol, EURB(10))); + + // carol sets no ripple on bob + env(trust(carol, bob["EUR"](0), bob, tfSetNoRipple)); + env.close(); + + // payment no longer works because carol has no ripple on bob + env(pay(alice, carol, EURB(5)), + paths(EURA), + domain(domainID), + ter(tecPATH_DRY)); + env.close(); + env.require(balance(bob, EURA(10)), balance(carol, EURB(10))); + } + + void + testOfferTokenIssuerInDomain(FeatureBitset features) + { + testcase("Offer token issuer in domain"); + + // whether the issuer is in the domain should NOT affect whether an + // offer can be consumed in domain payment + Env env(*this, features); + auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] = + PermissionedDEX(env); + + // create an xrp/usd offer with usd as takergets + auto const bobOffer1Seq{env.seq(bob)}; + env(offer(bob, XRP(10), USD(10)), domain(domainID)); + env.close(); + + // create an usd/xrp offer with usd as takerpays + auto const bobOffer2Seq{env.seq(bob)}; + env(offer(bob, USD(10), XRP(10)), domain(domainID), txflags(tfPassive)); + env.close(); + + BEAST_EXPECT( + checkOffer(env, bob, bobOffer1Seq, XRP(10), USD(10), 0, true)); + BEAST_EXPECT(checkOffer( + env, bob, bobOffer2Seq, USD(10), XRP(10), lsfPassive, true)); + + // remove gateway from domain + env(credentials::deleteCred(domainOwner, gw, domainOwner, credType)); + env.close(); + + // payment succeeds even if issuer is not in domain + // xrp/usd offer is consumed + env(pay(alice, carol, USD(10)), + path(~USD), + sendmax(XRP(10)), + domain(domainID)); + env.close(); + BEAST_EXPECT(!offerExists(env, bob, bobOffer1Seq)); + + // payment succeeds even if issuer is not in domain + // usd/xrp offer is consumed + env(pay(alice, carol, XRP(10)), + path(~XRP), + sendmax(USD(10)), + domain(domainID)); + env.close(); + BEAST_EXPECT(!offerExists(env, bob, bobOffer2Seq)); + } + + void + testRemoveUnfundedOffer(FeatureBitset features) + { + testcase("Remove unfunded offer"); + + // checking that an unfunded offer will be implictly removed by a + // successfuly payment tx + Env env(*this, features); + auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] = + PermissionedDEX(env); + + auto const aliceOfferSeq{env.seq(alice)}; + env(offer(alice, XRP(100), USD(100)), domain(domainID)); + env.close(); + + auto const bobOfferSeq{env.seq(bob)}; + env(offer(bob, XRP(20), USD(20)), domain(domainID)); + env.close(); + + BEAST_EXPECT( + checkOffer(env, bob, bobOfferSeq, XRP(20), USD(20), 0, true)); + BEAST_EXPECT( + checkOffer(env, alice, aliceOfferSeq, XRP(100), USD(100), 0, true)); + + auto const domainDirKey = getDefaultOfferDirKey(env, bob, bobOfferSeq); + BEAST_EXPECT(domainDirKey); + BEAST_EXPECT(checkDirectorySize(env, *domainDirKey, 2)); + + // remove alice from domain and thus alice's offer becomes unfunded + env(credentials::deleteCred(domainOwner, alice, domainOwner, credType)); + env.close(); + + env(pay(gw, carol, USD(10)), + path(~USD), + sendmax(XRP(10)), + domain(domainID)); + env.close(); + + BEAST_EXPECT( + checkOffer(env, bob, bobOfferSeq, XRP(10), USD(10), 0, true)); + + // alice's unfunded offer is removed implicitly + BEAST_EXPECT(!offerExists(env, alice, aliceOfferSeq)); + BEAST_EXPECT(checkDirectorySize(env, *domainDirKey, 1)); + } + + void + testAmmNotUsed(FeatureBitset features) + { + testcase("AMM not used"); + + Env env(*this, features); + auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] = + PermissionedDEX(env); + AMM amm(env, alice, XRP(10), USD(50)); + + // a domain payment isn't able to consume AMM + env(pay(bob, carol, USD(5)), + path(~USD), + sendmax(XRP(5)), + domain(domainID), + ter(tecPATH_PARTIAL)); + env.close(); + + // a non domain payment can use AMM + env(pay(bob, carol, USD(5)), path(~USD), sendmax(XRP(5))); + env.close(); + + // USD amount in AMM is changed + auto [xrp, usd, lpt] = amm.balances(XRP, USD); + BEAST_EXPECT(usd == USD(45)); + } + + void + testHybridOfferCreate(FeatureBitset features) + { + testcase("Hybrid offer create"); + + // test preflight - invalid hybrid flag + { + Env env(*this, features - featurePermissionedDEX); + auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] = + PermissionedDEX(env); + + env(offer(bob, XRP(10), USD(10)), + domain(domainID), + txflags(tfHybrid), + ter(temDISABLED)); + env.close(); + + env(offer(bob, XRP(10), USD(10)), + txflags(tfHybrid), + ter(temINVALID_FLAG)); + env.close(); + + env.enableFeature(featurePermissionedDEX); + env.close(); + + // hybrid offer must have domainID + env(offer(bob, XRP(10), USD(10)), + txflags(tfHybrid), + ter(temINVALID_FLAG)); + env.close(); + + // hybrid offer must have domainID + auto const offerSeq{env.seq(bob)}; + env(offer(bob, XRP(10), USD(10)), + txflags(tfHybrid), + domain(domainID)); + env.close(); + BEAST_EXPECT(checkOffer( + env, bob, offerSeq, XRP(10), USD(10), lsfHybrid, true)); + } + + // apply - domain offer can cross with hybrid + { + Env env(*this, features); + auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] = + PermissionedDEX(env); + + auto const bobOfferSeq{env.seq(bob)}; + env(offer(bob, XRP(10), USD(10)), + txflags(tfHybrid), + domain(domainID)); + env.close(); + + BEAST_EXPECT(checkOffer( + env, bob, bobOfferSeq, XRP(10), USD(10), lsfHybrid, true)); + BEAST_EXPECT(offerExists(env, bob, bobOfferSeq)); + BEAST_EXPECT(ownerCount(env, bob) == 3); + + auto const aliceOfferSeq{env.seq(alice)}; + env(offer(alice, USD(10), XRP(10)), domain(domainID)); + env.close(); + + BEAST_EXPECT(!offerExists(env, alice, aliceOfferSeq)); + BEAST_EXPECT(!offerExists(env, bob, bobOfferSeq)); + BEAST_EXPECT(ownerCount(env, alice) == 2); + } + + // apply - open offer can cross with hybrid + { + Env env(*this, features); + auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] = + PermissionedDEX(env); + + auto const bobOfferSeq{env.seq(bob)}; + env(offer(bob, XRP(10), USD(10)), + txflags(tfHybrid), + domain(domainID)); + env.close(); + + BEAST_EXPECT(offerExists(env, bob, bobOfferSeq)); + BEAST_EXPECT(ownerCount(env, bob) == 3); + BEAST_EXPECT(checkOffer( + env, bob, bobOfferSeq, XRP(10), USD(10), lsfHybrid, true)); + + auto const aliceOfferSeq{env.seq(alice)}; + env(offer(alice, USD(10), XRP(10))); + env.close(); + + BEAST_EXPECT(!offerExists(env, alice, aliceOfferSeq)); + BEAST_EXPECT(!offerExists(env, bob, bobOfferSeq)); + BEAST_EXPECT(ownerCount(env, alice) == 2); + } + + // apply - by default, hybrid offer tries to cross with offers in the + // domain book + { + Env env(*this, features); + auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] = + PermissionedDEX(env); + + auto const bobOfferSeq{env.seq(bob)}; + env(offer(bob, XRP(10), USD(10)), domain(domainID)); + env.close(); + + BEAST_EXPECT( + checkOffer(env, bob, bobOfferSeq, XRP(10), USD(10), 0, true)); + BEAST_EXPECT(ownerCount(env, bob) == 3); + + // hybrid offer auto crosses with domain offer + auto const aliceOfferSeq{env.seq(alice)}; + env(offer(alice, USD(10), XRP(10)), + domain(domainID), + txflags(tfHybrid)); + env.close(); + + BEAST_EXPECT(!offerExists(env, alice, aliceOfferSeq)); + BEAST_EXPECT(!offerExists(env, bob, bobOfferSeq)); + BEAST_EXPECT(ownerCount(env, alice) == 2); + } + + // apply - hybrid offer does not automatically cross with open offers + // because by default, it only tries to cross domain offers + { + Env env(*this, features); + auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] = + PermissionedDEX(env); + + auto const bobOfferSeq{env.seq(bob)}; + env(offer(bob, XRP(10), USD(10))); + env.close(); + + BEAST_EXPECT( + checkOffer(env, bob, bobOfferSeq, XRP(10), USD(10), 0, false)); + BEAST_EXPECT(ownerCount(env, bob) == 3); + + // hybrid offer auto crosses with domain offer + auto const aliceOfferSeq{env.seq(alice)}; + env(offer(alice, USD(10), XRP(10)), + domain(domainID), + txflags(tfHybrid)); + env.close(); + + BEAST_EXPECT(offerExists(env, alice, aliceOfferSeq)); + BEAST_EXPECT(offerExists(env, bob, bobOfferSeq)); + BEAST_EXPECT( + checkOffer(env, bob, bobOfferSeq, XRP(10), USD(10), 0, false)); + BEAST_EXPECT(checkOffer( + env, alice, aliceOfferSeq, USD(10), XRP(10), lsfHybrid, true)); + BEAST_EXPECT(ownerCount(env, alice) == 3); + } + } + + void + testHybridInvalidOffer(FeatureBitset features) + { + testcase("Hybrid invalid offer"); + + // bob has a hybrid offer and then he is removed from domain. + // in this case, the hybrid offer will be considered as unfunded even in + // a regular payment + Env env(*this, features); + auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] = + PermissionedDEX(env); + + auto const hybridOfferSeq{env.seq(bob)}; + env(offer(bob, XRP(50), USD(50)), txflags(tfHybrid), domain(domainID)); + env.close(); + + // remove bob from domain + env(credentials::deleteCred(domainOwner, bob, domainOwner, credType)); + env.close(); + + // bob's hybrid offer is unfunded and can not be consumed in a domain + // payment + env(pay(alice, carol, USD(5)), + path(~USD), + sendmax(XRP(5)), + domain(domainID), + ter(tecPATH_PARTIAL)); + env.close(); + BEAST_EXPECT(checkOffer( + env, bob, hybridOfferSeq, XRP(50), USD(50), lsfHybrid, true)); + + // bob's unfunded hybrid offer can't be consumed even with a regular + // payment + env(pay(alice, carol, USD(5)), + path(~USD), + sendmax(XRP(5)), + ter(tecPATH_PARTIAL)); + env.close(); + BEAST_EXPECT(checkOffer( + env, bob, hybridOfferSeq, XRP(50), USD(50), lsfHybrid, true)); + + // create a regular offer + auto const regularOfferSeq{env.seq(bob)}; + env(offer(bob, XRP(10), USD(10))); + env.close(); + BEAST_EXPECT(offerExists(env, bob, regularOfferSeq)); + BEAST_EXPECT(checkOffer(env, bob, regularOfferSeq, XRP(10), USD(10))); + + auto const sleHybridOffer = + env.le(keylet::offer(bob.id(), hybridOfferSeq)); + BEAST_EXPECT(sleHybridOffer); + auto const openDir = + sleHybridOffer->getFieldArray(sfAdditionalBooks)[0].getFieldH256( + sfBookDirectory); + BEAST_EXPECT(checkDirectorySize(env, openDir, 2)); + + // this normal payment should consume the regular offer and remove the + // unfunded hybrid offer + env(pay(alice, carol, USD(5)), path(~USD), sendmax(XRP(5))); + env.close(); + + BEAST_EXPECT(!offerExists(env, bob, hybridOfferSeq)); + BEAST_EXPECT(checkOffer(env, bob, regularOfferSeq, XRP(5), USD(5))); + BEAST_EXPECT(checkDirectorySize(env, openDir, 1)); + } + + void + testHybridBookStep(FeatureBitset features) + { + testcase("Hybrid book step"); + + // both non domain and domain payments can consume hybrid offer + { + Env env(*this, features); + auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] = + PermissionedDEX(env); + + auto const hybridOfferSeq{env.seq(bob)}; + env(offer(bob, XRP(10), USD(10)), + txflags(tfHybrid), + domain(domainID)); + env.close(); + + env(pay(alice, carol, USD(5)), + path(~USD), + sendmax(XRP(5)), + domain(domainID)); + env.close(); + BEAST_EXPECT(checkOffer( + env, bob, hybridOfferSeq, XRP(5), USD(5), lsfHybrid, true)); + + // hybrid offer can't be consumed since bob is not in domain anymore + env(pay(alice, carol, USD(5)), path(~USD), sendmax(XRP(5))); + env.close(); + + BEAST_EXPECT(!offerExists(env, bob, hybridOfferSeq)); + } + + // someone from another domain can't cross hybrid if they specified + // wrong domainID + { + Env env(*this, features); + auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] = + PermissionedDEX(env); + + // Fund accounts + Account badDomainOwner("badDomainOwner"); + Account devin("devin"); + env.fund(XRP(1000), badDomainOwner, devin); + env.close(); + + auto const badCredType = "badCred"; + pdomain::Credentials credentials{{badDomainOwner, badCredType}}; + env(pdomain::setTx(badDomainOwner, credentials)); + + auto objects = pdomain::getObjects(badDomainOwner, env); + auto const badDomainID = objects.begin()->first; + + env(credentials::create(devin, badDomainOwner, badCredType)); + env.close(); + env(credentials::accept(devin, badDomainOwner, badCredType)); + env.close(); + + auto const hybridOfferSeq{env.seq(bob)}; + env(offer(bob, XRP(10), USD(10)), + txflags(tfHybrid), + domain(domainID)); + env.close(); + + // other domains can't consume the offer + env(pay(devin, badDomainOwner, USD(5)), + path(~USD), + sendmax(XRP(5)), + domain(badDomainID), + ter(tecPATH_DRY)); + env.close(); + BEAST_EXPECT(checkOffer( + env, bob, hybridOfferSeq, XRP(10), USD(10), lsfHybrid, true)); + + env(pay(alice, carol, USD(5)), + path(~USD), + sendmax(XRP(5)), + domain(domainID)); + env.close(); + BEAST_EXPECT(checkOffer( + env, bob, hybridOfferSeq, XRP(5), USD(5), lsfHybrid, true)); + + // hybrid offer can't be consumed since bob is not in domain anymore + env(pay(alice, carol, USD(5)), path(~USD), sendmax(XRP(5))); + env.close(); + + BEAST_EXPECT(!offerExists(env, bob, hybridOfferSeq)); + } + + // test domain payment consuming two offers w/ hybrid offer + { + Env env(*this, features); + auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] = + PermissionedDEX(env); + + auto const EUR = gw["EUR"]; + env.trust(EUR(1000), alice); + env.close(); + env.trust(EUR(1000), bob); + env.close(); + env.trust(EUR(1000), carol); + env.close(); + env(pay(gw, bob, EUR(100))); + env.close(); + + auto const usdOfferSeq{env.seq(bob)}; + env(offer(bob, XRP(10), USD(10)), domain(domainID)); + env.close(); + + BEAST_EXPECT( + checkOffer(env, bob, usdOfferSeq, XRP(10), USD(10), 0, true)); + + // payment fail because there isn't eur offer + env(pay(alice, carol, EUR(5)), + path(~USD, ~EUR), + sendmax(XRP(5)), + domain(domainID), + ter(tecPATH_PARTIAL)); + env.close(); + BEAST_EXPECT( + checkOffer(env, bob, usdOfferSeq, XRP(10), USD(10), 0, true)); + + // bob creates a hybrid eur offer + auto const eurOfferSeq{env.seq(bob)}; + env(offer(bob, USD(10), EUR(10)), + domain(domainID), + txflags(tfHybrid)); + env.close(); + BEAST_EXPECT(checkOffer( + env, bob, eurOfferSeq, USD(10), EUR(10), lsfHybrid, true)); + + // alice successfully consume two domain offers: xrp/usd and usd/eur + env(pay(alice, carol, EUR(5)), + path(~USD, ~EUR), + sendmax(XRP(5)), + domain(domainID)); + env.close(); + + BEAST_EXPECT( + checkOffer(env, bob, usdOfferSeq, XRP(5), USD(5), 0, true)); + BEAST_EXPECT(checkOffer( + env, bob, eurOfferSeq, USD(5), EUR(5), lsfHybrid, true)); + } + + // test regular payment using a regular offer and a hybrid offer + { + Env env(*this, features); + auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] = + PermissionedDEX(env); + + auto const EUR = gw["EUR"]; + env.trust(EUR(1000), alice); + env.close(); + env.trust(EUR(1000), bob); + env.close(); + env.trust(EUR(1000), carol); + env.close(); + env(pay(gw, bob, EUR(100))); + env.close(); + + // bob creates a regular usd offer + auto const usdOfferSeq{env.seq(bob)}; + env(offer(bob, XRP(10), USD(10))); + env.close(); + + BEAST_EXPECT( + checkOffer(env, bob, usdOfferSeq, XRP(10), USD(10), 0, false)); + + // bob creates a hybrid eur offer + auto const eurOfferSeq{env.seq(bob)}; + env(offer(bob, USD(10), EUR(10)), + domain(domainID), + txflags(tfHybrid)); + env.close(); + BEAST_EXPECT(checkOffer( + env, bob, eurOfferSeq, USD(10), EUR(10), lsfHybrid, true)); + + // alice successfully consume two offers: xrp/usd and usd/eur + env(pay(alice, carol, EUR(5)), path(~USD, ~EUR), sendmax(XRP(5))); + env.close(); + + BEAST_EXPECT( + checkOffer(env, bob, usdOfferSeq, XRP(5), USD(5), 0, false)); + BEAST_EXPECT(checkOffer( + env, bob, eurOfferSeq, USD(5), EUR(5), lsfHybrid, true)); + } + } + + void + testHybridOfferDirectories(FeatureBitset features) + { + Env env(*this, features); + auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] = + PermissionedDEX(env); + + std::vector offerSeqs; + offerSeqs.reserve(100); + + Book domainBook{Issue(XRP), Issue(USD), domainID}; + Book openBook{Issue(XRP), Issue(USD)}; + + auto const domainDir = getBookDirKey(domainBook, XRP(10), USD(10)); + auto const openDir = getBookDirKey(openBook, XRP(10), USD(10)); + + size_t dirCnt = 100; + + for (size_t i = 1; i <= dirCnt; i++) + { + auto const bobOfferSeq{env.seq(bob)}; + offerSeqs.emplace_back(bobOfferSeq); + env(offer(bob, XRP(10), USD(10)), + txflags(tfHybrid), + domain(domainID)); + env.close(); + + auto const sleOffer = env.le(keylet::offer(bob.id(), bobOfferSeq)); + BEAST_EXPECT(sleOffer); + BEAST_EXPECT(sleOffer->getFieldH256(sfBookDirectory) == domainDir); + BEAST_EXPECT( + sleOffer->getFieldArray(sfAdditionalBooks).size() == 1); + BEAST_EXPECT( + sleOffer->getFieldArray(sfAdditionalBooks)[0].getFieldH256( + sfBookDirectory) == openDir); + + BEAST_EXPECT(checkOffer( + env, bob, bobOfferSeq, XRP(10), USD(10), lsfHybrid, true)); + BEAST_EXPECT(checkDirectorySize(env, domainDir, i)); + BEAST_EXPECT(checkDirectorySize(env, openDir, i)); + } + + for (auto const offerSeq : offerSeqs) + { + env(offer_cancel(bob, offerSeq)); + env.close(); + dirCnt--; + BEAST_EXPECT(!offerExists(env, bob, offerSeq)); + BEAST_EXPECT(checkDirectorySize(env, domainDir, dirCnt)); + BEAST_EXPECT(checkDirectorySize(env, openDir, dirCnt)); + } + } + +public: + void + run() override + { + FeatureBitset const all{jtx::supported_amendments()}; + + // Test domain offer (w/o hyrbid) + testOfferCreate(all); + testPayment(all); + testBookStep(all); + testRippling(all); + testOfferTokenIssuerInDomain(all); + testRemoveUnfundedOffer(all); + testAmmNotUsed(all); + + // Test hybrid offers + testHybridOfferCreate(all); + testHybridBookStep(all); + testHybridInvalidOffer(all); + testHybridOfferDirectories(all); + } +}; + +BEAST_DEFINE_TESTSUITE(PermissionedDEX, app, ripple); + +} // namespace test +} // namespace ripple diff --git a/src/test/app/SetAuth_test.cpp b/src/test/app/SetAuth_test.cpp index e55fbc4d5df..a4c2df6228b 100644 --- a/src/test/app/SetAuth_test.cpp +++ b/src/test/app/SetAuth_test.cpp @@ -75,7 +75,8 @@ struct SetAuth_test : public beast::unit_test::suite { using namespace jtx; auto const sa = supported_amendments(); - testAuth(sa - featureFlowCross); + testAuth(sa - featureFlowCross - featurePermissionedDEX); + testAuth(sa - featurePermissionedDEX); testAuth(sa); } }; diff --git a/src/test/app/TheoreticalQuality_test.cpp b/src/test/app/TheoreticalQuality_test.cpp index 0269d206ccc..1b3e6d9a82b 100644 --- a/src/test/app/TheoreticalQuality_test.cpp +++ b/src/test/app/TheoreticalQuality_test.cpp @@ -267,6 +267,7 @@ class TheoreticalQuality_test : public beast::unit_test::suite sb.rules().enabled(featureOwnerPaysFee), OfferCrossing::no, ammContext, + std::nullopt, dummyJ); BEAST_EXPECT(sr.first == tesSUCCESS); diff --git a/src/test/app/TrustAndBalance_test.cpp b/src/test/app/TrustAndBalance_test.cpp index 037a7e0d895..8f092a725f5 100644 --- a/src/test/app/TrustAndBalance_test.cpp +++ b/src/test/app/TrustAndBalance_test.cpp @@ -481,7 +481,8 @@ class TrustAndBalance_test : public beast::unit_test::suite using namespace test::jtx; auto const sa = supported_amendments(); - testWithFeatures(sa - featureFlowCross); + testWithFeatures(sa - featureFlowCross - featurePermissionedDEX); + testWithFeatures(sa - featurePermissionedDEX); testWithFeatures(sa); } }; diff --git a/src/test/jtx.h b/src/test/jtx.h index 6b73ca63eca..a4f89df3bc8 100644 --- a/src/test/jtx.h +++ b/src/test/jtx.h @@ -36,6 +36,7 @@ #include #include #include +#include #include #include #include @@ -50,6 +51,7 @@ #include #include #include +#include #include #include #include diff --git a/src/test/jtx/domain.h b/src/test/jtx/domain.h new file mode 100644 index 00000000000..47b76476dac --- /dev/null +++ b/src/test/jtx/domain.h @@ -0,0 +1,45 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2025 Ripple Labs Inc. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#pragma once + +#include + +namespace ripple { +namespace test { +namespace jtx { + +/** Set the domain on a JTx. */ +class domain +{ +private: + uint256 v_; + +public: + explicit domain(uint256 v) : v_(v) + { + } + + void + operator()(Env&, JTx& jt) const; +}; + +} // namespace jtx +} // namespace test +} // namespace ripple diff --git a/src/test/jtx/impl/domain.cpp b/src/test/jtx/impl/domain.cpp new file mode 100644 index 00000000000..51adb4ce98c --- /dev/null +++ b/src/test/jtx/impl/domain.cpp @@ -0,0 +1,36 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2025 Ripple Labs Inc. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include + +#include + +namespace ripple { +namespace test { +namespace jtx { + +void +domain::operator()(Env&, JTx& jt) const +{ + jt[sfDomainID.jsonName] = to_string(v_); +} + +} // namespace jtx +} // namespace test +} // namespace ripple diff --git a/src/test/jtx/impl/paths.cpp b/src/test/jtx/impl/paths.cpp index 2a45909eb91..f230305469f 100644 --- a/src/test/jtx/impl/paths.cpp +++ b/src/test/jtx/impl/paths.cpp @@ -23,6 +23,8 @@ #include +#include + namespace ripple { namespace test { namespace jtx { @@ -34,6 +36,18 @@ paths::operator()(Env& env, JTx& jt) const auto const from = env.lookup(jv[jss::Account].asString()); auto const to = env.lookup(jv[jss::Destination].asString()); auto const amount = amountFromJson(sfAmount, jv[jss::Amount]); + + std::optional domain; + if (jv.isMember(sfDomainID.jsonName)) + { + if (!jv[sfDomainID.jsonName].isString()) + return; + uint256 num; + auto const s = jv[sfDomainID.jsonName].asString(); + if (num.parseHex(s)) + domain = num; + } + Pathfinder pf( std::make_shared( env.current(), env.app().journal("RippleLineCache")), @@ -43,6 +57,7 @@ paths::operator()(Env& env, JTx& jt) const in_.account, amount, std::nullopt, + domain, env.app()); if (!pf.findPaths(depth_)) return; diff --git a/src/test/jtx/impl/permissioned_dex.cpp b/src/test/jtx/impl/permissioned_dex.cpp new file mode 100644 index 00000000000..145d73c23d2 --- /dev/null +++ b/src/test/jtx/impl/permissioned_dex.cpp @@ -0,0 +1,87 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2025 Ripple Labs Inc. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include + +#include +#include + +#include + +namespace ripple { +namespace test { +namespace jtx { + +uint256 +setupDomain( + jtx::Env& env, + std::vector const& accounts, + jtx::Account const& domainOwner, + std::string credType) +{ + using namespace jtx; + env.fund(XRP(100000), domainOwner); + env.close(); + + pdomain::Credentials credentials{{domainOwner, credType}}; + env(pdomain::setTx(domainOwner, credentials)); + + auto objects = pdomain::getObjects(domainOwner, env); + auto const domainID = objects.begin()->first; + + for (auto const& account : accounts) + { + env(credentials::create(account, domainOwner, credType)); + env.close(); + env(credentials::accept(account, domainOwner, credType)); + env.close(); + } + return domainID; +} + +PermissionedDEX::PermissionedDEX(Env& env) + : gw("permdex-gateway") + , domainOwner("permdex-domainOwner") + , alice("permdex-alice") + , bob("permdex-bob") + , carol("permdex-carol") + , USD(gw["USD"]) + , credType("permdex-abcde") +{ + // Fund accounts + env.fund(XRP(100000), alice, bob, carol, gw); + env.close(); + + domainID = setupDomain(env, {alice, bob, carol, gw}, domainOwner, credType); + + auto setupTrustline = [&](Account const account) { + env.trust(USD(1000), account); + env.close(); + + env(pay(gw, account, USD(100))); + env.close(); + }; + + for (auto const& account : {alice, bob, carol, domainOwner}) + setupTrustline(account); +} + +} // namespace jtx +} // namespace test +} // namespace ripple diff --git a/src/test/jtx/permissioned_dex.h b/src/test/jtx/permissioned_dex.h new file mode 100644 index 00000000000..44d2787bb76 --- /dev/null +++ b/src/test/jtx/permissioned_dex.h @@ -0,0 +1,51 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2025 Ripple Labs Inc. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#pragma once + +#include +namespace ripple { +namespace test { +namespace jtx { + +uint256 +setupDomain( + jtx::Env& env, + std::vector const& accounts, + jtx::Account const& domainOwner = jtx::Account("domainOwner"), + std::string credType = "Cred"); + +class PermissionedDEX +{ +public: + Account gw; + Account domainOwner; + Account alice; + Account bob; + Account carol; + IOU USD; + uint256 domainID; + std::string credType; + + PermissionedDEX(Env& env); +}; + +} // namespace jtx +} // namespace test +} // namespace ripple diff --git a/src/test/ledger/BookDirs_test.cpp b/src/test/ledger/BookDirs_test.cpp index ed7ca910830..063827059e6 100644 --- a/src/test/ledger/BookDirs_test.cpp +++ b/src/test/ledger/BookDirs_test.cpp @@ -101,7 +101,8 @@ struct BookDirs_test : public beast::unit_test::suite { using namespace jtx; auto const sa = supported_amendments(); - test_bookdir(sa - featureFlowCross); + test_bookdir(sa - featureFlowCross - featurePermissionedDEX); + test_bookdir(sa - featurePermissionedDEX); test_bookdir(sa); } }; diff --git a/src/test/ledger/Invariants_test.cpp b/src/test/ledger/Invariants_test.cpp index 18b037cbbe0..a280271c1ff 100644 --- a/src/test/ledger/Invariants_test.cpp +++ b/src/test/ledger/Invariants_test.cpp @@ -976,6 +976,30 @@ class Invariants_test : public beast::unit_test::suite }); } + void + createPermissionedDomain( + ApplyContext& ac, + std::shared_ptr& sle, + test::jtx::Account const& A1, + test::jtx::Account const& A2) + { + sle->setAccountID(sfOwner, A1); + sle->setFieldU32(sfSequence, 10); + + STArray credentials(sfAcceptedCredentials, 2); + for (std::size_t n = 0; n < 2; ++n) + { + auto cred = STObject::makeInnerObject(sfCredential); + cred.setAccountID(sfIssuer, A2); + auto credType = "cred_type" + std::to_string(n); + cred.setFieldVL( + sfCredentialType, Slice(credType.c_str(), credType.size())); + credentials.push_back(std::move(cred)); + } + sle->setFieldArray(sfAcceptedCredentials, credentials); + ac.view().insert(sle); + }; + void testPermissionedDomainInvariants() { @@ -1083,36 +1107,15 @@ class Invariants_test : public beast::unit_test::suite STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject& tx) {}}, {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); - auto const createPD = [](ApplyContext& ac, - std::shared_ptr& sle, - Account const& A1, - Account const& A2) { - sle->setAccountID(sfOwner, A1); - sle->setFieldU32(sfSequence, 10); - - STArray credentials(sfAcceptedCredentials, 2); - for (std::size_t n = 0; n < 2; ++n) - { - auto cred = STObject::makeInnerObject(sfCredential); - cred.setAccountID(sfIssuer, A2); - auto credType = "cred_type" + std::to_string(n); - cred.setFieldVL( - sfCredentialType, Slice(credType.c_str(), credType.size())); - credentials.push_back(std::move(cred)); - } - sle->setFieldArray(sfAcceptedCredentials, credentials); - ac.view().insert(sle); - }; - testcase << "PermissionedDomain Set 1"; doInvariantCheck( {{"permissioned domain with no rules."}}, - [createPD](Account const& A1, Account const& A2, ApplyContext& ac) { + [&](Account const& A1, Account const& A2, ApplyContext& ac) { Keylet const pdKeylet = keylet::permissionedDomain(A1.id(), 10); auto slePd = std::make_shared(pdKeylet); // create PD - createPD(ac, slePd, A1, A2); + createPermissionedDomain(ac, slePd, A1, A2); // update PD with empty rules { @@ -1131,12 +1134,12 @@ class Invariants_test : public beast::unit_test::suite doInvariantCheck( {{"permissioned domain bad credentials size " + std::to_string(tooBig)}}, - [createPD](Account const& A1, Account const& A2, ApplyContext& ac) { + [&](Account const& A1, Account const& A2, ApplyContext& ac) { Keylet const pdKeylet = keylet::permissionedDomain(A1.id(), 10); auto slePd = std::make_shared(pdKeylet); // create PD - createPD(ac, slePd, A1, A2); + createPermissionedDomain(ac, slePd, A1, A2); // update PD { @@ -1166,12 +1169,12 @@ class Invariants_test : public beast::unit_test::suite testcase << "PermissionedDomain Set 3"; doInvariantCheck( {{"permissioned domain credentials aren't sorted"}}, - [createPD](Account const& A1, Account const& A2, ApplyContext& ac) { + [&](Account const& A1, Account const& A2, ApplyContext& ac) { Keylet const pdKeylet = keylet::permissionedDomain(A1.id(), 10); auto slePd = std::make_shared(pdKeylet); // create PD - createPD(ac, slePd, A1, A2); + createPermissionedDomain(ac, slePd, A1, A2); // update PD { @@ -1201,12 +1204,12 @@ class Invariants_test : public beast::unit_test::suite testcase << "PermissionedDomain Set 4"; doInvariantCheck( {{"permissioned domain credentials aren't unique"}}, - [createPD](Account const& A1, Account const& A2, ApplyContext& ac) { + [&](Account const& A1, Account const& A2, ApplyContext& ac) { Keylet const pdKeylet = keylet::permissionedDomain(A1.id(), 10); auto slePd = std::make_shared(pdKeylet); // create PD - createPD(ac, slePd, A1, A2); + createPermissionedDomain(ac, slePd, A1, A2); // update PD { @@ -1230,6 +1233,175 @@ class Invariants_test : public beast::unit_test::suite {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); } + void + testPermissionedDEX() + { + using namespace test::jtx; + testcase << "PermissionedDEX"; + + doInvariantCheck( + {{"domain doesn't exist"}}, + [](Account const& A1, Account const&, ApplyContext& ac) { + Keylet const offerKey = keylet::offer(A1.id(), 10); + auto sleOffer = std::make_shared(offerKey); + sleOffer->setAccountID(sfAccount, A1); + sleOffer->setFieldAmount(sfTakerPays, A1["USD"](10)); + sleOffer->setFieldAmount(sfTakerGets, XRP(1)); + ac.view().insert(sleOffer); + return true; + }, + XRPAmount{}, + STTx{ + ttOFFER_CREATE, + [](STObject& tx) { + tx.setFieldH256( + sfDomainID, + uint256{ + "F10D0CC9A0F9A3CBF585B80BE09A186483668FDBDD39AA7E33" + "70F3649CE134E5"}); + Account const A1{"A1"}; + tx.setFieldAmount(sfTakerPays, A1["USD"](10)); + tx.setFieldAmount(sfTakerGets, XRP(1)); + }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); + + // missing domain ID in offer object + doInvariantCheck( + {{"hybrid offer is malformed"}}, + [&](Account const& A1, Account const& A2, ApplyContext& ac) { + Keylet const pdKeylet = keylet::permissionedDomain(A1.id(), 10); + auto slePd = std::make_shared(pdKeylet); + createPermissionedDomain(ac, slePd, A1, A2); + + Keylet const offerKey = keylet::offer(A2.id(), 10); + auto sleOffer = std::make_shared(offerKey); + sleOffer->setAccountID(sfAccount, A2); + sleOffer->setFieldAmount(sfTakerPays, A1["USD"](10)); + sleOffer->setFieldAmount(sfTakerGets, XRP(1)); + sleOffer->setFlag(lsfHybrid); + + STArray bookArr; + bookArr.push_back(STObject::makeInnerObject(sfBook)); + sleOffer->setFieldArray(sfAdditionalBooks, bookArr); + ac.view().insert(sleOffer); + return true; + }, + XRPAmount{}, + STTx{ttOFFER_CREATE, [&](STObject& tx) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); + + // more than one entry in sfAdditionalBooks + doInvariantCheck( + {{"hybrid offer is malformed"}}, + [&](Account const& A1, Account const& A2, ApplyContext& ac) { + Keylet const pdKeylet = keylet::permissionedDomain(A1.id(), 10); + auto slePd = std::make_shared(pdKeylet); + createPermissionedDomain(ac, slePd, A1, A2); + + Keylet const offerKey = keylet::offer(A2.id(), 10); + auto sleOffer = std::make_shared(offerKey); + sleOffer->setAccountID(sfAccount, A2); + sleOffer->setFieldAmount(sfTakerPays, A1["USD"](10)); + sleOffer->setFieldAmount(sfTakerGets, XRP(1)); + sleOffer->setFlag(lsfHybrid); + sleOffer->setFieldH256(sfDomainID, pdKeylet.key); + + STArray bookArr; + bookArr.push_back(STObject::makeInnerObject(sfBook)); + bookArr.push_back(STObject::makeInnerObject(sfBook)); + sleOffer->setFieldArray(sfAdditionalBooks, bookArr); + ac.view().insert(sleOffer); + return true; + }, + XRPAmount{}, + STTx{ttOFFER_CREATE, [&](STObject& tx) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); + + // hybrid offer missing sfAdditionalBooks + doInvariantCheck( + {{"hybrid offer is malformed"}}, + [&](Account const& A1, Account const& A2, ApplyContext& ac) { + Keylet const pdKeylet = keylet::permissionedDomain(A1.id(), 10); + auto slePd = std::make_shared(pdKeylet); + createPermissionedDomain(ac, slePd, A1, A2); + + Keylet const offerKey = keylet::offer(A2.id(), 10); + auto sleOffer = std::make_shared(offerKey); + sleOffer->setAccountID(sfAccount, A2); + sleOffer->setFieldAmount(sfTakerPays, A1["USD"](10)); + sleOffer->setFieldAmount(sfTakerGets, XRP(1)); + sleOffer->setFlag(lsfHybrid); + sleOffer->setFieldH256(sfDomainID, pdKeylet.key); + ac.view().insert(sleOffer); + return true; + }, + XRPAmount{}, + STTx{ttOFFER_CREATE, [&](STObject& tx) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); + + doInvariantCheck( + {{"transaction consumed wrong domains"}}, + [&](Account const& A1, Account const& A2, ApplyContext& ac) { + Keylet const pdKeylet = keylet::permissionedDomain(A1.id(), 10); + auto slePd = std::make_shared(pdKeylet); + createPermissionedDomain(ac, slePd, A1, A2); + + Keylet const badDomainKeylet = + keylet::permissionedDomain(A1.id(), 20); + auto sleBadPd = std::make_shared(badDomainKeylet); + createPermissionedDomain(ac, sleBadPd, A1, A2); + + Keylet const offerKey = keylet::offer(A2.id(), 10); + auto sleOffer = std::make_shared(offerKey); + sleOffer->setAccountID(sfAccount, A2); + sleOffer->setFieldAmount(sfTakerPays, A1["USD"](10)); + sleOffer->setFieldAmount(sfTakerGets, XRP(1)); + sleOffer->setFieldH256(sfDomainID, pdKeylet.key); + ac.view().insert(sleOffer); + return true; + }, + XRPAmount{}, + STTx{ + ttOFFER_CREATE, + [&](STObject& tx) { + Account const A1{"A1"}; + Keylet const badDomainKey = + keylet::permissionedDomain(A1.id(), 20); + tx.setFieldH256(sfDomainID, badDomainKey.key); + tx.setFieldAmount(sfTakerPays, A1["USD"](10)); + tx.setFieldAmount(sfTakerGets, XRP(1)); + }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); + + doInvariantCheck( + {{"domain transaction affected regular offers"}}, + [&](Account const& A1, Account const& A2, ApplyContext& ac) { + Keylet const pdKeylet = keylet::permissionedDomain(A1.id(), 10); + auto slePd = std::make_shared(pdKeylet); + createPermissionedDomain(ac, slePd, A1, A2); + + Keylet const offerKey = keylet::offer(A2.id(), 10); + auto sleOffer = std::make_shared(offerKey); + sleOffer->setAccountID(sfAccount, A2); + sleOffer->setFieldAmount(sfTakerPays, A1["USD"](10)); + sleOffer->setFieldAmount(sfTakerGets, XRP(1)); + ac.view().insert(sleOffer); + return true; + }, + XRPAmount{}, + STTx{ + ttOFFER_CREATE, + [&](STObject& tx) { + Account const A1{"A1"}; + Keylet const domainKey = + keylet::permissionedDomain(A1.id(), 10); + tx.setFieldH256(sfDomainID, domainKey.key); + tx.setFieldAmount(sfTakerPays, A1["USD"](10)); + tx.setFieldAmount(sfTakerGets, XRP(1)); + }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); + } + public: void run() override @@ -1248,6 +1420,7 @@ class Invariants_test : public beast::unit_test::suite testValidNewAccountRoot(); testNFTokenPageInvariants(); testPermissionedDomainInvariants(); + testPermissionedDEX(); } }; diff --git a/src/test/ledger/PaymentSandbox_test.cpp b/src/test/ledger/PaymentSandbox_test.cpp index 303e700f404..8bb0666e062 100644 --- a/src/test/ledger/PaymentSandbox_test.cpp +++ b/src/test/ledger/PaymentSandbox_test.cpp @@ -421,7 +421,8 @@ class PaymentSandbox_test : public beast::unit_test::suite }; using namespace jtx; auto const sa = supported_amendments(); - testAll(sa - featureFlowCross); + testAll(sa - featureFlowCross - featurePermissionedDEX); + testAll(sa - featurePermissionedDEX); testAll(sa); } }; diff --git a/src/test/protocol/Issue_test.cpp b/src/test/protocol/Issue_test.cpp index 53ebf5be245..d5b6d264054 100644 --- a/src/test/protocol/Issue_test.cpp +++ b/src/test/protocol/Issue_test.cpp @@ -22,7 +22,10 @@ #include #include +#include + #include +#include #include #include #include @@ -46,6 +49,8 @@ namespace ripple { class Issue_test : public beast::unit_test::suite { public: + using Domain = uint256; + // Comparison, hash tests for uint60 (via base_uint) template void @@ -239,6 +244,120 @@ class Issue_test : public beast::unit_test::suite } } + template + void + testIssueDomainSet() + { + Currency const c1(1); + AccountID const i1(1); + Currency const c2(2); + AccountID const i2(2); + Issue const a1(c1, i1); + Issue const a2(c2, i2); + uint256 const domain1{1}; + uint256 const domain2{2}; + + Set c; + + c.insert(std::make_pair(a1, domain1)); + if (!BEAST_EXPECT(c.size() == 1)) + return; + c.insert(std::make_pair(a2, domain1)); + if (!BEAST_EXPECT(c.size() == 2)) + return; + c.insert(std::make_pair(a2, domain2)); + if (!BEAST_EXPECT(c.size() == 3)) + return; + + if (!BEAST_EXPECT(c.erase(std::make_pair(Issue(c1, i2), domain1)) == 0)) + return; + if (!BEAST_EXPECT(c.erase(std::make_pair(a1, domain1)) == 1)) + return; + if (!BEAST_EXPECT(c.erase(std::make_pair(a2, domain1)) == 1)) + return; + if (!BEAST_EXPECT(c.erase(std::make_pair(a2, domain2)) == 1)) + return; + if (!BEAST_EXPECT(c.empty())) + return; + } + + template + void + testIssueDomainMap() + { + Currency const c1(1); + AccountID const i1(1); + Currency const c2(2); + AccountID const i2(2); + Issue const a1(c1, i1); + Issue const a2(c2, i2); + uint256 const domain1{1}; + uint256 const domain2{2}; + + Map c; + + c.insert(std::make_pair(std::make_pair(a1, domain1), 1)); + if (!BEAST_EXPECT(c.size() == 1)) + return; + c.insert(std::make_pair(std::make_pair(a2, domain1), 2)); + if (!BEAST_EXPECT(c.size() == 2)) + return; + c.insert(std::make_pair(std::make_pair(a2, domain2), 2)); + if (!BEAST_EXPECT(c.size() == 3)) + return; + + if (!BEAST_EXPECT(c.erase(std::make_pair(Issue(c1, i2), domain1)) == 0)) + return; + if (!BEAST_EXPECT(c.erase(std::make_pair(a1, domain1)) == 1)) + return; + if (!BEAST_EXPECT(c.erase(std::make_pair(a2, domain1)) == 1)) + return; + if (!BEAST_EXPECT(c.erase(std::make_pair(a2, domain2)) == 1)) + return; + if (!BEAST_EXPECT(c.empty())) + return; + } + + void + testIssueDomainSets() + { + testcase("std::set >"); + testIssueDomainSet>>(); + + testcase("std::set >"); + testIssueDomainSet>>(); + + testcase("hash_set >"); + testIssueDomainSet>>(); + + testcase("hash_set >"); + testIssueDomainSet>>(); + } + + void + testIssueDomainMaps() + { + testcase("std::map , int>"); + testIssueDomainMap, int>>(); + + testcase("std::map , int>"); + testIssueDomainMap, int>>(); + +#if RIPPLE_ASSETS_ENABLE_STD_HASH + testcase("hash_map , int>"); + testIssueDomainMap, int>>(); + + testcase("hash_map , int>"); + testIssueDomainMap, int>>(); + + testcase("hardened_hash_map , int>"); + testIssueDomainMap, int>>(); + + testcase("hardened_hash_map , int>"); + testIssueDomainMap, int>>(); +#endif + } + void testIssueSets() { @@ -306,7 +425,10 @@ class Issue_test : public beast::unit_test::suite Issue a2(c1, i2); Issue a3(c2, i2); Issue a4(c3, i2); + uint256 const domain1{1}; + uint256 const domain2{2}; + // Books without domains BEAST_EXPECT(Book(a1, a2) != Book(a2, a3)); BEAST_EXPECT(Book(a1, a2) < Book(a2, a3)); BEAST_EXPECT(Book(a1, a2) <= Book(a2, a3)); @@ -316,6 +438,75 @@ class Issue_test : public beast::unit_test::suite BEAST_EXPECT(Book(a3, a4) >= Book(a2, a3)); BEAST_EXPECT(Book(a3, a4) > Book(a2, a3)); + // test domain books + { + // Books with different domains + BEAST_EXPECT(Book(a2, a3, domain1) != Book(a2, a3, domain2)); + BEAST_EXPECT(Book(a2, a3, domain1) < Book(a2, a3, domain2)); + BEAST_EXPECT(Book(a2, a3, domain2) > Book(a2, a3, domain1)); + + // One Book has a domain, the other does not + BEAST_EXPECT(Book(a2, a3, domain1) != Book(a2, a3)); + BEAST_EXPECT(Book(a2, a3) < Book(a2, a3, domain1)); + BEAST_EXPECT(Book(a2, a3, domain1) > Book(a2, a3)); + + // Both Books have the same domain + BEAST_EXPECT(Book(a2, a3, domain1) == Book(a2, a3, domain1)); + BEAST_EXPECT(Book(a2, a3, domain2) == Book(a2, a3, domain2)); + BEAST_EXPECT(Book(a2, a3) == Book(a2, a3, std::nullopt)); + + // Both Books have no domain + BEAST_EXPECT( + Book(a2, a3, std::nullopt) == Book(a2, a3, std::nullopt)); + + // Testing comparisons with >= and <= + + // When comparing books with domain1 vs domain2 + BEAST_EXPECT(Book(a2, a3, domain1) <= Book(a2, a3, domain2)); + BEAST_EXPECT(Book(a2, a3, domain2) >= Book(a2, a3, domain1)); + BEAST_EXPECT(Book(a2, a3, domain1) >= Book(a2, a3, domain1)); + BEAST_EXPECT(Book(a2, a3, domain2) <= Book(a2, a3, domain2)); + + // One Book has domain1 and the other has no domain + BEAST_EXPECT(Book(a2, a3, domain1) > Book(a2, a3)); + BEAST_EXPECT(Book(a2, a3) < Book(a2, a3, domain1)); + + // One Book has domain2 and the other has no domain + BEAST_EXPECT(Book(a2, a3, domain2) > Book(a2, a3)); + BEAST_EXPECT(Book(a2, a3) < Book(a2, a3, domain2)); + + // Comparing two Books with no domains + BEAST_EXPECT( + Book(a2, a3, std::nullopt) <= Book(a2, a3, std::nullopt)); + BEAST_EXPECT( + Book(a2, a3, std::nullopt) >= Book(a2, a3, std::nullopt)); + + // Test case where domain1 is less than domain2 + BEAST_EXPECT(Book(a2, a3, domain1) <= Book(a2, a3, domain2)); + BEAST_EXPECT(Book(a2, a3, domain2) >= Book(a2, a3, domain1)); + + // Test case where domain2 is equal to domain1 + BEAST_EXPECT(Book(a2, a3, domain1) >= Book(a2, a3, domain1)); + BEAST_EXPECT(Book(a2, a3, domain1) <= Book(a2, a3, domain1)); + + // More test cases involving a4 (with domain2) + + // Comparing Book with domain2 (a4) to a Book with domain1 + BEAST_EXPECT(Book(a2, a3, domain1) < Book(a3, a4, domain2)); + BEAST_EXPECT(Book(a3, a4, domain2) > Book(a2, a3, domain1)); + + // Comparing Book with domain2 (a4) to a Book with no domain + BEAST_EXPECT(Book(a3, a4, domain2) > Book(a2, a3)); + BEAST_EXPECT(Book(a2, a3) < Book(a3, a4, domain2)); + + // Comparing Book with domain2 (a4) to a Book with the same domain + BEAST_EXPECT(Book(a3, a4, domain2) == Book(a3, a4, domain2)); + + // Comparing Book with domain2 (a4) to a Book with domain1 + BEAST_EXPECT(Book(a2, a3, domain1) < Book(a3, a4, domain2)); + BEAST_EXPECT(Book(a3, a4, domain2) > Book(a2, a3, domain1)); + } + std::hash hash; // log << std::hex << hash (Book (a1, a2)); @@ -348,6 +539,57 @@ class Issue_test : public beast::unit_test::suite BEAST_EXPECT(hash(Book(a1, a2)) != hash(Book(a2, a3))); BEAST_EXPECT(hash(Book(a1, a2)) != hash(Book(a2, a4))); BEAST_EXPECT(hash(Book(a1, a2)) != hash(Book(a3, a4))); + + // Books with domain + BEAST_EXPECT( + hash(Book(a1, a2, domain1)) == hash(Book(a1, a2, domain1))); + BEAST_EXPECT( + hash(Book(a1, a3, domain1)) == hash(Book(a1, a3, domain1))); + BEAST_EXPECT( + hash(Book(a1, a4, domain1)) == hash(Book(a1, a4, domain1))); + BEAST_EXPECT( + hash(Book(a2, a3, domain1)) == hash(Book(a2, a3, domain1))); + BEAST_EXPECT( + hash(Book(a2, a4, domain1)) == hash(Book(a2, a4, domain1))); + BEAST_EXPECT( + hash(Book(a3, a4, domain1)) == hash(Book(a3, a4, domain1))); + BEAST_EXPECT(hash(Book(a1, a2)) == hash(Book(a1, a2, std::nullopt))); + + // Comparing Books with domain1 vs no domain + BEAST_EXPECT(hash(Book(a1, a2)) != hash(Book(a1, a2, domain1))); + BEAST_EXPECT(hash(Book(a1, a3)) != hash(Book(a1, a3, domain1))); + BEAST_EXPECT(hash(Book(a1, a4)) != hash(Book(a1, a4, domain1))); + BEAST_EXPECT(hash(Book(a2, a3)) != hash(Book(a2, a3, domain1))); + BEAST_EXPECT(hash(Book(a2, a4)) != hash(Book(a2, a4, domain1))); + BEAST_EXPECT(hash(Book(a3, a4)) != hash(Book(a3, a4, domain1))); + + // Books with domain1 but different Issues + BEAST_EXPECT( + hash(Book(a1, a2, domain1)) != hash(Book(a1, a3, domain1))); + BEAST_EXPECT( + hash(Book(a1, a2, domain1)) != hash(Book(a1, a4, domain1))); + BEAST_EXPECT( + hash(Book(a2, a3, domain1)) != hash(Book(a2, a4, domain1))); + BEAST_EXPECT( + hash(Book(a1, a2, domain1)) != hash(Book(a2, a3, domain1))); + BEAST_EXPECT( + hash(Book(a2, a4, domain1)) != hash(Book(a3, a4, domain1))); + BEAST_EXPECT( + hash(Book(a3, a4, domain1)) != hash(Book(a1, a4, domain1))); + + // Books with domain1 and domain2 + BEAST_EXPECT( + hash(Book(a1, a2, domain1)) != hash(Book(a1, a2, domain2))); + BEAST_EXPECT( + hash(Book(a1, a3, domain1)) != hash(Book(a1, a3, domain2))); + BEAST_EXPECT( + hash(Book(a1, a4, domain1)) != hash(Book(a1, a4, domain2))); + BEAST_EXPECT( + hash(Book(a2, a3, domain1)) != hash(Book(a2, a3, domain2))); + BEAST_EXPECT( + hash(Book(a2, a4, domain1)) != hash(Book(a2, a4, domain2))); + BEAST_EXPECT( + hash(Book(a3, a4, domain1)) != hash(Book(a3, a4, domain2))); } //-------------------------------------------------------------------------- @@ -365,6 +607,14 @@ class Issue_test : public beast::unit_test::suite Book const b1(a1, a2); Book const b2(a2, a1); + uint256 const domain1{1}; + uint256 const domain2{2}; + + Book const b1_d1(a1, a2, domain1); + Book const b2_d1(a2, a1, domain1); + Book const b1_d2(a1, a2, domain2); + Book const b2_d2(a2, a1, domain2); + { Set c; @@ -413,6 +663,66 @@ class Issue_test : public beast::unit_test::suite return; #endif } + + { + Set c; + + c.insert(b1_d1); + if (!BEAST_EXPECT(c.size() == 1)) + return; + c.insert(b2_d1); + if (!BEAST_EXPECT(c.size() == 2)) + return; + c.insert(b1_d2); + if (!BEAST_EXPECT(c.size() == 3)) + return; + c.insert(b2_d2); + if (!BEAST_EXPECT(c.size() == 4)) + return; + + // Try removing non-existent elements + if (!BEAST_EXPECT(c.erase(Book(a2, a2, domain1)) == 0)) + return; + + if (!BEAST_EXPECT(c.erase(Book(a1, a2, domain1)) == 1)) + return; + if (!BEAST_EXPECT(c.erase(Book(a2, a1, domain1)) == 1)) + return; + if (!BEAST_EXPECT(c.size() == 2)) + return; + + if (!BEAST_EXPECT(c.erase(Book(a1, a2, domain2)) == 1)) + return; + if (!BEAST_EXPECT(c.erase(Book(a2, a1, domain2)) == 1)) + return; + if (!BEAST_EXPECT(c.empty())) + return; + } + + { + Set c; + + c.insert(b1); + c.insert(b2); + c.insert(b1_d1); + c.insert(b2_d1); + if (!BEAST_EXPECT(c.size() == 4)) + return; + + if (!BEAST_EXPECT(c.erase(Book(a1, a2)) == 1)) + return; + if (!BEAST_EXPECT(c.erase(Book(a2, a1)) == 1)) + return; + if (!BEAST_EXPECT(c.size() == 2)) + return; + + if (!BEAST_EXPECT(c.erase(Book(a1, a2, domain1)) == 1)) + return; + if (!BEAST_EXPECT(c.erase(Book(a2, a1, domain1)) == 1)) + return; + if (!BEAST_EXPECT(c.empty())) + return; + } } template @@ -428,6 +738,14 @@ class Issue_test : public beast::unit_test::suite Book const b1(a1, a2); Book const b2(a2, a1); + uint256 const domain1{1}; + uint256 const domain2{2}; + + Book const b1_d1(a1, a2, domain1); + Book const b2_d1(a2, a1, domain1); + Book const b1_d2(a1, a2, domain2); + Book const b2_d2(a2, a1, domain2); + // typename Map::value_type value_type; // std::pair value_type; @@ -474,6 +792,72 @@ class Issue_test : public beast::unit_test::suite if (!BEAST_EXPECT(c.empty())) return; } + + { + Map c; + + c.insert(std::make_pair(b1_d1, 10)); + if (!BEAST_EXPECT(c.size() == 1)) + return; + c.insert(std::make_pair(b2_d1, 20)); + if (!BEAST_EXPECT(c.size() == 2)) + return; + c.insert(std::make_pair(b1_d2, 30)); + if (!BEAST_EXPECT(c.size() == 3)) + return; + c.insert(std::make_pair(b2_d2, 40)); + if (!BEAST_EXPECT(c.size() == 4)) + return; + + // Try removing non-existent elements + if (!BEAST_EXPECT(c.erase(Book(a2, a2, domain1)) == 0)) + return; + + if (!BEAST_EXPECT(c.erase(Book(a1, a2, domain1)) == 1)) + return; + if (!BEAST_EXPECT(c.erase(Book(a2, a1, domain1)) == 1)) + return; + if (!BEAST_EXPECT(c.size() == 2)) + return; + + if (!BEAST_EXPECT(c.erase(Book(a1, a2, domain2)) == 1)) + return; + if (!BEAST_EXPECT(c.erase(Book(a2, a1, domain2)) == 1)) + return; + if (!BEAST_EXPECT(c.empty())) + return; + } + + { + Map c; + + c.insert(std::make_pair(b1, 1)); + c.insert(std::make_pair(b2, 2)); + c.insert(std::make_pair(b1_d1, 3)); + c.insert(std::make_pair(b2_d1, 4)); + if (!BEAST_EXPECT(c.size() == 4)) + return; + + // Try removing non-existent elements + if (!BEAST_EXPECT(c.erase(Book(a1, a1, domain1)) == 0)) + return; + if (!BEAST_EXPECT(c.erase(Book(a2, a2, domain2)) == 0)) + return; + + if (!BEAST_EXPECT(c.erase(Book(a1, a2)) == 1)) + return; + if (!BEAST_EXPECT(c.erase(Book(a2, a1)) == 1)) + return; + if (!BEAST_EXPECT(c.size() == 2)) + return; + + if (!BEAST_EXPECT(c.erase(Book(a1, a2, domain1)) == 1)) + return; + if (!BEAST_EXPECT(c.erase(Book(a2, a1, domain1)) == 1)) + return; + if (!BEAST_EXPECT(c.empty())) + return; + } } void @@ -556,6 +940,10 @@ class Issue_test : public beast::unit_test::suite testBookSets(); testBookMaps(); + + // --- + testIssueDomainSets(); + testIssueDomainMaps(); } }; diff --git a/src/test/rpc/BookChanges_test.cpp b/src/test/rpc/BookChanges_test.cpp index 95997538d79..1f059c2bf73 100644 --- a/src/test/rpc/BookChanges_test.cpp +++ b/src/test/rpc/BookChanges_test.cpp @@ -18,6 +18,10 @@ //============================================================================== #include +#include + +#include "xrpl/beast/unit_test/suite.h" +#include "xrpl/protocol/jss.h" namespace ripple { namespace test { @@ -83,14 +87,59 @@ class BookChanges_test : public beast::unit_test::suite // == 3); } + void + testDomainOffer() + { + testcase("Domain Offer"); + using namespace jtx; + + FeatureBitset const all{ + jtx::supported_amendments() | featurePermissionedDomains | + featureCredentials | featurePermissionedDEX}; + + Env env(*this, all); + PermissionedDEX permDex(env); + auto const& [gw, domainOwner, alice, bob, carol, USD, domainID, credType] = + permDex; + + auto wsc = makeWSClient(env.app().config()); + + env(offer(alice, XRP(10), USD(10)), domain(domainID)); + env.close(); + + env(pay(bob, carol, USD(10)), + path(~USD), + sendmax(XRP(10)), + domain(domainID)); + env.close(); + + std::string const txHash{ + env.tx()->getJson(JsonOptions::none)[jss::hash].asString()}; + + Json::Value const txResult = env.rpc("tx", txHash)[jss::result]; + auto const ledgerIndex = txResult[jss::ledger_index].asInt(); + + Json::Value jvParams; + jvParams[jss::ledger_index] = ledgerIndex; + + auto jv = wsc->invoke("book_changes", jvParams); + auto jrr = jv[jss::result]; + + BEAST_EXPECT(jrr[jss::changes].size() == 1); + BEAST_EXPECT( + jrr[jss::changes][0u][jss::domain].asString() == + to_string(domainID)); + } + void run() override { testConventionalLedgerInputStrings(); testLedgerInputDefaultBehavior(); - // Note: Other aspects of the book_changes rpc are fertile grounds for - // unit-testing purposes. It can be included in future work + testDomainOffer(); + // Note: Other aspects of the book_changes rpc are fertile grounds + // for unit-testing purposes. It can be included in future work } }; diff --git a/src/test/rpc/Book_test.cpp b/src/test/rpc/Book_test.cpp index 79e3f940f82..4c943145559 100644 --- a/src/test/rpc/Book_test.cpp +++ b/src/test/rpc/Book_test.cpp @@ -22,6 +22,8 @@ #include #include +#include +#include #include namespace ripple { @@ -30,10 +32,14 @@ namespace test { class Book_test : public beast::unit_test::suite { std::string - getBookDir(jtx::Env& env, Issue const& in, Issue const& out) + getBookDir( + jtx::Env& env, + Issue const& in, + Issue const& out, + std::optional const& domain = std::nullopt) { std::string dir; - auto uBookBase = getBookBase({in, out}); + auto uBookBase = getBookBase({in, out, domain}); auto uBookEnd = getQualityNext(uBookBase); auto view = env.closed(); auto key = view->succ(uBookBase, uBookEnd); @@ -1657,6 +1663,19 @@ class Book_test : public beast::unit_test::suite "Unneeded field 'taker_gets.issuer' " "for XRP currency specification."); } + { + Json::Value jvParams; + jvParams[jss::ledger_index] = "validated"; + jvParams[jss::taker_pays][jss::currency] = "USD"; + jvParams[jss::taker_pays][jss::issuer] = gw.human(); + jvParams[jss::taker_gets][jss::currency] = "EUR"; + jvParams[jss::taker_gets][jss::issuer] = gw.human(); + jvParams[jss::domain] = "badString"; + auto const jrr = env.rpc( + "json", "book_offers", to_string(jvParams))[jss::result]; + BEAST_EXPECT(jrr[jss::error] == "domainMalformed"); + BEAST_EXPECT(jrr[jss::error_message] == "Unable to parse domain."); + } } void @@ -1711,6 +1730,268 @@ class Book_test : public beast::unit_test::suite (asAdmin ? RPC::Tuning::bookOffers.rdefault : 0u)); } + void + testTrackDomainOffer() + { + testcase("TrackDomainOffer"); + using namespace jtx; + + FeatureBitset const all{ + jtx::supported_amendments() | featurePermissionedDomains | + featureCredentials | featurePermissionedDEX}; + + Env env(*this, all); + PermissionedDEX permDex(env); + auto const alice = permDex.alice; + auto const bob = permDex.bob; + auto const carol = permDex.carol; + auto const domainID = permDex.domainID; + auto const gw = permDex.gw; + auto const USD = permDex.USD; + + auto wsc = makeWSClient(env.app().config()); + + env(offer(alice, XRP(10), USD(10)), domain(domainID)); + env.close(); + + auto checkBookOffers = [&](Json::Value const& jrr) { + BEAST_EXPECT(jrr[jss::offers].isArray()); + BEAST_EXPECT(jrr[jss::offers].size() == 1); + auto const jrOffer = jrr[jss::offers][0u]; + BEAST_EXPECT(jrOffer[sfAccount.fieldName] == alice.human()); + BEAST_EXPECT( + jrOffer[sfBookDirectory.fieldName] == + getBookDir(env, XRP, USD.issue(), domainID)); + BEAST_EXPECT(jrOffer[sfBookNode.fieldName] == "0"); + BEAST_EXPECT(jrOffer[jss::Flags] == 0); + BEAST_EXPECT(jrOffer[sfLedgerEntryType.fieldName] == jss::Offer); + BEAST_EXPECT(jrOffer[sfOwnerNode.fieldName] == "0"); + BEAST_EXPECT( + jrOffer[jss::TakerGets] == + USD(10).value().getJson(JsonOptions::none)); + BEAST_EXPECT( + jrOffer[jss::TakerPays] == + XRP(10).value().getJson(JsonOptions::none)); + BEAST_EXPECT( + jrOffer[sfDomainID.jsonName].asString() == to_string(domainID)); + }; + + // book_offers: open book doesn't return offer + { + Json::Value jvParams; + jvParams[jss::taker] = env.master.human(); + jvParams[jss::taker_pays][jss::currency] = "XRP"; + jvParams[jss::ledger_index] = "validated"; + jvParams[jss::taker_gets][jss::currency] = "USD"; + jvParams[jss::taker_gets][jss::issuer] = gw.human(); + + auto jv = wsc->invoke("book_offers", jvParams); + auto jrr = jv[jss::result]; + BEAST_EXPECT(jrr[jss::offers].isArray()); + BEAST_EXPECT(jrr[jss::offers].size() == 0); + } + + auto checkSubBooks = [&](Json::Value const& jv) { + BEAST_EXPECT( + jv[jss::result].isMember(jss::offers) && + jv[jss::result][jss::offers].size() == 1); + BEAST_EXPECT( + jv[jss::result][jss::offers][0u][jss::TakerGets] == + USD(10).value().getJson(JsonOptions::none)); + BEAST_EXPECT( + jv[jss::result][jss::offers][0u][jss::TakerPays] == + XRP(10).value().getJson(JsonOptions::none)); + BEAST_EXPECT( + jv[jss::result][jss::offers][0u][sfDomainID.jsonName] + .asString() == to_string(domainID)); + }; + + // book_offers: requesting domain book returns hybrid offer + { + Json::Value jvParams; + jvParams[jss::taker] = env.master.human(); + jvParams[jss::taker_pays][jss::currency] = "XRP"; + jvParams[jss::ledger_index] = "validated"; + jvParams[jss::taker_gets][jss::currency] = "USD"; + jvParams[jss::taker_gets][jss::issuer] = gw.human(); + jvParams[jss::domain] = to_string(domainID); + + auto jv = wsc->invoke("book_offers", jvParams); + auto jrr = jv[jss::result]; + checkBookOffers(jrr); + } + + // subscribe to domain book should return domain offer + { + Json::Value books; + books[jss::books] = Json::arrayValue; + { + auto& j = books[jss::books].append(Json::objectValue); + j[jss::snapshot] = true; + j[jss::taker_pays][jss::currency] = "XRP"; + j[jss::taker_gets][jss::currency] = "USD"; + j[jss::taker_gets][jss::issuer] = gw.human(); + j[jss::domain] = to_string(domainID); + } + + auto jv = wsc->invoke("subscribe", books); + if (!BEAST_EXPECT(jv[jss::status] == "success")) + return; + checkSubBooks(jv); + } + + // subscribe to open book should not return domain offer + { + Json::Value books; + books[jss::books] = Json::arrayValue; + { + auto& j = books[jss::books].append(Json::objectValue); + j[jss::snapshot] = true; + j[jss::taker_pays][jss::currency] = "XRP"; + j[jss::taker_gets][jss::currency] = "USD"; + j[jss::taker_gets][jss::issuer] = gw.human(); + } + + auto jv = wsc->invoke("subscribe", books); + if (!BEAST_EXPECT(jv[jss::status] == "success")) + return; + BEAST_EXPECT( + jv[jss::result].isMember(jss::offers) && + jv[jss::result][jss::offers].size() == 0); + } + } + + void + testTrackHybridOffer() + { + testcase("TrackHybridOffer"); + using namespace jtx; + + FeatureBitset const all{ + jtx::supported_amendments() | featurePermissionedDomains | + featureCredentials | featurePermissionedDEX}; + + Env env(*this, all); + PermissionedDEX permDex(env); + auto const alice = permDex.alice; + auto const bob = permDex.bob; + auto const carol = permDex.carol; + auto const domainID = permDex.domainID; + auto const gw = permDex.gw; + auto const USD = permDex.USD; + + auto wsc = makeWSClient(env.app().config()); + + env(offer(alice, XRP(10), USD(10)), + domain(domainID), + txflags(tfHybrid)); + env.close(); + + auto checkBookOffers = [&](Json::Value const& jrr) { + BEAST_EXPECT(jrr[jss::offers].isArray()); + BEAST_EXPECT(jrr[jss::offers].size() == 1); + auto const jrOffer = jrr[jss::offers][0u]; + BEAST_EXPECT(jrOffer[sfAccount.fieldName] == alice.human()); + BEAST_EXPECT( + jrOffer[sfBookDirectory.fieldName] == + getBookDir(env, XRP, USD.issue(), domainID)); + BEAST_EXPECT(jrOffer[sfBookNode.fieldName] == "0"); + BEAST_EXPECT(jrOffer[jss::Flags] == lsfHybrid); + BEAST_EXPECT(jrOffer[sfLedgerEntryType.fieldName] == jss::Offer); + BEAST_EXPECT(jrOffer[sfOwnerNode.fieldName] == "0"); + BEAST_EXPECT( + jrOffer[jss::TakerGets] == + USD(10).value().getJson(JsonOptions::none)); + BEAST_EXPECT( + jrOffer[jss::TakerPays] == + XRP(10).value().getJson(JsonOptions::none)); + BEAST_EXPECT( + jrOffer[sfDomainID.jsonName].asString() == to_string(domainID)); + BEAST_EXPECT(jrOffer[sfAdditionalBooks.jsonName].size() == 1); + }; + + // book_offers: open book returns hybrid offer + { + Json::Value jvParams; + jvParams[jss::taker] = env.master.human(); + jvParams[jss::taker_pays][jss::currency] = "XRP"; + jvParams[jss::ledger_index] = "validated"; + jvParams[jss::taker_gets][jss::currency] = "USD"; + jvParams[jss::taker_gets][jss::issuer] = gw.human(); + + auto jv = wsc->invoke("book_offers", jvParams); + auto jrr = jv[jss::result]; + checkBookOffers(jrr); + } + + auto checkSubBooks = [&](Json::Value const& jv) { + BEAST_EXPECT( + jv[jss::result].isMember(jss::offers) && + jv[jss::result][jss::offers].size() == 1); + BEAST_EXPECT( + jv[jss::result][jss::offers][0u][jss::TakerGets] == + USD(10).value().getJson(JsonOptions::none)); + BEAST_EXPECT( + jv[jss::result][jss::offers][0u][jss::TakerPays] == + XRP(10).value().getJson(JsonOptions::none)); + BEAST_EXPECT( + jv[jss::result][jss::offers][0u][sfDomainID.jsonName] + .asString() == to_string(domainID)); + }; + + // book_offers: requesting domain book returns hybrid offer + { + Json::Value jvParams; + jvParams[jss::taker] = env.master.human(); + jvParams[jss::taker_pays][jss::currency] = "XRP"; + jvParams[jss::ledger_index] = "validated"; + jvParams[jss::taker_gets][jss::currency] = "USD"; + jvParams[jss::taker_gets][jss::issuer] = gw.human(); + jvParams[jss::domain] = to_string(domainID); + + auto jv = wsc->invoke("book_offers", jvParams); + auto jrr = jv[jss::result]; + checkBookOffers(jrr); + } + + // subscribe to domain book should return hybrid offer + { + Json::Value books; + books[jss::books] = Json::arrayValue; + { + auto& j = books[jss::books].append(Json::objectValue); + j[jss::snapshot] = true; + j[jss::taker_pays][jss::currency] = "XRP"; + j[jss::taker_gets][jss::currency] = "USD"; + j[jss::taker_gets][jss::issuer] = gw.human(); + j[jss::domain] = to_string(domainID); + } + + auto jv = wsc->invoke("subscribe", books); + if (!BEAST_EXPECT(jv[jss::status] == "success")) + return; + checkSubBooks(jv); + } + + // subscribe to open book should return hybrid offer + { + Json::Value books; + books[jss::books] = Json::arrayValue; + { + auto& j = books[jss::books].append(Json::objectValue); + j[jss::snapshot] = true; + j[jss::taker_pays][jss::currency] = "XRP"; + j[jss::taker_gets][jss::currency] = "USD"; + j[jss::taker_gets][jss::issuer] = gw.human(); + } + + auto jv = wsc->invoke("subscribe", books); + if (!BEAST_EXPECT(jv[jss::status] == "success")) + return; + checkSubBooks(jv); + } + } + void run() override { @@ -1728,6 +2009,8 @@ class Book_test : public beast::unit_test::suite testBookOfferErrors(); testBookOfferLimits(true); testBookOfferLimits(false); + testTrackDomainOffer(); + testTrackHybridOffer(); } }; diff --git a/src/test/rpc/GatewayBalances_test.cpp b/src/test/rpc/GatewayBalances_test.cpp index 249d4f892fd..7e9273d25ee 100644 --- a/src/test/rpc/GatewayBalances_test.cpp +++ b/src/test/rpc/GatewayBalances_test.cpp @@ -252,7 +252,10 @@ class GatewayBalances_test : public beast::unit_test::suite { using namespace jtx; auto const sa = supported_amendments(); - for (auto feature : {sa - featureFlowCross, sa}) + for (auto feature : + {sa - featureFlowCross - featurePermissionedDEX, + sa - featurePermissionedDEX, + sa}) { testGWB(feature); testGWBApiVersions(feature); diff --git a/src/test/rpc/JSONRPC_test.cpp b/src/test/rpc/JSONRPC_test.cpp index 8d4f7631254..c7a26720b21 100644 --- a/src/test/rpc/JSONRPC_test.cpp +++ b/src/test/rpc/JSONRPC_test.cpp @@ -2041,6 +2041,28 @@ static constexpr TxnTestData txnTestArray[] = { "Cannot specify differing 'Amount' and 'DeliverMax'", "Cannot specify differing 'Amount' and 'DeliverMax'", "Cannot specify differing 'Amount' and 'DeliverMax'"}}}, + {"Payment cannot specify bad DomainID.", + __LINE__, + R"({ + "command": "doesnt_matter", + "account": "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh", + "secret": "masterpassphrase", + "debug_signing": 0, + "tx_json": { + "Account": "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh", + "Amount": "1000000000", + "Destination": "rnUy2SHTrB9DubsPmkJZUXTf5FcNDGrYEA", + "Fee": 50, + "Sequence": 0, + "SigningPubKey": "", + "TransactionType": "Payment", + "DomainID": "invalid", + } +})", + {{"Field 'tx_json.DomainID' has invalid data.", + "Field 'tx_json.DomainID' has invalid data.", + "Field 'tx_json.DomainID' has invalid data.", + "Field 'tx_json.DomainID' has invalid data."}}}, }; diff --git a/src/test/rpc/NoRipple_test.cpp b/src/test/rpc/NoRipple_test.cpp index 5c41f25128d..42c86b34bb3 100644 --- a/src/test/rpc/NoRipple_test.cpp +++ b/src/test/rpc/NoRipple_test.cpp @@ -294,7 +294,8 @@ class NoRipple_test : public beast::unit_test::suite }; using namespace jtx; auto const sa = supported_amendments(); - withFeatsTests(sa - featureFlowCross); + withFeatsTests(sa - featureFlowCross - featurePermissionedDEX); + withFeatsTests(sa - featurePermissionedDEX); withFeatsTests(sa); } }; diff --git a/src/test/rpc/Subscribe_test.cpp b/src/test/rpc/Subscribe_test.cpp index 3d1b4254220..32296c5d0a2 100644 --- a/src/test/rpc/Subscribe_test.cpp +++ b/src/test/rpc/Subscribe_test.cpp @@ -1300,6 +1300,60 @@ class Subscribe_test : public beast::unit_test::suite } } + void + testSubBookChanges() + { + testcase("SubBookChanges"); + using namespace jtx; + using namespace std::chrono_literals; + FeatureBitset const all{ + jtx::supported_amendments() | featurePermissionedDomains | + featureCredentials | featurePermissionedDEX}; + + Env env(*this, all); + PermissionedDEX permDex(env); + auto const alice = permDex.alice; + auto const bob = permDex.bob; + auto const carol = permDex.carol; + auto const domainID = permDex.domainID; + auto const gw = permDex.gw; + auto const USD = permDex.USD; + + auto wsc = makeWSClient(env.app().config()); + + Json::Value streams; + streams[jss::streams] = Json::arrayValue; + streams[jss::streams][0u] = "book_changes"; + + auto jv = wsc->invoke("subscribe", streams); + if (!BEAST_EXPECT(jv[jss::status] == "success")) + return; + env(offer(alice, XRP(10), USD(10)), + domain(domainID), + txflags(tfHybrid)); + env.close(); + + env(pay(bob, carol, USD(5)), + path(~USD), + sendmax(XRP(5)), + domain(domainID)); + env.close(); + + BEAST_EXPECT(wsc->findMsg(5s, [&](auto const& jv) { + if (jv[jss::changes].size() != 1) + return false; + + auto const jrOffer = jv[jss::changes][0u]; + return (jv[jss::changes][0u][jss::domain]).asString() == + strHex(domainID) && + jrOffer[jss::currency_a].asString() == "XRP_drops" && + jrOffer[jss::volume_a].asString() == "5000000" && + jrOffer[jss::currency_b].asString() == + "rHUKYAZyUFn8PCZWbPfwHfbVQXTYrYKkHb/USD" && + jrOffer[jss::volume_b].asString() == "5"; + })); + } + void run() override { @@ -1318,6 +1372,7 @@ class Subscribe_test : public beast::unit_test::suite testSubErrors(false); testSubByUrl(); testHistoryTxStream(); + testSubBookChanges(); } }; diff --git a/src/xrpld/app/ledger/OrderBookDB.cpp b/src/xrpld/app/ledger/OrderBookDB.cpp index 5d3616ce20e..500515e0864 100644 --- a/src/xrpld/app/ledger/OrderBookDB.cpp +++ b/src/xrpld/app/ledger/OrderBookDB.cpp @@ -28,6 +28,8 @@ #include #include +#include + namespace ripple { OrderBookDB::OrderBookDB(Application& app) @@ -89,6 +91,8 @@ OrderBookDB::update(std::shared_ptr const& ledger) decltype(allBooks_) allBooks; decltype(xrpBooks_) xrpBooks; + decltype(domainBooks_) domainBooks; + decltype(xrpDomainBooks_) xrpDomainBooks; allBooks.reserve(allBooks_.size()); xrpBooks.reserve(xrpBooks_.size()); @@ -120,10 +124,16 @@ OrderBookDB::update(std::shared_ptr const& ledger) book.in.account = sle->getFieldH160(sfTakerPaysIssuer); book.out.currency = sle->getFieldH160(sfTakerGetsCurrency); book.out.account = sle->getFieldH160(sfTakerGetsIssuer); + book.domain = (*sle)[~sfDomainID]; - allBooks[book.in].insert(book.out); + if (book.domain) + domainBooks_[{book.in, *book.domain}].insert(book.out); + else + allBooks[book.in].insert(book.out); - if (isXRP(book.out)) + if (book.domain && isXRP(book.out)) + xrpDomainBooks.insert({book.in, *book.domain}); + else if (isXRP(book.out)) xrpBooks.insert(book.in); ++cnt; @@ -160,6 +170,8 @@ OrderBookDB::update(std::shared_ptr const& ledger) std::lock_guard sl(mLock); allBooks_.swap(allBooks); xrpBooks_.swap(xrpBooks); + domainBooks_.swap(domainBooks); + xrpDomainBooks_.swap(xrpDomainBooks); } app_.getLedgerMaster().newOrderBookDB(); @@ -172,27 +184,48 @@ OrderBookDB::addOrderBook(Book const& book) std::lock_guard sl(mLock); - allBooks_[book.in].insert(book.out); + if (book.domain) + domainBooks_[{book.in, *book.domain}].insert(book.out); + else + allBooks_[book.in].insert(book.out); - if (toXRP) + if (book.domain && toXRP) + xrpDomainBooks_.insert({book.in, *book.domain}); + else if (toXRP) xrpBooks_.insert(book.in); } // return list of all orderbooks that want this issuerID and currencyID std::vector -OrderBookDB::getBooksByTakerPays(Issue const& issue) +OrderBookDB::getBooksByTakerPays( + Issue const& issue, + std::optional const& domain) { std::vector ret; { std::lock_guard sl(mLock); - if (auto it = allBooks_.find(issue); it != allBooks_.end()) + if (!domain) { - ret.reserve(it->second.size()); + if (auto it = allBooks_.find(issue); it != allBooks_.end()) + { + ret.reserve(it->second.size()); - for (auto const& gets : it->second) - ret.push_back(Book(issue, gets)); + for (auto const& gets : it->second) + ret.emplace_back(issue, gets); + } + } + else + { + if (auto it = domainBooks_.find({issue, *domain}); + it != domainBooks_.end()) + { + ret.reserve(it->second.size()); + + for (auto const& gets : it->second) + ret.emplace_back(issue, gets, domain); + } } } @@ -200,19 +233,34 @@ OrderBookDB::getBooksByTakerPays(Issue const& issue) } int -OrderBookDB::getBookSize(Issue const& issue) +OrderBookDB::getBookSize( + Issue const& issue, + std::optional const& domain) { std::lock_guard sl(mLock); - if (auto it = allBooks_.find(issue); it != allBooks_.end()) - return static_cast(it->second.size()); + + if (!domain) + { + if (auto it = allBooks_.find(issue); it != allBooks_.end()) + return static_cast(it->second.size()); + } + else + { + if (auto it = domainBooks_.find({issue, *domain}); + it != domainBooks_.end()) + return static_cast(it->second.size()); + } + return 0; } bool -OrderBookDB::isBookToXRP(Issue const& issue) +OrderBookDB::isBookToXRP(Issue const& issue, std::optional domain) { std::lock_guard sl(mLock); - return xrpBooks_.count(issue) > 0; + if (domain) + return xrpDomainBooks_.contains({issue, *domain}); + return xrpBooks_.contains(issue); } BookListeners::pointer @@ -278,7 +326,8 @@ OrderBookDB::processTxn( { auto listeners = getBookListeners( {data->getFieldAmount(sfTakerGets).issue(), - data->getFieldAmount(sfTakerPays).issue()}); + data->getFieldAmount(sfTakerPays).issue(), + (*data)[~sfDomainID]}); if (listeners) listeners->publish(jvObj, havePublished); } diff --git a/src/xrpld/app/ledger/OrderBookDB.h b/src/xrpld/app/ledger/OrderBookDB.h index d120f43aea4..f60d55d7cfa 100644 --- a/src/xrpld/app/ledger/OrderBookDB.h +++ b/src/xrpld/app/ledger/OrderBookDB.h @@ -27,12 +27,15 @@ #include #include +#include namespace ripple { class OrderBookDB { public: + using Domain = uint256; + explicit OrderBookDB(Application& app); void @@ -46,15 +49,19 @@ class OrderBookDB /** @return a list of all orderbooks that want this issuerID and currencyID. */ std::vector - getBooksByTakerPays(Issue const&); + getBooksByTakerPays( + Issue const&, + std::optional const& domain = std::nullopt); /** @return a count of all orderbooks that want this issuerID and currencyID. */ int - getBookSize(Issue const&); + getBookSize( + Issue const&, + std::optional const& domain = std::nullopt); bool - isBookToXRP(Issue const&); + isBookToXRP(Issue const&, std::optional domain = std::nullopt); BookListeners::pointer getBookListeners(Book const&); @@ -74,9 +81,15 @@ class OrderBookDB // Maps order books by "issue in" to "issue out": hardened_hash_map> allBooks_; + hardened_hash_map, hardened_hash_set> + domainBooks_; + // does an order book to XRP exist hash_set xrpBooks_; + // does an order book to XRP exist + hash_set> xrpDomainBooks_; + std::recursive_mutex mLock; using BookToListenersMap = hash_map; diff --git a/src/xrpld/app/misc/PermissionedDEXHelpers.cpp b/src/xrpld/app/misc/PermissionedDEXHelpers.cpp new file mode 100644 index 00000000000..6e28f11359f --- /dev/null +++ b/src/xrpld/app/misc/PermissionedDEXHelpers.cpp @@ -0,0 +1,88 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2025 Ripple Labs Inc. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include +#include + +namespace ripple { +namespace permissioned_dex { + +bool +accountInDomain( + ReadView const& view, + AccountID const& account, + uint256 const& domainID) +{ + auto const sleDomain = view.read(keylet::permissionedDomain(domainID)); + if (!sleDomain) + return false; + + // domain owner is in the domain + if (sleDomain->getAccountID(sfOwner) == account) + return true; + + auto const& credentials = sleDomain->getFieldArray(sfAcceptedCredentials); + + bool const inDomain = std::any_of( + credentials.begin(), credentials.end(), [&](auto const& credential) { + auto const sleCred = view.read(keylet::credential( + account, credential[sfIssuer], credential[sfCredentialType])); + if (!sleCred || !sleCred->isFlag(lsfAccepted)) + return false; + + return !credentials::checkExpired( + sleCred, view.info().parentCloseTime); + }); + + return inDomain; +} + +bool +offerInDomain( + ReadView const& view, + uint256 const& offerID, + uint256 const& domainID, + beast::Journal j) +{ + auto const sleOffer = view.read(keylet::offer(offerID)); + + // The following are defensive checks that should never happen, since this + // function is used to check against the order book offers, which should not + // have any of the following wrong behavior + if (!sleOffer) + return false; // LCOV_EXCL_LINE + if (!sleOffer->isFieldPresent(sfDomainID)) + return false; // LCOV_EXCL_LINE + if (sleOffer->getFieldH256(sfDomainID) != domainID) + return false; // LCOV_EXCL_LINE + + if (sleOffer->isFlag(lsfHybrid) && + !sleOffer->isFieldPresent(sfAdditionalBooks)) + { + JLOG(j.error()) << "Hybrid offer " << offerID + << " missing AdditionalBooks field"; + return false; // LCOV_EXCL_LINE + } + + return accountInDomain(view, sleOffer->getAccountID(sfAccount), domainID); +} + +} // namespace permissioned_dex + +} // namespace ripple diff --git a/src/xrpld/app/misc/PermissionedDEXHelpers.h b/src/xrpld/app/misc/PermissionedDEXHelpers.h new file mode 100644 index 00000000000..716852952ba --- /dev/null +++ b/src/xrpld/app/misc/PermissionedDEXHelpers.h @@ -0,0 +1,43 @@ +//------------------------------------------------------------------------------ +/* + This file is part of rippled: https://github.com/ripple/rippled + Copyright (c) 2025 Ripple Labs Inc. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL , DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#pragma once +#include + +namespace ripple { +namespace permissioned_dex { + +// Check if an account is in a permissioned domain +[[nodiscard]] bool +accountInDomain( + ReadView const& view, + AccountID const& account, + uint256 const& domainID); + +// Check if an offer is in the permissioned domain +[[nodiscard]] bool +offerInDomain( + ReadView const& view, + uint256 const& offerID, + uint256 const& domainID, + beast::Journal j); + +} // namespace permissioned_dex + +} // namespace ripple diff --git a/src/xrpld/app/paths/Flow.cpp b/src/xrpld/app/paths/Flow.cpp index 66793fc74c4..8860a3e1dcd 100644 --- a/src/xrpld/app/paths/Flow.cpp +++ b/src/xrpld/app/paths/Flow.cpp @@ -64,6 +64,7 @@ flow( OfferCrossing offerCrossing, std::optional const& limitQuality, std::optional const& sendMax, + std::optional const& domainID, beast::Journal j, path::detail::FlowDebugInfo* flowDebugInfo) { @@ -98,6 +99,7 @@ flow( ownerPaysTransferFee, offerCrossing, ammContext, + domainID, j); if (toStrandsTer != tesSUCCESS) diff --git a/src/xrpld/app/paths/Flow.h b/src/xrpld/app/paths/Flow.h index 048b8785f18..659f1804847 100644 --- a/src/xrpld/app/paths/Flow.h +++ b/src/xrpld/app/paths/Flow.h @@ -66,6 +66,7 @@ flow( OfferCrossing offerCrossing, std::optional const& limitQuality, std::optional const& sendMax, + std::optional const& domainID, beast::Journal j, path::detail::FlowDebugInfo* flowDebugInfo = nullptr); diff --git a/src/xrpld/app/paths/PathRequest.cpp b/src/xrpld/app/paths/PathRequest.cpp index dc2868eaf20..84a7cb4cefe 100644 --- a/src/xrpld/app/paths/PathRequest.cpp +++ b/src/xrpld/app/paths/PathRequest.cpp @@ -438,6 +438,21 @@ PathRequest::parseJson(Json::Value const& jvParams) if (jvParams.isMember(jss::id)) jvId = jvParams[jss::id]; + if (jvParams.isMember(jss::domain)) + { + uint256 num; + if (!jvParams[jss::domain].isString() || + !num.parseHex(jvParams[jss::domain].asString())) + { + jvStatus = rpcError(rpcDOMAIN_MALFORMED); + return PFR_PJ_INVALID; + } + else + { + domain = num; + } + } + return PFR_PJ_NOCHANGE; } @@ -484,6 +499,7 @@ PathRequest::getPathFinder( std::nullopt, dst_amount, saSendMax, + domain, app_); if (pathfinder->findPaths(level, continueCallback)) pathfinder->computePathRanks(max_paths_, continueCallback); @@ -581,6 +597,7 @@ PathRequest::findPaths( *raDstAccount, // --> Account to deliver to. *raSrcAccount, // --> Account sending from. ps, // --> Path set. + domain, // --> Domain. app_.logs(), &rcInput); @@ -601,6 +618,7 @@ PathRequest::findPaths( *raDstAccount, // --> Account to deliver to. *raSrcAccount, // --> Account sending from. ps, // --> Path set. + domain, // --> Domain. app_.logs()); if (rc.result() != tesSUCCESS) diff --git a/src/xrpld/app/paths/PathRequest.h b/src/xrpld/app/paths/PathRequest.h index 3fdbecf7edc..888c5ec94f4 100644 --- a/src/xrpld/app/paths/PathRequest.h +++ b/src/xrpld/app/paths/PathRequest.h @@ -25,6 +25,7 @@ #include #include +#include #include #include @@ -156,6 +157,8 @@ class PathRequest final : public InfoSubRequest, std::set sciSourceCurrencies; std::map mContext; + std::optional domain; + bool convert_all_; std::recursive_mutex mIndexLock; diff --git a/src/xrpld/app/paths/Pathfinder.cpp b/src/xrpld/app/paths/Pathfinder.cpp index 379bb07e4b5..d560d955224 100644 --- a/src/xrpld/app/paths/Pathfinder.cpp +++ b/src/xrpld/app/paths/Pathfinder.cpp @@ -166,6 +166,7 @@ Pathfinder::Pathfinder( std::optional const& uSrcIssuer, STAmount const& saDstAmount, std::optional const& srcAmount, + std::optional const& domain, Application& app) : mSrcAccount(uSrcAccount) , mDstAccount(uDstAccount) @@ -184,6 +185,7 @@ Pathfinder::Pathfinder( 0, true))) , convert_all_(convertAllCheck(mDstAmount)) + , mDomain(domain) , mLedger(cache->getLedger()) , mRLCache(cache) , app_(app) @@ -236,7 +238,8 @@ Pathfinder::findPaths( mSource = STPathElement(account, mSrcCurrency, issuer); auto issuerString = mSrcIssuer ? to_string(*mSrcIssuer) : std::string("none"); - JLOG(j_.trace()) << "findPaths>" << " mSrcAccount=" << mSrcAccount + JLOG(j_.trace()) << "findPaths>" + << " mSrcAccount=" << mSrcAccount << " mDstAccount=" << mDstAccount << " mDstAmount=" << mDstAmount.getFullText() << " mSrcCurrency=" << mSrcCurrency @@ -371,6 +374,7 @@ Pathfinder::getPathLiquidity( mDstAccount, mSrcAccount, pathSet, + mDomain, app_.logs(), &rcInput); // If we can't get even the minimum liquidity requested, we're done. @@ -391,6 +395,7 @@ Pathfinder::getPathLiquidity( mDstAccount, mSrcAccount, pathSet, + mDomain, app_.logs(), &rcInput); @@ -430,6 +435,7 @@ Pathfinder::computePathRanks( mDstAccount, mSrcAccount, STPathSet(), + mDomain, app_.logs(), &rcInput); @@ -740,7 +746,7 @@ Pathfinder::getPathsOut( if (!bFrozen) { - count = app_.getOrderBookDB().getBookSize(issue); + count = app_.getOrderBookDB().getBookSize(issue, mDomain); if (auto const lines = mRLCache->getRippleLines(account, direction)) { @@ -1127,7 +1133,8 @@ Pathfinder::addLink( { // to XRP only if (!bOnXRP && - app_.getOrderBookDB().isBookToXRP({uEndCurrency, uEndIssuer})) + app_.getOrderBookDB().isBookToXRP( + {uEndCurrency, uEndIssuer}, mDomain)) { STPathElement pathElement( STPathElement::typeCurrency, @@ -1141,7 +1148,7 @@ Pathfinder::addLink( { bool bDestOnly = (addFlags & afOB_LAST) != 0; auto books = app_.getOrderBookDB().getBooksByTakerPays( - {uEndCurrency, uEndIssuer}); + {uEndCurrency, uEndIssuer}, mDomain); JLOG(j_.trace()) << books.size() << " books found from this currency/issuer"; diff --git a/src/xrpld/app/paths/Pathfinder.h b/src/xrpld/app/paths/Pathfinder.h index 973fda88551..ea3928dff46 100644 --- a/src/xrpld/app/paths/Pathfinder.h +++ b/src/xrpld/app/paths/Pathfinder.h @@ -48,6 +48,7 @@ class Pathfinder : public CountedObject std::optional const& uSrcIssuer, STAmount const& dstAmount, std::optional const& srcAmount, + std::optional const& domain, Application& app); Pathfinder(Pathfinder const&) = delete; Pathfinder& @@ -205,6 +206,7 @@ class Pathfinder : public CountedObject been removed. */ STAmount mRemainingAmount; bool convert_all_; + std::optional mDomain; std::shared_ptr mLedger; std::unique_ptr m_loadEvent; diff --git a/src/xrpld/app/paths/RippleCalc.cpp b/src/xrpld/app/paths/RippleCalc.cpp index c783bb8e9f3..4e472e07c83 100644 --- a/src/xrpld/app/paths/RippleCalc.cpp +++ b/src/xrpld/app/paths/RippleCalc.cpp @@ -53,6 +53,8 @@ RippleCalc::rippleCalculate( // A set of paths that are included in the transaction that we'll // explore for liquidity. STPathSet const& spsPaths, + + std::optional const& domainID, Logs& l, Input const* const pInputs) { @@ -110,6 +112,7 @@ RippleCalc::rippleCalculate( OfferCrossing::no, limitQuality, sendMax, + domainID, j, nullptr); } diff --git a/src/xrpld/app/paths/RippleCalc.h b/src/xrpld/app/paths/RippleCalc.h index 45f68725ccb..09de7334e8a 100644 --- a/src/xrpld/app/paths/RippleCalc.h +++ b/src/xrpld/app/paths/RippleCalc.h @@ -111,6 +111,8 @@ class RippleCalc // A set of paths that are included in the transaction that we'll // explore for liquidity. STPathSet const& spsPaths, + + std::optional const& domainID, Logs& l, Input const* const pInputs = nullptr); diff --git a/src/xrpld/app/paths/detail/BookStep.cpp b/src/xrpld/app/paths/detail/BookStep.cpp index 5e650230fe4..8d20a9900ca 100644 --- a/src/xrpld/app/paths/detail/BookStep.cpp +++ b/src/xrpld/app/paths/detail/BookStep.cpp @@ -93,7 +93,7 @@ class BookStep : public StepImp> public: BookStep(StrandContext const& ctx, Issue const& in, Issue const& out) : maxOffersToConsume_(getMaxOffersToConsume(ctx)) - , book_(in, out) + , book_(in, out, ctx.domainID) , strandSrc_(ctx.strandSrc) , strandDst_(ctx.strandDst) , prevStep_(ctx.prevStep) @@ -190,7 +190,8 @@ class BookStep : public StepImp> logStringImpl(char const* name) const { std::ostringstream ostr; - ostr << name << ": " << "\ninIss: " << book_.in.account + ostr << name << ": " + << "\ninIss: " << book_.in.account << "\noutIss: " << book_.out.account << "\ninCur: " << book_.in.currency << "\noutCur: " << book_.out.currency; @@ -836,6 +837,10 @@ BookStep::forEachOffer( // At any payment engine iteration, AMM offer can only be consumed once. auto tryAMM = [&](std::optional const& lobQuality) -> bool { + // amm doesn't support domain yet + if (book_.domain) + return true; + // If offer crossing then use either LOB quality or nullopt // to prevent AMM being blocked by a lower quality LOB. auto const qualityThreshold = [&]() -> std::optional { diff --git a/src/xrpld/app/paths/detail/PaySteps.cpp b/src/xrpld/app/paths/detail/PaySteps.cpp index 99f212d5481..aa9e21e1825 100644 --- a/src/xrpld/app/paths/detail/PaySteps.cpp +++ b/src/xrpld/app/paths/detail/PaySteps.cpp @@ -142,6 +142,7 @@ toStrand( bool ownerPaysTransferFee, OfferCrossing offerCrossing, AMMContext& ammContext, + std::optional const& domainID, beast::Journal j) { if (isXRP(src) || isXRP(dst) || !isConsistent(deliver) || @@ -279,6 +280,7 @@ toStrand( seenDirectIssues, seenBookOuts, ammContext, + domainID, j}; }; @@ -476,6 +478,7 @@ toStrands( bool ownerPaysTransferFee, OfferCrossing offerCrossing, AMMContext& ammContext, + std::optional const& domainID, beast::Journal j) { std::vector result; @@ -502,6 +505,7 @@ toStrands( ownerPaysTransferFee, offerCrossing, ammContext, + domainID, j); auto const ter = sp.first; auto& strand = sp.second; @@ -546,6 +550,7 @@ toStrands( ownerPaysTransferFee, offerCrossing, ammContext, + domainID, j); auto ter = sp.first; auto& strand = sp.second; @@ -592,6 +597,7 @@ StrandContext::StrandContext( std::array, 2>& seenDirectIssues_, boost::container::flat_set& seenBookOuts_, AMMContext& ammContext_, + std::optional const& domainID_, beast::Journal j_) : view(view_) , strandSrc(strandSrc_) @@ -608,6 +614,7 @@ StrandContext::StrandContext( , seenDirectIssues(seenDirectIssues_) , seenBookOuts(seenBookOuts_) , ammContext(ammContext_) + , domainID(domainID_) , j(j_) { } diff --git a/src/xrpld/app/paths/detail/Steps.h b/src/xrpld/app/paths/detail/Steps.h index bb9abf6545b..0fcdc85fe15 100644 --- a/src/xrpld/app/paths/detail/Steps.h +++ b/src/xrpld/app/paths/detail/Steps.h @@ -23,6 +23,7 @@ #include #include +#include #include #include #include @@ -388,6 +389,7 @@ normalizePath( owner @param offerCrossing false -> payment; true -> offer crossing @param ammContext counts iterations with AMM offers + @param domainID the domain that order books will use @param j Journal for logging messages @return Error code and constructed Strand */ @@ -403,6 +405,7 @@ toStrand( bool ownerPaysTransferFee, OfferCrossing offerCrossing, AMMContext& ammContext, + std::optional const& domainID, beast::Journal j); /** @@ -427,6 +430,7 @@ toStrand( owner @param offerCrossing false -> payment; true -> offer crossing @param ammContext counts iterations with AMM offers + @param domainID the domain that order books will use @param j Journal for logging messages @return error code and collection of strands */ @@ -443,6 +447,7 @@ toStrands( bool ownerPaysTransferFee, OfferCrossing offerCrossing, AMMContext& ammContext, + std::optional const& domainID, beast::Journal j); /// @cond INTERNAL @@ -553,6 +558,7 @@ struct StrandContext */ boost::container::flat_set& seenBookOuts; AMMContext& ammContext; + std::optional domainID; // the domain the order book will use beast::Journal const j; /** StrandContext constructor. */ @@ -574,6 +580,7 @@ struct StrandContext boost::container::flat_set& seenBookOuts_, ///< For detecting book loops AMMContext& ammContext_, + std::optional const& domainID, beast::Journal j_); ///< Journal for logging }; diff --git a/src/xrpld/app/tx/detail/CashCheck.cpp b/src/xrpld/app/tx/detail/CashCheck.cpp index 468adbd209b..db9522076b7 100644 --- a/src/xrpld/app/tx/detail/CashCheck.cpp +++ b/src/xrpld/app/tx/detail/CashCheck.cpp @@ -451,6 +451,7 @@ CashCheck::doApply() OfferCrossing::no, std::nullopt, sleCheck->getFieldAmount(sfSendMax), + std::nullopt, // check does not support domain viewJ); if (result.result() != tesSUCCESS) diff --git a/src/xrpld/app/tx/detail/CreateOffer.cpp b/src/xrpld/app/tx/detail/CreateOffer.cpp index 92ba54f0774..0bd3e41db2e 100644 --- a/src/xrpld/app/tx/detail/CreateOffer.cpp +++ b/src/xrpld/app/tx/detail/CreateOffer.cpp @@ -18,16 +18,21 @@ //============================================================================== #include +#include #include #include #include +#include #include #include #include +#include -namespace ripple { +#include "xrpl/protocol/STAmount.h" +#include "xrpl/protocol/TER.h" +namespace ripple { TxConsequences CreateOffer::makeTxConsequences(PreflightContext const& ctx) { @@ -42,6 +47,16 @@ CreateOffer::makeTxConsequences(PreflightContext const& ctx) NotTEC CreateOffer::preflight(PreflightContext const& ctx) { + if (ctx.tx.isFieldPresent(sfDomainID) && + !ctx.rules.enabled(featurePermissionedDEX)) + return temDISABLED; + + // Permissioned offers should use the PE (which must be enabled by + // featureFlowCross amendment) + if (ctx.rules.enabled(featurePermissionedDEX) && + !ctx.rules.enabled(featureFlowCross)) + return temDISABLED; + if (auto const ret = preflight1(ctx); !isTesSuccess(ret)) return ret; @@ -56,6 +71,12 @@ CreateOffer::preflight(PreflightContext const& ctx) return temINVALID_FLAG; } + if (!ctx.rules.enabled(featurePermissionedDEX) && tx.isFlag(tfHybrid)) + return temINVALID_FLAG; + + if (tx.isFlag(tfHybrid) && !tx.isFieldPresent(sfDomainID)) + return temINVALID_FLAG; + bool const bImmediateOrCancel(uTxFlags & tfImmediateOrCancel); bool const bFillOrKill(uTxFlags & tfFillOrKill); @@ -198,6 +219,15 @@ CreateOffer::preclaim(PreclaimContext const& ctx) return result; } + // if domain is specified, make sure that domain exists and the offer create + // is part of the domain + if (ctx.tx.isFieldPresent(sfDomainID)) + { + if (!permissioned_dex::accountInDomain( + ctx.view, id, ctx.tx[sfDomainID])) + return tecNO_PERMISSION; + } + return tesSUCCESS; } @@ -708,7 +738,8 @@ std::pair CreateOffer::flowCross( PaymentSandbox& psb, PaymentSandbox& psbCancel, - Amounts const& takerAmount) + Amounts const& takerAmount, + std::optional const& domainID) { try { @@ -805,6 +836,7 @@ CreateOffer::flowCross( offerCrossing, threshold, sendMax, + domainID, j_); // If stale offers were found remove them. @@ -907,13 +939,18 @@ CreateOffer::flowCross( } std::pair -CreateOffer::cross(Sandbox& sb, Sandbox& sbCancel, Amounts const& takerAmount) +CreateOffer::cross( + Sandbox& sb, + Sandbox& sbCancel, + Amounts const& takerAmount, + std::optional const& domainID) { if (sb.rules().enabled(featureFlowCross)) { PaymentSandbox psbFlow{&sb}; PaymentSandbox psbCancelFlow{&sbCancel}; - auto const ret = flowCross(psbFlow, psbCancelFlow, takerAmount); + auto const ret = + flowCross(psbFlow, psbCancelFlow, takerAmount, domainID); psbFlow.apply(sb); psbCancelFlow.apply(sbCancel); return ret; @@ -950,6 +987,54 @@ CreateOffer::preCompute() return Transactor::preCompute(); } +TER +CreateOffer::applyHybrid( + Sandbox& sb, + std::shared_ptr sleOffer, + Keylet const& offerKey, + STAmount const& saTakerPays, + STAmount const& saTakerGets, + std::function)> const& setDir) +{ + if (!sleOffer->isFieldPresent(sfDomainID)) + return tecINTERNAL; // LCOV_EXCL_LINE + + // set hybrid flag + sleOffer->setFlag(lsfHybrid); + + // if offer is hybrid, need to also place into open offer dir + Book const book{saTakerPays.issue(), saTakerGets.issue()}; + + auto dir = + keylet::quality(keylet::book(book), getRate(saTakerGets, saTakerPays)); + bool const bookExists = sb.exists(dir); + + auto const bookNode = sb.dirAppend(dir, offerKey, [&](SLE::ref sle) { + // don't set domainID on the directory object since this directory is + // for open book + setDir(sle, std::nullopt); + }); + + if (!bookNode) + { + JLOG(j_.debug()) + << "final result: failed to add hybrid offer to open book"; + return tecDIR_FULL; // LCOV_EXCL_LINE + } + + STArray bookArr(sfAdditionalBooks, 1); + auto bookInfo = STObject::makeInnerObject(sfBook); + bookInfo.setFieldH256(sfBookDirectory, dir.key); + bookInfo.setFieldU64(sfBookNode, *bookNode); + bookArr.push_back(std::move(bookInfo)); + + if (!bookExists) + ctx_.app.getOrderBookDB().addOrderBook(book); + + sleOffer->setFieldArray(sfAdditionalBooks, bookArr); + return tesSUCCESS; +} + std::pair CreateOffer::applyGuts(Sandbox& sb, Sandbox& sbCancel) { @@ -961,9 +1046,11 @@ CreateOffer::applyGuts(Sandbox& sb, Sandbox& sbCancel) bool const bImmediateOrCancel(uTxFlags & tfImmediateOrCancel); bool const bFillOrKill(uTxFlags & tfFillOrKill); bool const bSell(uTxFlags & tfSell); + bool const bHybrid(uTxFlags & tfHybrid); auto saTakerPays = ctx_.tx[sfTakerPays]; auto saTakerGets = ctx_.tx[sfTakerGets]; + auto const domainID = ctx_.tx[~sfDomainID]; auto const cancelSequence = ctx_.tx[~sfOfferSequence]; @@ -1080,7 +1167,8 @@ CreateOffer::applyGuts(Sandbox& sb, Sandbox& sbCancel) stream << " out: " << format_amount(takerAmount.out); } - std::tie(result, place_offer) = cross(sb, sbCancel, takerAmount); + std::tie(result, place_offer) = + cross(sb, sbCancel, takerAmount, domainID); // We expect the implementation of cross to succeed // or give a tec. @@ -1222,21 +1310,39 @@ CreateOffer::applyGuts(Sandbox& sb, Sandbox& sbCancel) adjustOwnerCount(sb, sleCreator, 1, viewJ); JLOG(j_.trace()) << "adding to book: " << to_string(saTakerPays.issue()) - << " : " << to_string(saTakerGets.issue()); + << " : " << to_string(saTakerGets.issue()) + << (domainID ? (" : " + to_string(*domainID)) : ""); - Book const book{saTakerPays.issue(), saTakerGets.issue()}; + Book const book{saTakerPays.issue(), saTakerGets.issue(), domainID}; // Add offer to order book, using the original rate // before any crossing occured. + // + // Regular offer - BookDirectory points to open directory + // + // Domain offer (w/o hyrbid) - BookDirectory points to domain + // directory + // + // Hybrid domain offer - BookDirectory points to domain directory, + // and AdditionalBooks field stores one entry that points to the open + // directory auto dir = keylet::quality(keylet::book(book), uRate); bool const bookExisted = static_cast(sb.peek(dir)); - auto const bookNode = sb.dirAppend(dir, offer_index, [&](SLE::ref sle) { + auto setBookDir = [&](SLE::ref sle, + std::optional const& maybeDomain) { sle->setFieldH160(sfTakerPaysCurrency, saTakerPays.issue().currency); sle->setFieldH160(sfTakerPaysIssuer, saTakerPays.issue().account); sle->setFieldH160(sfTakerGetsCurrency, saTakerGets.issue().currency); sle->setFieldH160(sfTakerGetsIssuer, saTakerGets.issue().account); sle->setFieldU64(sfExchangeRate, uRate); + if (maybeDomain) + sle->setFieldH256(sfDomainID, *maybeDomain); + }; + + auto const bookNode = sb.dirAppend(dir, offer_index, [&](SLE::ref sle) { + // sets domainID on book directory if it's a domain offer + setBookDir(sle, domainID); }); if (!bookNode) @@ -1259,6 +1365,18 @@ CreateOffer::applyGuts(Sandbox& sb, Sandbox& sbCancel) sleOffer->setFlag(lsfPassive); if (bSell) sleOffer->setFlag(lsfSell); + if (domainID) + sleOffer->setFieldH256(sfDomainID, *domainID); + + // if it's a hybrid offer, set hybrid flag, and create an open dir + if (bHybrid) + { + auto const res = applyHybrid( + sb, sleOffer, offer_index, saTakerPays, saTakerGets, setBookDir); + if (res != tesSUCCESS) + return {res, true}; // LCOV_EXCL_LINE + } + sb.insert(sleOffer); if (!bookExisted) diff --git a/src/xrpld/app/tx/detail/CreateOffer.h b/src/xrpld/app/tx/detail/CreateOffer.h index 35808c78fed..9b35062d8a7 100644 --- a/src/xrpld/app/tx/detail/CreateOffer.h +++ b/src/xrpld/app/tx/detail/CreateOffer.h @@ -121,18 +121,32 @@ class CreateOffer : public Transactor flowCross( PaymentSandbox& psb, PaymentSandbox& psbCancel, - Amounts const& takerAmount); + Amounts const& takerAmount, + std::optional const& domainID); // Temporary // This is a central location that invokes both versions of cross // so the results can be compared. Eventually this layer will be // removed once flowCross is determined to be stable. std::pair - cross(Sandbox& sb, Sandbox& sbCancel, Amounts const& takerAmount); + cross( + Sandbox& sb, + Sandbox& sbCancel, + Amounts const& takerAmount, + std::optional const& domainID); static std::string format_amount(STAmount const& amount); + TER + applyHybrid( + Sandbox& sb, + std::shared_ptr sleOffer, + Keylet const& offer_index, + STAmount const& saTakerPays, + STAmount const& saTakerGets, + std::function)> const& setDir); + private: // What kind of offer we are placing CrossType cross_type_; diff --git a/src/xrpld/app/tx/detail/InvariantCheck.cpp b/src/xrpld/app/tx/detail/InvariantCheck.cpp index b97a0c02eea..0cb82463020 100644 --- a/src/xrpld/app/tx/detail/InvariantCheck.cpp +++ b/src/xrpld/app/tx/detail/InvariantCheck.cpp @@ -1543,4 +1543,87 @@ ValidPermissionedDomain::finalize( (sleStatus_[1] ? check(*sleStatus_[1], j) : true); } +void +ValidPermissionedDEX::visitEntry( + bool, + std::shared_ptr const& before, + std::shared_ptr const& after) +{ + if (after && after->getType() == ltDIR_NODE) + { + if (after->isFieldPresent(sfDomainID)) + domains_.insert(after->getFieldH256(sfDomainID)); + } + + if (after && after->getType() == ltOFFER) + { + if (after->isFieldPresent(sfDomainID)) + domains_.insert(after->getFieldH256(sfDomainID)); + else + regularOffers_ = true; + + // if a hybrid offer is missing domain or additional book, there's + // something wrong + if (after->isFlag(lsfHybrid) && + (!after->isFieldPresent(sfDomainID) || + !after->isFieldPresent(sfAdditionalBooks) || + after->getFieldArray(sfAdditionalBooks).size() > 1)) + badHybrids_ = true; + } +} + +bool +ValidPermissionedDEX::finalize( + STTx const& tx, + TER const result, + XRPAmount const, + ReadView const& view, + beast::Journal const& j) +{ + auto const txType = tx.getTxnType(); + if ((txType != ttPAYMENT && txType != ttOFFER_CREATE) || + result != tesSUCCESS) + return true; + + // For each offercreate transaction, check if + // permissioned offers are valid + if (txType == ttOFFER_CREATE && badHybrids_) + { + JLOG(j.fatal()) << "Invariant failed: hybrid offer is malformed"; + return false; + } + + if (!tx.isFieldPresent(sfDomainID)) + return true; + + auto const domain = tx.getFieldH256(sfDomainID); + + if (!view.exists(keylet::permissionedDomain(domain))) + { + JLOG(j.fatal()) << "Invariant failed: domain doesn't exist"; + return false; + } + + // for both payment and offercreate, there shouldn't be another domain + // that's different from the domain specified + for (auto const& d : domains_) + { + if (d != domain) + { + JLOG(j.fatal()) << "Invariant failed: transaction" + " consumed wrong domains"; + return false; + } + } + + if (regularOffers_) + { + JLOG(j.fatal()) << "Invariant failed: domain transaction" + " affected regular offers"; + return false; + } + + return true; +} + } // namespace ripple diff --git a/src/xrpld/app/tx/detail/InvariantCheck.h b/src/xrpld/app/tx/detail/InvariantCheck.h index cb06b0fb054..d7a90e9b6b8 100644 --- a/src/xrpld/app/tx/detail/InvariantCheck.h +++ b/src/xrpld/app/tx/detail/InvariantCheck.h @@ -28,6 +28,7 @@ #include #include +#include namespace ripple { @@ -616,6 +617,28 @@ class ValidPermissionedDomain beast::Journal const&); }; +class ValidPermissionedDEX +{ + bool regularOffers_ = false; + bool badHybrids_ = false; + hash_set domains_; + +public: + void + visitEntry( + bool, + std::shared_ptr const&, + std::shared_ptr const&); + + bool + finalize( + STTx const&, + TER const, + XRPAmount const, + ReadView const&, + beast::Journal const&); +}; + // additional invariant checks can be declared above and then added to this // tuple using InvariantChecks = std::tuple< @@ -635,7 +658,8 @@ using InvariantChecks = std::tuple< NFTokenCountTracking, ValidClawback, ValidMPTIssuance, - ValidPermissionedDomain>; + ValidPermissionedDomain, + ValidPermissionedDEX>; /** * @brief get a tuple of all invariant checks diff --git a/src/xrpld/app/tx/detail/OfferStream.cpp b/src/xrpld/app/tx/detail/OfferStream.cpp index 7640cca206b..e9701c6e35c 100644 --- a/src/xrpld/app/tx/detail/OfferStream.cpp +++ b/src/xrpld/app/tx/detail/OfferStream.cpp @@ -17,11 +17,15 @@ */ //============================================================================== +#include #include #include #include +#include "xrpl/protocol/LedgerFormats.h" +#include "xrpld/ledger/View.h" + namespace ripple { namespace { @@ -288,6 +292,17 @@ TOfferStreamBase::step() continue; } + if (entry->isFieldPresent(sfDomainID) && + !permissioned_dex::offerInDomain( + view_, entry->key(), entry->getFieldH256(sfDomainID), j_)) + { + JLOG(j_.trace()) + << "Removing offer no longer in domain " << entry->key(); + permRmOffer(entry->key()); + offer_ = TOffer{}; + continue; + } + // Calculate owner funds ownerFunds_ = accountFundsHelper( view_, diff --git a/src/xrpld/app/tx/detail/Payment.cpp b/src/xrpld/app/tx/detail/Payment.cpp index c2b7b23a6a5..e519d1eacb6 100644 --- a/src/xrpld/app/tx/detail/Payment.cpp +++ b/src/xrpld/app/tx/detail/Payment.cpp @@ -18,6 +18,7 @@ //============================================================================== #include +#include #include #include #include @@ -70,6 +71,10 @@ Payment::preflight(PreflightContext const& ctx) !ctx.rules.enabled(featureCredentials)) return temDISABLED; + if (ctx.tx.isFieldPresent(sfDomainID) && + !ctx.rules.enabled(featurePermissionedDEX)) + return temDISABLED; + if (auto const ret = preflight1(ctx); !isTesSuccess(ret)) return ret; @@ -323,6 +328,17 @@ Payment::preclaim(PreclaimContext const& ctx) !isTesSuccess(err)) return err; + if (ctx.tx.isFieldPresent(sfDomainID)) + { + if (!permissioned_dex::accountInDomain( + ctx.view, ctx.tx[sfAccount], ctx.tx[sfDomainID])) + return tecNO_PERMISSION; + + if (!permissioned_dex::accountInDomain( + ctx.view, ctx.tx[sfDestination], ctx.tx[sfDomainID])) + return tecNO_PERMISSION; + } + return tesSUCCESS; } @@ -424,6 +440,7 @@ Payment::doApply() dstAccountID, account_, ctx_.tx.getFieldPathSet(sfPaths), + ctx_.tx[~sfDomainID], ctx_.app.logs(), &rcInput); // VFALCO NOTE We might not need to apply, depending diff --git a/src/xrpld/app/tx/detail/XChainBridge.cpp b/src/xrpld/app/tx/detail/XChainBridge.cpp index 5fa03557e5a..6ca049ee66e 100644 --- a/src/xrpld/app/tx/detail/XChainBridge.cpp +++ b/src/xrpld/app/tx/detail/XChainBridge.cpp @@ -511,6 +511,7 @@ transferHelper( /*offer crossing*/ OfferCrossing::no, /*limit quality*/ std::nullopt, /*sendmax*/ std::nullopt, + /*domain id*/ std::nullopt, j); if (auto const r = result.result(); diff --git a/src/xrpld/ledger/detail/View.cpp b/src/xrpld/ledger/detail/View.cpp index 2a5224ebf19..f0a4afeee37 100644 --- a/src/xrpld/ledger/detail/View.cpp +++ b/src/xrpld/ledger/detail/View.cpp @@ -17,6 +17,7 @@ */ //============================================================================== +#include #include #include @@ -26,6 +27,7 @@ #include #include #include +#include #include #include @@ -381,7 +383,8 @@ accountHolds( amount.clear(Issue{currency, issuer}); } - JLOG(j.trace()) << "accountHolds:" << " account=" << to_string(account) + JLOG(j.trace()) << "accountHolds:" + << " account=" << to_string(account) << " amount=" << amount.getFullText(); return view.balanceHook(account, issuer, amount); @@ -530,7 +533,8 @@ xrpLiquid( STAmount const amount = (balance < reserve) ? STAmount{0} : balance - reserve; - JLOG(j.trace()) << "accountHolds:" << " account=" << to_string(id) + JLOG(j.trace()) << "accountHolds:" + << " account=" << to_string(id) << " amount=" << amount.getFullText() << " fullBalance=" << fullBalance.getFullText() << " balance=" << balance.getFullText() @@ -1117,6 +1121,27 @@ offerDelete(ApplyView& view, std::shared_ptr const& sle, beast::Journal j) return tefBAD_LEDGER; } + if (sle->isFieldPresent(sfAdditionalBooks)) + { + XRPL_ASSERT( + sle->isFlag(lsfHybrid) && sle->isFieldPresent(sfDomainID), + "ripple::offerDelete : should be a hybrid domain offer"); + + auto const& additionalBookDirs = sle->getFieldArray(sfAdditionalBooks); + + for (auto const& bookDir : additionalBookDirs) + { + auto const& dirIndex = bookDir.getFieldH256(sfBookDirectory); + auto const& dirNode = bookDir.getFieldU64(sfBookNode); + + if (!view.dirRemove( + keylet::page(dirIndex), dirNode, offerIndex, false)) + { + return tefBAD_LEDGER; // LCOV_EXCL_LINE + } + } + } + adjustOwnerCount(view, view.peek(keylet::account(owner)), -1, j); view.erase(sle); diff --git a/src/xrpld/rpc/BookChanges.h b/src/xrpld/rpc/BookChanges.h index c87fa0ccf4e..030213a7e9a 100644 --- a/src/xrpld/rpc/BookChanges.h +++ b/src/xrpld/rpc/BookChanges.h @@ -49,13 +49,13 @@ computeBookChanges(std::shared_ptr const& lpAccepted) std::map< std::string, std::tuple< - STAmount, // side A volume - STAmount, // side B volume - STAmount, // high rate - STAmount, // low rate - STAmount, // open rate - STAmount // close rate - >> + STAmount, // side A volume + STAmount, // side B volume + STAmount, // high rate + STAmount, // low rate + STAmount, // open rate + STAmount, // close rate + std::optional>> // domain id tally; for (auto& tx : lpAccepted->txs) @@ -148,6 +148,8 @@ computeBookChanges(std::shared_ptr const& lpAccepted) else ss << p << "|" << g; + std::optional domain = finalFields[~sfDomainID]; + std::string key{ss.str()}; if (tally.find(key) == tally.end()) @@ -157,8 +159,8 @@ computeBookChanges(std::shared_ptr const& lpAccepted) rate, // high rate, // low rate, // open - rate // close - }; + rate, // close + domain}; else { // increment volume @@ -173,7 +175,8 @@ computeBookChanges(std::shared_ptr const& lpAccepted) if (std::get<3>(entry) > rate) // low std::get<3>(entry) = rate; - std::get<5>(entry) = rate; // close + std::get<5>(entry) = rate; // close + std::get<6>(entry) = domain; // domain } } } @@ -211,6 +214,10 @@ computeBookChanges(std::shared_ptr const& lpAccepted) inner[jss::low] = to_string(std::get<3>(entry.second).iou()); inner[jss::open] = to_string(std::get<4>(entry.second).iou()); inner[jss::close] = to_string(std::get<5>(entry.second).iou()); + + std::optional const domain = std::get<6>(entry.second); + if (domain) + inner[jss::domain] = to_string(*domain); } return jvObj; diff --git a/src/xrpld/rpc/detail/TransactionSign.cpp b/src/xrpld/rpc/detail/TransactionSign.cpp index 2a7807f8cae..4543b1d96b5 100644 --- a/src/xrpld/rpc/detail/TransactionSign.cpp +++ b/src/xrpld/rpc/detail/TransactionSign.cpp @@ -40,6 +40,7 @@ #include #include +#include namespace ripple { namespace RPC { @@ -222,6 +223,22 @@ checkPayment( rpcINVALID_PARAMS, "Cannot specify both 'tx_json.Paths' and 'build_path'"); + std::optional domain; + if (tx_json.isMember(jss::domain)) + { + uint256 num; + if (!tx_json[sfDomainID.jsonName].isString() || + !num.parseHex(tx_json[sfDomainID.jsonName].asString())) + { + return RPC::make_error( + rpcDOMAIN_MALFORMED, "Unable to parse 'DomainID'."); + } + else + { + domain = num; + } + } + if (!tx_json.isMember(jss::Paths) && params.isMember(jss::build_path)) { STAmount sendMax; @@ -260,6 +277,7 @@ checkPayment( sendMax.issue().account, amount, std::nullopt, + domain, app); if (pf.findPaths(app.config().PATH_SEARCH_OLD)) { @@ -716,8 +734,7 @@ transactionFormatResultImpl(Transaction::pointer tpTrans, unsigned apiVersion) //------------------------------------------------------------------------------ -[[nodiscard]] -static XRPAmount +[[nodiscard]] static XRPAmount getTxFee(Application const& app, Config const& config, Json::Value tx) { // autofilling only needed in this function so that the `STParsedJSONObject` diff --git a/src/xrpld/rpc/handlers/BookOffers.cpp b/src/xrpld/rpc/handlers/BookOffers.cpp index bede01b9279..df4712209c5 100644 --- a/src/xrpld/rpc/handlers/BookOffers.cpp +++ b/src/xrpld/rpc/handlers/BookOffers.cpp @@ -172,6 +172,22 @@ doBookOffers(RPC::JsonContext& context) return RPC::invalid_field_error(jss::taker); } + std::optional domain; + if (context.params.isMember(jss::domain)) + { + uint256 num; + if (!context.params[jss::domain].isString() || + !num.parseHex(context.params[jss::domain].asString())) + { + return RPC::make_error( + rpcDOMAIN_MALFORMED, "Unable to parse domain."); + } + else + { + domain = num; + } + } + if (pay_currency == get_currency && pay_issuer == get_issuer) { JLOG(context.j.info()) << "taker_gets same as taker_pays."; @@ -190,7 +206,7 @@ doBookOffers(RPC::JsonContext& context) context.netOps.getBookPage( lpLedger, - {{pay_currency, pay_issuer}, {get_currency, get_issuer}}, + {{pay_currency, pay_issuer}, {get_currency, get_issuer}, domain}, takerID ? *takerID : beast::zero, bProof, limit, diff --git a/src/xrpld/rpc/handlers/Subscribe.cpp b/src/xrpld/rpc/handlers/Subscribe.cpp index 35b82edb3ff..15b47ecebda 100644 --- a/src/xrpld/rpc/handlers/Subscribe.cpp +++ b/src/xrpld/rpc/handlers/Subscribe.cpp @@ -305,6 +305,20 @@ doSubscribe(RPC::JsonContext& context) return rpcError(rpcBAD_ISSUER); } + if (j.isMember(jss::domain)) + { + uint256 domain; + if (!j[jss::domain].isString() || + !domain.parseHex(j[jss::domain].asString())) + { + return rpcError(rpcDOMAIN_MALFORMED); + } + else + { + book.domain = domain; + } + } + if (!isConsistent(book)) { JLOG(context.j.warn()) << "Bad market: " << book;