diff --git a/src/data/Types.hpp b/src/data/Types.hpp index 175583efa1..ca2bcf7418 100644 --- a/src/data/Types.hpp +++ b/src/data/Types.hpp @@ -70,6 +70,7 @@ struct TransactionAndMetadata { Blob metadata; std::uint32_t ledgerSequence = 0; std::uint32_t date = 0; + std::optional delegatedAccount; TransactionAndMetadata() = default; diff --git a/src/data/cassandra/CassandraBackendFamily.hpp b/src/data/cassandra/CassandraBackendFamily.hpp index ad917e1293..12cf9845f8 100644 --- a/src/data/cassandra/CassandraBackendFamily.hpp +++ b/src/data/cassandra/CassandraBackendFamily.hpp @@ -40,12 +40,18 @@ #include #include #include +#include #include #include #include #include #include +#include +#include +#include +#include #include +#include #include #include diff --git a/src/rpc/CMakeLists.txt b/src/rpc/CMakeLists.txt index f06752fc01..d800ea76c7 100644 --- a/src/rpc/CMakeLists.txt +++ b/src/rpc/CMakeLists.txt @@ -20,6 +20,7 @@ target_sources( common/MetaProcessors.cpp common/impl/APIVersionParser.cpp common/impl/HandlerProvider.cpp + filters/impl/DelegateTransactionsFilter.cpp handlers/AccountChannels.cpp handlers/AccountCurrencies.cpp handlers/AccountInfo.cpp diff --git a/src/rpc/RPCHelpers.cpp b/src/rpc/RPCHelpers.cpp index 72a6555ff5..ed3e85ea81 100644 --- a/src/rpc/RPCHelpers.cpp +++ b/src/rpc/RPCHelpers.cpp @@ -1567,4 +1567,48 @@ toJsonWithBinaryTx(data::TransactionAndMetadata const& txnPlusMeta, std::uint32_ return obj; } +std::optional +parseDelegateType(boost::json::value const& delegateType) +{ + if (not delegateType.is_string()) + return {}; + + auto const& type = delegateType.as_string(); + + if (type == "delegator") + return DelegateFilter::Role::Delegator; + if (type == "delegatee") + return DelegateFilter::Role::Delegatee; + + return {}; +} + +std::optional +parseDelegateFilter(boost::json::object const& delegateObject) +{ + DelegateFilter delegate{}; + if (!delegateObject.contains("delegate_filter")) + return {}; + + auto const& filterVal = delegateObject.at("delegate_filter"); + if (!filterVal.is_string()) + return {}; + + auto const delegateTypeOpt = parseDelegateType(filterVal.as_string()); + if (!delegateTypeOpt.has_value()) + return {}; + + delegate.delegateType = *delegateTypeOpt; + if (delegateObject.contains("counterparty")) { + auto const& counterpartyVal = delegateObject.at("counterparty"); + + if (!counterpartyVal.is_string()) + return {}; + + delegate.counterParty = counterpartyVal.as_string(); + } + + return delegate; +} + } // namespace rpc diff --git a/src/rpc/RPCHelpers.hpp b/src/rpc/RPCHelpers.hpp index 5d3f9bb994..9f8524c85c 100644 --- a/src/rpc/RPCHelpers.hpp +++ b/src/rpc/RPCHelpers.hpp @@ -861,4 +861,22 @@ getDeliveredAmount( uint32_t date ); +/** + * @brief Parse the delegate type from a JSON value + * + * @param delegateType The JSON value containing the delegate type string + * @return The parsed delegate type or std::nullopt if the input is invalid or not a string + */ +std::optional +parseDelegateType(boost::json::value const& delegateType); + +/** + * @brief Parse a delegate filter object from JSON + * + * @param delegateObject The JSON object containing the delegate filter input from user + * @return The constructed DelegateFilter or std::nullopt if parsing fails + */ +std::optional +parseDelegateFilter(boost::json::object const& delegateObject); + } // namespace rpc diff --git a/src/rpc/common/Types.hpp b/src/rpc/common/Types.hpp index 85beda00b0..bbfa19dc8b 100644 --- a/src/rpc/common/Types.hpp +++ b/src/rpc/common/Types.hpp @@ -33,9 +33,9 @@ #include #include +#include #include #include -#include namespace etl { class LoadBalancer; @@ -194,6 +194,25 @@ struct AccountCursor { } }; +/** + * @brief A delegate object used filter account_tx by specific delegate accounts + */ +struct DelegateFilter { + /** + * @brief A delegate type used in delegate filter + */ + enum class Role { + Delegatee, /**< This account is the *active* sender, acting on behalf of another party. + * e.g., Account A in "A sends payment to B on behalf of C." */ + + Delegator /**< This account is the *passive* party whose funds are being moved from. + * e.g., Account C in "A sends payment to B on behalf of C." */ + }; + + Role delegateType; + std::optional counterParty; +}; + /** * @brief Convert an empty output to a JSON object * diff --git a/src/rpc/common/Validators.cpp b/src/rpc/common/Validators.cpp index cb458252c8..f67973b355 100644 --- a/src/rpc/common/Validators.cpp +++ b/src/rpc/common/Validators.cpp @@ -357,4 +357,26 @@ CustomValidator CustomValidators::authorizeCredentialValidator = return MaybeError{}; }}; +CustomValidator CustomValidators::delegateValidator = + CustomValidator{[](boost::json::value const& value, std::string_view key) -> MaybeError { + if (!value.is_object()) + return Error{Status{RippledError::rpcINVALID_PARAMS, std::string(key) + " not object"}}; + + auto const& delegate = value.as_object(); + if (!delegate.contains("delegate_filter")) + return Error{Status{RippledError::rpcINVALID_PARAMS, "Field 'delegate_filter' is required but missing."}}; + + if (!parseDelegateType(delegate.at("delegate_filter")).has_value()) + return Error{Status{ + RippledError::rpcINVALID_PARAMS, "Field 'delegate_filter' value must be 'delegator' or 'delegatee'." + }}; + + if (delegate.contains("counterparty") && !accountValidator.verify(delegate, "counterparty")) + return Error{ + Status{RippledError::rpcINVALID_PARAMS, "Field 'counterparty' value must be a valid account."} + }; + + return MaybeError{}; + }}; + } // namespace rpc::validation diff --git a/src/rpc/common/Validators.hpp b/src/rpc/common/Validators.hpp index 633a6f6c93..e6d4b427db 100644 --- a/src/rpc/common/Validators.hpp +++ b/src/rpc/common/Validators.hpp @@ -592,6 +592,13 @@ struct CustomValidators final { * Used by AuthorizeCredentialValidator in deposit_preauth. */ static CustomValidator credentialTypeValidator; + + /** + * @brief Provides a validator for validating filtering by delegation. + * + * Used by account_tx if user wants to filter by delegation. + */ + static CustomValidator delegateValidator; }; /** diff --git a/src/rpc/filters/TransactionFilter.hpp b/src/rpc/filters/TransactionFilter.hpp new file mode 100644 index 0000000000..1872ec1431 --- /dev/null +++ b/src/rpc/filters/TransactionFilter.hpp @@ -0,0 +1,54 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2025, the clio developers. + + Permission to use, copy, modify, and 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 "data/Types.hpp" + +#include + +#include + +namespace rpc { + +/** + * @brief Result of a filter check. + */ +struct FilterResult { + bool shouldInclude; + std::optional relevantAccount; +}; + +/** + * @brief Interface for filtering transactions. + */ +class TransactionFilter { +public: + virtual ~TransactionFilter() = default; + + /** + * @brief Check if a transaction blob matches the filter criteria. + * @param txnPlusMeta The transaction and metadata blob from the backend. + * @return FilterResult indicating if the txn should be included in the output Json or not + */ + [[nodiscard]] virtual FilterResult + check(data::TransactionAndMetadata const& txnPlusMeta) const = 0; +}; + +} // namespace rpc diff --git a/src/rpc/filters/impl/DelegateTransactionsFilter.cpp b/src/rpc/filters/impl/DelegateTransactionsFilter.cpp new file mode 100644 index 0000000000..7160c0e275 --- /dev/null +++ b/src/rpc/filters/impl/DelegateTransactionsFilter.cpp @@ -0,0 +1,84 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2025, the clio developers. + + Permission to use, copy, modify, and 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 "rpc/filters/impl/DelegateTransactionsFilter.hpp" + +#include "data/Types.hpp" +#include "rpc/common/Types.hpp" +#include "rpc/filters/TransactionFilter.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace rpc { + +DelegateTransactionFilter::DelegateTransactionFilter(rpc::DelegateFilter filter, ripple::AccountID queriedAccount) + : delegateFilter_(std::move(filter)), queriedAccount_(queriedAccount) +{ + if (delegateFilter_.counterParty) + counterparty_ = ripple::parseBase58(*delegateFilter_.counterParty); +} + +FilterResult +DelegateTransactionFilter::check(data::TransactionAndMetadata const& txnPlusMeta) const +{ + ripple::SerialIter sit{txnPlusMeta.transaction.data(), txnPlusMeta.transaction.size()}; + ripple::STTx const sttx{sit}; + + // The account where the funds are withdrawn is always delegator + auto const txAccount = sttx.getAccountID(ripple::sfAccount); + + std::optional txDelegate; + if (sttx.isFieldPresent(ripple::sfDelegate)) + txDelegate = sttx.getAccountID(ripple::sfDelegate); + + // txn with no delegate filter should return immediately + // Note: should already have been checked in handler code before calling this function though + if (not txDelegate.has_value()) + return {.shouldInclude = false, .relevantAccount = std::nullopt}; + + // Filter by "Delegator" ie. User wants to find the Owner. + // This implies the user must be the Delegatee that acted on someone's behalf. + if (delegateFilter_.delegateType == rpc::DelegateFilter::Role::Delegator) { + if (*txDelegate == queriedAccount_) { + if (!counterparty_ || *counterparty_ == txAccount) + return {.shouldInclude = true, .relevantAccount = txAccount}; + } + } + + // Filter by "Delegatee" ie. User wants to find the Signer who acted on behalf of the user. + // This implies the user must be the delegator. + else if (delegateFilter_.delegateType == rpc::DelegateFilter::Role::Delegatee) { + if (txAccount == queriedAccount_) { + if (!counterparty_ || *counterparty_ == *txDelegate) + return {.shouldInclude = true, .relevantAccount = txDelegate}; + } + } + + return {.shouldInclude = false, .relevantAccount = std::nullopt}; +} + +} // namespace rpc diff --git a/src/rpc/filters/impl/DelegateTransactionsFilter.hpp b/src/rpc/filters/impl/DelegateTransactionsFilter.hpp new file mode 100644 index 0000000000..bfd91b8950 --- /dev/null +++ b/src/rpc/filters/impl/DelegateTransactionsFilter.hpp @@ -0,0 +1,53 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2025, the clio developers. + + Permission to use, copy, modify, and 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 "data/Types.hpp" +#include "rpc/common/Types.hpp" +#include "rpc/filters/TransactionFilter.hpp" + +#include + +#include + +namespace rpc { + +/** + * @brief Delegate transaction filter to filter txn based on permission delegate + */ +class DelegateTransactionFilter : public TransactionFilter { +public: + /** + * @brief Construct a new delegate transaction filter + * @param filter The filter parameters from the JSON request (role, counterparty string) + * @param queriedAccount The account currently being queried in account_tx (input from account_tx handler) + */ + DelegateTransactionFilter(rpc::DelegateFilter filter, ripple::AccountID queriedAccount); + + FilterResult + check(data::TransactionAndMetadata const& txnPlusMeta) const override; + +private: + rpc::DelegateFilter delegateFilter_; + ripple::AccountID queriedAccount_; + std::optional counterparty_; +}; + +} // namespace rpc diff --git a/src/rpc/handlers/AccountTx.cpp b/src/rpc/handlers/AccountTx.cpp index ad82919e6c..34a0d7c98d 100644 --- a/src/rpc/handlers/AccountTx.cpp +++ b/src/rpc/handlers/AccountTx.cpp @@ -25,6 +25,7 @@ #include "rpc/RPCHelpers.hpp" #include "rpc/common/JsonBool.hpp" #include "rpc/common/Types.hpp" +#include "rpc/filters/impl/DelegateTransactionsFilter.hpp" #include "util/Assert.hpp" #include "util/JsonUtils.hpp" #include "util/Profiler.hpp" @@ -119,6 +120,12 @@ AccountTxHandler::process(AccountTxHandler::Input const& input, Context const& c } } + std::optional txFilter; + if (input.delegateFilter) { + auto const accountID = accountFromStringStrict(input.account); + txFilter.emplace(*input.delegateFilter, *accountID); + } + auto const limit = input.limit.value_or(kLIMIT_DEFAULT); auto const accountID = accountFromStringStrict(input.account); auto const [txnsAndCursor, timeDiff] = util::timed([&]() { @@ -145,6 +152,15 @@ AccountTxHandler::process(AccountTxHandler::Input const& input, Context const& c continue; } + std::optional relevantAccount; + if (txFilter) { + auto const result = txFilter->check(txnPlusMeta); + if (not result.shouldInclude) + continue; + + relevantAccount = result.relevantAccount; + } + boost::json::object obj; // if binary is false or transactionType is specified, we need to expand the transaction @@ -191,6 +207,15 @@ AccountTxHandler::process(AccountTxHandler::Input const& input, Context const& c obj[JS(close_time_iso)] = ripple::to_string_iso(ledgerHeader->closeTime); } } + + if (relevantAccount) { + if (input.delegateFilter->delegateType == rpc::DelegateFilter::Role::Delegator) { + obj["delegator"] = ripple::to_string(*relevantAccount); + } else { + obj["delegatee"] = ripple::to_string(*relevantAccount); + } + } + obj[JS(validated)] = true; response.transactions.push_back(std::move(obj)); continue; @@ -286,6 +311,10 @@ tag_invoke(boost::json::value_to_tag, boost::json::valu if (jsonObject.contains("tx_type")) input.transactionTypeInLowercase = boost::json::value_to(jsonObject.at("tx_type")); + if (jsonObject.contains("delegate")) { + input.delegateFilter = parseDelegateFilter(jsonObject.at("delegate").as_object()); + } + return input; } diff --git a/src/rpc/handlers/AccountTx.hpp b/src/rpc/handlers/AccountTx.hpp index e29bb4355b..1815c35256 100644 --- a/src/rpc/handlers/AccountTx.hpp +++ b/src/rpc/handlers/AccountTx.hpp @@ -103,6 +103,7 @@ class AccountTxHandler { std::optional limit; std::optional marker; std::optional transactionTypeInLowercase; + std::optional delegateFilter; }; using Result = HandlerReturnType; @@ -157,6 +158,7 @@ class AccountTxHandler { modifiers::ToLower{}, validation::OneOf(typesKeysInLowercase.cbegin(), typesKeysInLowercase.cend()), }, + {"delegate", validation::CustomValidators::delegateValidator} }; static auto const kRPC_SPEC = RpcSpec{ diff --git a/tests/common/util/MockBackendTestFixture.hpp b/tests/common/util/MockBackendTestFixture.hpp index 06c84e3722..8489d0225a 100644 --- a/tests/common/util/MockBackendTestFixture.hpp +++ b/tests/common/util/MockBackendTestFixture.hpp @@ -20,7 +20,6 @@ #pragma once #include "data/BackendInterface.hpp" -#include "util/LoggerFixtures.hpp" #include "util/MockBackend.hpp" #include "util/config/ConfigDefinition.hpp" diff --git a/tests/common/util/TestObject.cpp b/tests/common/util/TestObject.cpp index 2db24dffd8..73d0d0328f 100644 --- a/tests/common/util/TestObject.cpp +++ b/tests/common/util/TestObject.cpp @@ -43,7 +43,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -1800,3 +1802,27 @@ createVault( return vault; } + +ripple::Blob +createDelegateBlob(std::string_view owner, std::string_view delegate) +{ + ripple::STObject obj(ripple::sfTransaction); + obj.setFieldU16(ripple::sfTransactionType, ripple::ttPAYMENT); + + if (auto const acc = ripple::parseBase58(std::string(owner))) { + obj.setAccountID(ripple::sfAccount, *acc); + obj.setAccountID(ripple::sfDestination, *acc); + } + if (auto const acc = ripple::parseBase58(std::string(delegate))) + obj.setAccountID(ripple::sfDelegate, *acc); + + obj.setFieldAmount(ripple::sfAmount, ripple::STAmount(100)); + obj.setFieldAmount(ripple::sfFee, ripple::STAmount(10)); + obj.setFieldU32(ripple::sfSequence, 1); + obj.setFieldVL(ripple::sfSigningPubKey, ripple::Slice(nullptr, 0)); + + ripple::STTx tx(std::move(obj)); + ripple::Serializer s; + tx.add(s); + return s.getData(); +} diff --git a/tests/common/util/TestObject.hpp b/tests/common/util/TestObject.hpp index e06fa62b2e..27fd56061c 100644 --- a/tests/common/util/TestObject.hpp +++ b/tests/common/util/TestObject.hpp @@ -581,3 +581,6 @@ createVault( ripple::uint256 previousTxId, uint32_t previousTxSeq ); + +[[nodiscard]] ripple::Blob +createDelegateBlob(std::string_view owner, std::string_view delegate); diff --git a/tests/integration/data/cassandra/BackendTests.cpp b/tests/integration/data/cassandra/BackendTests.cpp index 04909575fd..ff889f043c 100644 --- a/tests/integration/data/cassandra/BackendTests.cpp +++ b/tests/integration/data/cassandra/BackendTests.cpp @@ -709,7 +709,7 @@ TEST_F(BackendCassandraTest, Basic) auto retTxns = backend_->fetchAllTransactionsInLedger(seq, yield); for (auto [hash, txn, meta] : txns) { bool found = false; - for (auto [retTxn, retMeta, retSeq, retDate] : retTxns) { + for (auto [retTxn, retMeta, retSeq, retDate, retFilter] : retTxns) { if (std::strncmp( reinterpret_cast(retTxn.data()), static_cast(txn.data()), @@ -738,8 +738,8 @@ TEST_F(BackendCassandraTest, Basic) } while (cursor); EXPECT_EQ(retData.size(), data.size()); for (size_t i = 0; i < retData.size(); ++i) { - auto [txn, meta, _, _2] = retData[i]; - auto [_3, expTxn, expMeta] = data[i]; + auto [txn, meta, _, _2, _3] = retData[i]; + auto [_4, expTxn, expMeta] = data[i]; EXPECT_STREQ(reinterpret_cast(txn.data()), static_cast(expTxn.data())); EXPECT_STREQ(reinterpret_cast(meta.data()), static_cast(expMeta.data())); } diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index fe15f63305..f9f8836a15 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -90,6 +90,7 @@ target_sources( rpc/common/SpecsTests.cpp rpc/common/TypesTests.cpp rpc/common/impl/HandlerProviderTests.cpp + rpc/filters/impl/DelegateTransactionsFilterTests.cpp rpc/handlers/AccountChannelsTests.cpp rpc/handlers/AccountCurrenciesTests.cpp rpc/handlers/AccountInfoTests.cpp diff --git a/tests/unit/rpc/RPCHelpersTests.cpp b/tests/unit/rpc/RPCHelpersTests.cpp index 40189b9b66..c3c5b76677 100644 --- a/tests/unit/rpc/RPCHelpersTests.cpp +++ b/tests/unit/rpc/RPCHelpersTests.cpp @@ -603,6 +603,98 @@ TEST_F(RPCHelpersTest, FetchAndCheckAnyFlagExists_TrustLineIsFrozenAndCheckFreez }); } +TEST_F(RPCHelpersTest, ParseDelegateType) +{ + auto result = parseDelegateType(boost::json::value("delegator")); + EXPECT_TRUE(result.has_value()); + EXPECT_EQ(*result, DelegateFilter::Role::Delegator); + + result = parseDelegateType(boost::json::value("delegatee")); + EXPECT_TRUE(result.has_value()); + EXPECT_EQ(*result, DelegateFilter::Role::Delegatee); + + // invalid types + result = parseDelegateType(boost::json::value("invalid_type")); + EXPECT_FALSE(result.has_value()); + + result = parseDelegateType(boost::json::value(123)); + EXPECT_FALSE(result.has_value()); + + result = parseDelegateType(boost::json::value(true)); + EXPECT_FALSE(result.has_value()); +} + +TEST_F(RPCHelpersTest, ParseDelegateFilter_Success) +{ + // only delegate agent is valid + { + auto const json = boost::json::parse(R"JSON({ + "delegate_filter": "delegator" + })JSON") + .as_object(); + + auto const result = parseDelegateFilter(json); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->delegateType, DelegateFilter::Role::Delegator); + EXPECT_FALSE(result->counterParty.has_value()); + } + + // delegate agent + counterparty is valid + { + auto const json = boost::json::parse(R"JSON({ + "delegate_filter": "delegatee", + "counterparty": "rLEsXccBGNR3UPuPu2hUXPjziKC3qKSBun" + })JSON") + .as_object(); + + auto const result = parseDelegateFilter(json); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->delegateType, DelegateFilter::Role::Delegatee); + ASSERT_TRUE(result->counterParty.has_value()); + EXPECT_EQ(*result->counterParty, "rLEsXccBGNR3UPuPu2hUXPjziKC3qKSBun"); + } +} + +TEST_F(RPCHelpersTest, ParseDelegateFilter_Failures) +{ + // Missing required "delegate_filter" key + { + auto const json = boost::json::parse(R"JSON({ + "counterparty": "rLEsXccBGNR3UPuPu2hUXPjziKC3qKSBun" + })JSON") + .as_object(); + EXPECT_FALSE(parseDelegateFilter(json).has_value()); + } + + // "delegate_filter" is not a string (it's an integer) + { + auto const json = boost::json::parse(R"JSON({ + "delegate_filter": 123 + })JSON") + .as_object(); + EXPECT_FALSE(parseDelegateFilter(json).has_value()); + } + + // "delegate_filter" is a string but invalid value + { + auto const json = boost::json::parse(R"JSON({ + "delegate_filter": "random_string" + })JSON") + .as_object(); + EXPECT_FALSE(parseDelegateFilter(json).has_value()); + } + + // "counterparty" exists but is not a string (it's a number) + { + auto const json = boost::json::parse(R"JSON({ + "delegate_filter": "delegator", + "counterparty": 9999 + })JSON") + .as_object(); + EXPECT_FALSE(parseDelegateFilter(json).has_value()); + } +} + TEST_F(RPCHelpersTest, isGlobalFrozen_AccountIsGlobalFrozen) { auto const account = getAccountIdWithString(kACCOUNT); diff --git a/tests/unit/rpc/filters/impl/DelegateTransactionsFilterTests.cpp b/tests/unit/rpc/filters/impl/DelegateTransactionsFilterTests.cpp new file mode 100644 index 0000000000..5a69a3b957 --- /dev/null +++ b/tests/unit/rpc/filters/impl/DelegateTransactionsFilterTests.cpp @@ -0,0 +1,196 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2025, the clio developers. + + Permission to use, copy, modify, and 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 "data/Types.hpp" +#include "rpc/common/Types.hpp" +#include "rpc/filters/impl/DelegateTransactionsFilter.hpp" +#include "util/TestObject.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +using namespace rpc; +using namespace ripple; + +namespace { +auto const kACCOUNT_OWNER = *parseBase58("rnrx6w8Z2VJERMMpk9jv9Y2YZKTekFAZaK"); +auto const kACCOUNT_DELEGATOR = *parseBase58("rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh"); +auto const kACCOUNT_DESTINATION = *parseBase58("rMAXACCrp3Y8PpswXcg3bKggHX76V3F8M4"); +auto constexpr kMAX_SEQ = 30u; +} // namespace + +class DelegateTransactionFilterTest : public ::testing::Test { +protected: + static data::TransactionAndMetadata + createBlob(std::string_view owner, std::string_view delegate) + { + data::TransactionAndMetadata ret; + ret.transaction = createDelegateBlob(owner, delegate); + ret.ledgerSequence = kMAX_SEQ; + return ret; + } +}; + +TEST_F(DelegateTransactionFilterTest, ReturnsFalseIfNoDelegateField) +{ + DelegateFilter filterParams{.delegateType = DelegateFilter::Role::Delegator, .counterParty = std::nullopt}; + DelegateTransactionFilter filter(filterParams, kACCOUNT_OWNER); + + // Create standard tx (no delegate field) using standard TestObject helper + auto obj = createPaymentTransactionObject(to_string(kACCOUNT_OWNER), to_string(kACCOUNT_DESTINATION), 100, 10, 1); + + STTx tx(std::move(obj)); + Serializer s; + tx.add(s); + + data::TransactionAndMetadata blob; + blob.transaction = s.getData(); + + auto const& result = filter.check(blob); + EXPECT_FALSE(result.shouldInclude); +} + +TEST_F(DelegateTransactionFilterTest, RoleDelegator_MatchesWhenUserIsSigner) +{ + // I am Account B (Signer). I want to see transactions where I acted as delegator (signed for someone). + DelegateFilter filterParams{.delegateType = DelegateFilter::Role::Delegator, .counterParty = std::nullopt}; + DelegateTransactionFilter filter(filterParams, kACCOUNT_DELEGATOR); + + // Tx: Owner/delegator=A, Signer/delegatee=B + auto blob = createBlob(to_string(kACCOUNT_OWNER), to_string(kACCOUNT_DELEGATOR)); + + auto const& result = filter.check(blob); + EXPECT_TRUE(result.shouldInclude); + ASSERT_TRUE(result.relevantAccount.has_value()); + EXPECT_EQ(*result.relevantAccount, kACCOUNT_OWNER); +} + +TEST_F(DelegateTransactionFilterTest, RoleDelegator_FailsWhenUserIsNotSigner) +{ + // I am Account C. I query for Delegator work. + DelegateFilter filterParams{.delegateType = DelegateFilter::Role::Delegator, .counterParty = std::nullopt}; + DelegateTransactionFilter filter(filterParams, kACCOUNT_DESTINATION); + + // Tx: Owner/delegator=A, Signer/delegatee=B (C is not involved) + auto blob = createBlob(to_string(kACCOUNT_OWNER), to_string(kACCOUNT_DELEGATOR)); + + auto const& result = filter.check(blob); + EXPECT_FALSE(result.shouldInclude); +} + +TEST_F(DelegateTransactionFilterTest, RoleDelegator_WithCounterparty_Match) +{ + // I am Account B (Signer). I want to see work I did specifically for Account A. + DelegateFilter filterParams{ + .delegateType = DelegateFilter::Role::Delegator, .counterParty = to_string(kACCOUNT_OWNER) + }; + DelegateTransactionFilter filter(filterParams, kACCOUNT_DELEGATOR); + + // Tx: Owner/delegator=A, Signer/delegatee=B + auto blob = createBlob(to_string(kACCOUNT_OWNER), to_string(kACCOUNT_DELEGATOR)); + + auto const& result = filter.check(blob); + EXPECT_TRUE(result.shouldInclude); + EXPECT_EQ(*result.relevantAccount, kACCOUNT_OWNER); +} + +TEST_F(DelegateTransactionFilterTest, RoleDelegator_WithCounterparty_Mismatch) +{ + // I am Account B (Signer). I want to see work I did for Account C. + DelegateFilter filterParams{ + .delegateType = DelegateFilter::Role::Delegator, .counterParty = to_string(kACCOUNT_DESTINATION) + }; + DelegateTransactionFilter filter(filterParams, kACCOUNT_DELEGATOR); + + // Tx: Owner/delegator=A, Signer/delegatee=B (Owner A != Counterparty C) + auto blob = createBlob(to_string(kACCOUNT_OWNER), to_string(kACCOUNT_DELEGATOR)); + + auto const& result = filter.check(blob); + EXPECT_FALSE(result.shouldInclude); +} + +TEST_F(DelegateTransactionFilterTest, RoleDelegatee_MatchesWhenUserIsOwner) +{ + // I am Account A (Owner). I want to see who signed for me. + DelegateFilter filterParams{.delegateType = DelegateFilter::Role::Delegatee, .counterParty = std::nullopt}; + DelegateTransactionFilter filter(filterParams, kACCOUNT_OWNER); + + // Tx: Owner/delegator=A, Signer/delegatee=B + auto blob = createBlob(to_string(kACCOUNT_OWNER), to_string(kACCOUNT_DELEGATOR)); + + auto const& result = filter.check(blob); + EXPECT_TRUE(result.shouldInclude); + ASSERT_TRUE(result.relevantAccount.has_value()); + EXPECT_EQ(*result.relevantAccount, kACCOUNT_DELEGATOR); // Should return Signer +} + +TEST_F(DelegateTransactionFilterTest, RoleDelegatee_FailsWhenUserIsNotOwner) +{ + // I am Account C. I query for Delegatee work. + DelegateFilter filterParams{.delegateType = DelegateFilter::Role::Delegatee, .counterParty = std::nullopt}; + DelegateTransactionFilter filter(filterParams, kACCOUNT_DESTINATION); + + // Tx: Owner/delegator=A, Signer/delegatee=B (C is not involved) + auto blob = createBlob(to_string(kACCOUNT_OWNER), to_string(kACCOUNT_DELEGATOR)); + + auto const& result = filter.check(blob); + EXPECT_FALSE(result.shouldInclude); +} + +TEST_F(DelegateTransactionFilterTest, RoleDelegatee_WithCounterparty_Match) +{ + // I am Account A (Owner). I want to see work signed specifically by B. + DelegateFilter filterParams{ + .delegateType = DelegateFilter::Role::Delegatee, .counterParty = to_string(kACCOUNT_DELEGATOR) + }; + DelegateTransactionFilter filter(filterParams, kACCOUNT_OWNER); + + // Tx: Owner/delegator=A, Signer/delegatee=B + auto blob = createBlob(to_string(kACCOUNT_OWNER), to_string(kACCOUNT_DELEGATOR)); + + auto const& result = filter.check(blob); + EXPECT_TRUE(result.shouldInclude); + EXPECT_EQ(*result.relevantAccount, kACCOUNT_DELEGATOR); +} + +TEST_F(DelegateTransactionFilterTest, RoleDelegatee_WithCounterparty_Mismatch) +{ + // I am Account A (Owner). I want to see work signed by C. + DelegateFilter filterParams{ + .delegateType = DelegateFilter::Role::Delegatee, .counterParty = to_string(kACCOUNT_DESTINATION) + }; + DelegateTransactionFilter filter(filterParams, kACCOUNT_OWNER); + + // Tx: Owner/delegator=A, Signer/delegatee=B (Signer B != Counterparty C) + auto blob = createBlob(to_string(kACCOUNT_OWNER), to_string(kACCOUNT_DELEGATOR)); + + auto const& result = filter.check(blob); + EXPECT_FALSE(result.shouldInclude); +} diff --git a/tests/unit/rpc/handlers/AccountTxTests.cpp b/tests/unit/rpc/handlers/AccountTxTests.cpp index 20c6d9838b..cfb2459673 100644 --- a/tests/unit/rpc/handlers/AccountTxTests.cpp +++ b/tests/unit/rpc/handlers/AccountTxTests.cpp @@ -32,6 +32,7 @@ #include #include #include +#include #include #include @@ -398,6 +399,56 @@ struct AccountTxParameterTest : public RPCAccountTxHandlerTest, })JSON", .expectedError = "invalidParams", .expectedErrorMessage = "Invalid field 'tx_type'." + }, + AccountTxParamTestCaseBundle{ + .testName = "DelegateNotObject", + .testJson = R"JSON({ + "account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", + "delegate": 123 + })JSON", + .expectedError = "invalidParams", + .expectedErrorMessage = "delegate not object" + }, + AccountTxParamTestCaseBundle{ + .testName = "DelegateFilterMissing", + .testJson = R"JSON({ + "account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", + "delegate": { "other_field": "value" } + })JSON", + .expectedError = "invalidParams", + .expectedErrorMessage = "Field 'delegate_filter' is required but missing." + }, + AccountTxParamTestCaseBundle{ + .testName = "DelegateFilterInvalidValue", + .testJson = R"JSON({ + "account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", + "delegate": { "delegate_filter": "invalid_mode" } + })JSON", + .expectedError = "invalidParams", + .expectedErrorMessage = "Field 'delegate_filter' value must be 'delegator' or 'delegatee'." + }, + AccountTxParamTestCaseBundle{ + .testName = "DelegateCounterpartyInvalid", + .testJson = R"JSON({ + "account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", + "delegate": { + "delegate_filter": "delegatee", + "counterparty": "not_an_account" + } + })JSON", + .expectedError = "invalidParams", + .expectedErrorMessage = "Field 'counterparty' value must be a valid account." + }, + AccountTxParamTestCaseBundle{ + .testName = "DelegateOnlyCounterparty", + .testJson = R"JSON({ + "account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", + "delegate": { + "counterparty": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn" + } + })JSON", + .expectedError = "invalidParams", + .expectedErrorMessage = "Field 'delegate_filter' is required but missing." } }; }; @@ -1160,6 +1211,84 @@ TEST_F(RPCAccountTxHandlerTest, TxLargerThanMaxSeq) }); } +TEST_F(RPCAccountTxHandlerTest, WithDelegateAgent) +{ + auto transactions = genTransactions(kMIN_SEQ + 1, kMAX_SEQ - 1); + + for (auto& txn : transactions) { + txn.transaction = createDelegateBlob(kACCOUNT2, kACCOUNT); + } + + auto const transCursor = TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; + EXPECT_CALL(*backend_, fetchAccountTransactions(testing::_, testing::_, false, testing::_, testing::_)) + .WillOnce(Return(transCursor)); + + ON_CALL(*mockETLServicePtr_, getETLState).WillByDefault(Return(etl::ETLState{})); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{AccountTxHandler{backend_, mockETLServicePtr_}}; + static auto const kINPUT = json::parse(R"JSON({ + "account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", + "delegate": { + "delegate_filter": "delegator" + } + })JSON"); + + auto const output = handler.process(kINPUT, Context{yield}); + ASSERT_TRUE(output); + + EXPECT_EQ(output.result->at("account").as_string(), kACCOUNT); + auto const& txs = output.result->at("transactions").as_array(); + ASSERT_EQ(txs.size(), 2); + + // Check the transactions contains delegator + EXPECT_TRUE(txs[0].as_object().contains("delegator")); + EXPECT_TRUE(txs[1].as_object().contains("delegator")); + }); +} + +TEST_F(RPCAccountTxHandlerTest, WithDelegateFromAndCounterparty) +{ + auto transactions = genTransactions(kMIN_SEQ + 1, kMAX_SEQ - 1); + auto constexpr kCOUNTERPARTY = "rLEsXccBGNR3UPuPu2hUXPjziKC3qKSBun"; + + for (auto& txn : transactions) { + txn.transaction = createDelegateBlob(kACCOUNT, kCOUNTERPARTY); + } + + auto const transCursor = TransactionsAndCursor{.txns = transactions, .cursor = TransactionsCursor{12, 34}}; + + EXPECT_CALL(*backend_, fetchAccountTransactions(testing::_, testing::_, false, testing::_, testing::_)) + .WillOnce(Return(transCursor)); + + ON_CALL(*mockETLServicePtr_, getETLState).WillByDefault(Return(etl::ETLState{})); + + runSpawn([&, this](auto yield) { + auto const handler = AnyHandler{AccountTxHandler{backend_, mockETLServicePtr_}}; + static auto const kINPUT = json::parse( + fmt::format( + R"JSON({{ + "account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", + "delegate": {{ + "delegate_filter": "delegatee", + "counterparty": "{}" + }} + }})JSON", + kCOUNTERPARTY + ) + ); + + auto const output = handler.process(kINPUT, Context{yield}); + ASSERT_TRUE(output); + + auto const& txs = output.result->at("transactions").as_array(); + ASSERT_EQ(txs.size(), 2); + + EXPECT_TRUE(txs[0].as_object().contains("delegatee")); + EXPECT_EQ(txs[0].at("delegatee").as_string(), kCOUNTERPARTY); + }); +} + TEST_F(RPCAccountTxHandlerTest, NFTTxs_API_v1) { auto const out = R"JSON({