diff --git a/src/data/Types.hpp b/src/data/Types.hpp index fa3ad7b7f..43d657270 100644 --- a/src/data/Types.hpp +++ b/src/data/Types.hpp @@ -51,6 +51,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 1583548d2..42013c9a8 100644 --- a/src/data/cassandra/CassandraBackendFamily.hpp +++ b/src/data/cassandra/CassandraBackendFamily.hpp @@ -21,12 +21,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 a23ed71af..1f4c59908 100644 --- a/src/rpc/CMakeLists.txt +++ b/src/rpc/CMakeLists.txt @@ -21,6 +21,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 0e48d800f..fdc0a0499 100644 --- a/src/rpc/RPCHelpers.cpp +++ b/src/rpc/RPCHelpers.cpp @@ -1653,4 +1653,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::Authorizer; + if (type == "delegatee") + return DelegateFilter::Role::Actor; + + 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 231513c35..84e2283ab 100644 --- a/src/rpc/RPCHelpers.hpp +++ b/src/rpc/RPCHelpers.hpp @@ -851,4 +851,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 fbf190de1..a1c6ab727 100644 --- a/src/rpc/common/Types.hpp +++ b/src/rpc/common/Types.hpp @@ -14,9 +14,9 @@ #include #include +#include #include #include -#include namespace etl { class LoadBalancer; @@ -175,6 +175,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 { + Actor, /**< 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." */ + + Authorizer /**< 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 a745bf342..842ab4bf8 100644 --- a/src/rpc/common/Validators.cpp +++ b/src/rpc/common/Validators.cpp @@ -384,4 +384,30 @@ 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 c4e053d88..4875431e9 100644 --- a/src/rpc/common/Validators.hpp +++ b/src/rpc/common/Validators.hpp @@ -593,6 +593,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 000000000..1872ec143 --- /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 000000000..49496b43b --- /dev/null +++ b/src/rpc/filters/impl/DelegateTransactionsFilter.cpp @@ -0,0 +1,87 @@ +//------------------------------------------------------------------------------ +/* + 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::Authorizer) { + 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::Actor) { + 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 000000000..557139b33 --- /dev/null +++ b/src/rpc/filters/impl/DelegateTransactionsFilter.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 "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 985e1d06f..976f8d936 100644 --- a/src/rpc/handlers/AccountTx.cpp +++ b/src/rpc/handlers/AccountTx.cpp @@ -6,6 +6,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" @@ -109,6 +110,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([&]() { @@ -138,6 +145,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 @@ -188,6 +204,16 @@ 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::Authorizer) { + 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; @@ -296,6 +322,10 @@ tag_invoke(boost::json::value_to_tag, boost::json::valu 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 b26b8be18..81e4e1864 100644 --- a/src/rpc/handlers/AccountTx.hpp +++ b/src/rpc/handlers/AccountTx.hpp @@ -85,6 +85,7 @@ class AccountTxHandler { std::optional limit; std::optional marker; std::optional transactionTypeInLowercase; + std::optional delegateFilter; }; using Result = HandlerReturnType; @@ -141,6 +142,7 @@ class AccountTxHandler { 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 b4f383886..a71c5bc6b 100644 --- a/tests/common/util/MockBackendTestFixture.hpp +++ b/tests/common/util/MockBackendTestFixture.hpp @@ -1,7 +1,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 71d1f71ad..394abf69e 100644 --- a/tests/common/util/TestObject.cpp +++ b/tests/common/util/TestObject.cpp @@ -24,7 +24,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -1854,6 +1856,30 @@ 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(); +} + ripple::STObject createLoanBroker( std::string_view owner, diff --git a/tests/common/util/TestObject.hpp b/tests/common/util/TestObject.hpp index d22b1d7b1..0ba43e066 100644 --- a/tests/common/util/TestObject.hpp +++ b/tests/common/util/TestObject.hpp @@ -589,6 +589,9 @@ createVault( uint32_t previousTxSeq ); +[[nodiscard]] ripple::Blob +createDelegateBlob(std::string_view owner, std::string_view delegate); + [[nodiscard]] ripple::STObject createLoanBroker( std::string_view owner, diff --git a/tests/integration/data/cassandra/BackendTests.cpp b/tests/integration/data/cassandra/BackendTests.cpp index 2309c6fd3..a354096b7 100644 --- a/tests/integration/data/cassandra/BackendTests.cpp +++ b/tests/integration/data/cassandra/BackendTests.cpp @@ -745,7 +745,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()), @@ -776,8 +776,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()) diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index df88b91b9..92f5519ba 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -100,6 +100,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 e00c13481..9635fa361 100644 --- a/tests/unit/rpc/RPCHelpersTests.cpp +++ b/tests/unit/rpc/RPCHelpersTests.cpp @@ -614,6 +614,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::Authorizer); + + result = parseDelegateType(boost::json::value("delegatee")); + EXPECT_TRUE(result.has_value()); + EXPECT_EQ(*result, DelegateFilter::Role::Actor); + + // 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::Authorizer); + 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::Actor); + 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 000000000..c2d21995a --- /dev/null +++ b/tests/unit/rpc/filters/impl/DelegateTransactionsFilterTests.cpp @@ -0,0 +1,210 @@ +//------------------------------------------------------------------------------ +/* + 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::Authorizer, .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::Authorizer, .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::Authorizer, .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::Authorizer, .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::Authorizer, + .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::Actor, .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::Actor, .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::Actor, .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::Actor, .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 ed03794db..f1564432a 100644 --- a/tests/unit/rpc/handlers/AccountTxTests.cpp +++ b/tests/unit/rpc/handlers/AccountTxTests.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include @@ -392,6 +393,57 @@ 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." } }; }; @@ -1211,6 +1263,90 @@ 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({