diff --git a/src/rpc/CMakeLists.txt b/src/rpc/CMakeLists.txt index 9691de44d..fd9055f78 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 9b3600588..e4b7188e7 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 == JS(authorizer)) + return DelegateFilter::Role::Authorizer; + if (type == JS(actor)) + return DelegateFilter::Role::Actor; + + return {}; +} + +std::optional +parseDelegateFilter(boost::json::object const& delegateObject) +{ + DelegateFilter delegate{}; + if (!delegateObject.contains(JS(delegate_filter))) + return {}; + + auto const& filterVal = delegateObject.at(JS(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(JS(counter_party))) { + auto const& counterpartyVal = delegateObject.at(JS(counter_party)); + + 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 5fbeac1c1..cec23d342 100644 --- a/src/rpc/RPCHelpers.hpp +++ b/src/rpc/RPCHelpers.hpp @@ -853,4 +853,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 d2798c1e9..a7aa66385 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 = Role::Actor; + 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 9be9275a3..06edaacce 100644 --- a/src/rpc/common/Validators.cpp +++ b/src/rpc/common/Validators.cpp @@ -1,6 +1,7 @@ #include "rpc/common/Validators.hpp" #include "rpc/Errors.hpp" +#include "rpc/JS.hpp" #include "rpc/RPCHelpers.hpp" #include "rpc/common/Types.hpp" #include "util/AccountUtils.hpp" @@ -17,6 +18,7 @@ #include #include #include +#include #include #include @@ -383,4 +385,34 @@ 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::RpcInvalidParams, std::string(key) + "NotObject"}}; + + auto const& delegate = value.as_object(); + if (!delegate.contains(JS(delegate_filter))) { + return Error{Status{ + RippledError::RpcInvalidParams, "Field 'delegate_filter' is required but missing." + }}; + } + + if (!parseDelegateType(delegate.at(JS(delegate_filter))).has_value()) { + return Error{Status{ + RippledError::RpcInvalidParams, + "Field 'delegate_filter' value must be 'actor' or 'authorizer'." + }}; + } + + if (delegate.contains(JS(counter_party)) && + !accountValidator.verify(delegate, JS(counter_party))) { + return Error{Status{ + RippledError::RpcActMalformed, + "Field 'counter_party' 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 5c5cccbc3..47992d799 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..7b2a7b777 --- /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 = false; + 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..fb30c11e1 --- /dev/null +++ b/src/rpc/filters/impl/DelegateTransactionsFilter.cpp @@ -0,0 +1,85 @@ +//------------------------------------------------------------------------------ +/* + 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 + +namespace rpc { + +DelegateTransactionFilter::DelegateTransactionFilter( + rpc::DelegateFilter filter, + xrpl::AccountID queriedAccount +) + : delegateFilter_(std::move(filter)), queriedAccount_(queriedAccount) +{ + if (delegateFilter_.counterParty) + counterparty_ = xrpl::parseBase58(*delegateFilter_.counterParty); +} + +FilterResult +DelegateTransactionFilter::check(data::TransactionAndMetadata const& txnPlusMeta) const +{ + xrpl::SerialIter sit{txnPlusMeta.transaction.data(), txnPlusMeta.transaction.size()}; + xrpl::STTx const sttx{sit}; + + // The account is always the owner whose funds move; when sfDelegate is present, sfAccount is + // the "authorizer" and sfDelegate is the "actor" that signed on its behalf. + auto const txAccount = sttx.getAccountID(xrpl::sfAccount); + + std::optional txDelegate; + if (sttx.isFieldPresent(xrpl::sfDelegate)) + txDelegate = sttx.getAccountID(xrpl::sfDelegate); + + // Transactions without an sfDelegate field are not delegated; exclude them immediately. + if (not txDelegate.has_value()) + return {.shouldInclude = false, .relevantAccount = std::nullopt}; + + // Filter by "authorizer" ie. the queried account is the actor (signer) and the user wants to + // find the authorizer (owner) it acted for. + if (delegateFilter_.delegateType == rpc::DelegateFilter::Role::Authorizer) { + if (*txDelegate == queriedAccount_) { + if (!counterparty_ || *counterparty_ == txAccount) + return {.shouldInclude = true, .relevantAccount = txAccount}; + } + } + + // Filter by "actor" ie. the queried account is the authorizer (owner) and the user wants to + // find the actor (signer) that acted on its behalf. + 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..6df66c184 --- /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, xrpl::AccountID queriedAccount); + + [[nodiscard]] FilterResult + check(data::TransactionAndMetadata const& txnPlusMeta) const override; + +private: + rpc::DelegateFilter delegateFilter_; + xrpl::AccountID queriedAccount_; + std::optional counterparty_; +}; + +} // namespace rpc diff --git a/src/rpc/handlers/AccountTx.cpp b/src/rpc/handlers/AccountTx.cpp index 720529438..dfeaeb192 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" @@ -111,8 +112,17 @@ AccountTxHandler::process(AccountTxHandler::Input const& input, Context const& c } } - auto const limit = input.limit.value_or(kLimitDefault); auto const accountID = accountFromStringStrict(input.account); + + std::optional txFilter; + if (input.delegateFilter) { + txFilter.emplace( + *input.delegateFilter, + *accountID // NOLINT(bugprone-unchecked-optional-access) + ); + } + + auto const limit = input.limit.value_or(kLimitDefault); auto const [txnsAndCursor, timeDiff] = util::timed([&]() { return sharedPtrBackend_->fetchAccountTransactions( *accountID, limit, input.forward, cursor, ctx.yield @@ -140,6 +150,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 @@ -190,6 +209,17 @@ AccountTxHandler::process(AccountTxHandler::Input const& input, Context const& c obj[JS(close_time_iso)] = xrpl::toStringIso(ledgerHeader->closeTime); } } + + if (relevantAccount) { + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + if (input.delegateFilter->delegateType == + rpc::DelegateFilter::Role::Authorizer) { + obj[JS(authorizer)] = xrpl::to_string(*relevantAccount); + } else { + obj[JS(actor)] = xrpl::to_string(*relevantAccount); + } + } + obj[JS(validated)] = true; response.transactions.push_back(std::move(obj)); continue; @@ -298,6 +328,10 @@ tag_invoke(boost::json::value_to_tag, boost::json::valu boost::json::value_to(jsonObject.at("tx_type")); } + if (jsonObject.contains(JS(delegate))) { + input.delegateFilter = parseDelegateFilter(jsonObject.at(JS(delegate)).as_object()); + } + return input; } diff --git a/src/rpc/handlers/AccountTx.hpp b/src/rpc/handlers/AccountTx.hpp index d21cf5814..6834e1a99 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() ), }, + {JS(delegate), validation::CustomValidators::delegateValidator} }; static auto const kRpcSpec = 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 19fe2d95e..1ce223d31 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 @@ -1893,6 +1895,30 @@ createVault( return vault; } +xrpl::Blob +createDelegateBlob(std::string_view owner, std::string_view delegate) +{ + xrpl::STObject obj(xrpl::sfTransaction); + obj.setFieldU16(xrpl::sfTransactionType, xrpl::ttPAYMENT); + + if (auto const acc = xrpl::parseBase58(std::string(owner))) { + obj.setAccountID(xrpl::sfAccount, *acc); + obj.setAccountID(xrpl::sfDestination, *acc); + } + if (auto const acc = xrpl::parseBase58(std::string(delegate))) + obj.setAccountID(xrpl::sfDelegate, *acc); + + obj.setFieldAmount(xrpl::sfAmount, xrpl::STAmount(100)); + obj.setFieldAmount(xrpl::sfFee, xrpl::STAmount(10)); + obj.setFieldU32(xrpl::sfSequence, 1); + obj.setFieldVL(xrpl::sfSigningPubKey, xrpl::Slice(nullptr, 0)); + + xrpl::STTx const tx(std::move(obj)); + xrpl::Serializer s; + tx.add(s); + return s.getData(); +} + xrpl::STObject createLoanBroker( std::string_view owner, diff --git a/tests/common/util/TestObject.hpp b/tests/common/util/TestObject.hpp index 280f8798c..a6ea9987e 100644 --- a/tests/common/util/TestObject.hpp +++ b/tests/common/util/TestObject.hpp @@ -598,6 +598,9 @@ createVault( uint32_t previousTxSeq ); +[[nodiscard]] xrpl::Blob +createDelegateBlob(std::string_view owner, std::string_view delegate); + [[nodiscard]] xrpl::STObject createLoanBroker( std::string_view owner, diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index a5a35a406..c618d0d0c 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -101,6 +101,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 779eb3219..ae2480b03 100644 --- a/tests/unit/rpc/RPCHelpersTests.cpp +++ b/tests/unit/rpc/RPCHelpersTests.cpp @@ -616,6 +616,104 @@ TEST_F(RPCHelpersTest, FetchAndCheckAnyFlagExists_TrustLineIsFrozenAndCheckFreez }); } +TEST_F(RPCHelpersTest, ParseDelegateType) +{ + auto result = parseDelegateType(boost::json::value("authorizer")); + ASSERT_TRUE(result.has_value()); + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + EXPECT_EQ(*result, DelegateFilter::Role::Authorizer); + + result = parseDelegateType(boost::json::value("actor")); + ASSERT_TRUE(result.has_value()); + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + 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": "authorizer" + })JSON") + .as_object(); + + auto const result = parseDelegateFilter(json); + ASSERT_TRUE(result.has_value()); + // NOLINTBEGIN(bugprone-unchecked-optional-access) + EXPECT_EQ(result->delegateType, DelegateFilter::Role::Authorizer); + EXPECT_FALSE(result->counterParty.has_value()); + // NOLINTEND(bugprone-unchecked-optional-access) + } + + // delegate agent + counter_party is valid + { + auto const json = boost::json::parse(R"JSON({ + "delegate_filter": "actor", + "counter_party": "rLEsXccBGNR3UPuPu2hUXPjziKC3qKSBun" + })JSON") + .as_object(); + + auto const result = parseDelegateFilter(json); + ASSERT_TRUE(result.has_value()); + // NOLINTBEGIN(bugprone-unchecked-optional-access) + EXPECT_EQ(result->delegateType, DelegateFilter::Role::Actor); + ASSERT_TRUE(result->counterParty.has_value()); + EXPECT_EQ(*result->counterParty, "rLEsXccBGNR3UPuPu2hUXPjziKC3qKSBun"); + // NOLINTEND(bugprone-unchecked-optional-access) + } +} + +TEST_F(RPCHelpersTest, ParseDelegateFilter_Failures) +{ + // Missing required "delegate_filter" key + { + auto const json = boost::json::parse(R"JSON({ + "counter_party": "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()); + } + + // "counter_party" exists but is not a string (it's a number) + { + auto const json = boost::json::parse(R"JSON({ + "delegate_filter": "authorizer", + "counter_party": 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..f1db5c48f --- /dev/null +++ b/tests/unit/rpc/filters/impl/DelegateTransactionsFilterTests.cpp @@ -0,0 +1,212 @@ +//------------------------------------------------------------------------------ +/* + 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 + +using namespace rpc; +using namespace xrpl; + +namespace { +auto const kAccountOwner = *parseBase58("rnrx6w8Z2VJERMMpk9jv9Y2YZKTekFAZaK"); +auto const kAccountDelegator = *parseBase58("rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh"); +auto const kAccountDestination = *parseBase58("rMAXACCrp3Y8PpswXcg3bKggHX76V3F8M4"); +auto constexpr kMaxSeq = 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 = kMaxSeq; + return ret; + } +}; + +TEST_F(DelegateTransactionFilterTest, ReturnsFalseIfNoDelegateField) +{ + DelegateFilter const filterParams{ + .delegateType = DelegateFilter::Role::Authorizer, .counterParty = std::nullopt + }; + DelegateTransactionFilter const filter(filterParams, kAccountOwner); + + // Create standard tx (no delegate field) using standard TestObject helper + auto obj = createPaymentTransactionObject( + to_string(kAccountOwner), to_string(kAccountDestination), 100, 10, 1 + ); + + STTx const 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, RoleAuthorizer_MatchesWhenUserIsSigner) +{ + // I am Account B (the actor/signer). I want to see transactions where I acted as authorizer's + // delegate (signed for someone). + DelegateFilter const filterParams{ + .delegateType = DelegateFilter::Role::Authorizer, .counterParty = std::nullopt + }; + DelegateTransactionFilter const filter(filterParams, kAccountDelegator); + + // Tx: Owner/authorizer=A, Signer/actor=B + auto blob = createBlob(to_string(kAccountOwner), to_string(kAccountDelegator)); + + auto const& result = filter.check(blob); + EXPECT_TRUE(result.shouldInclude); + ASSERT_TRUE(result.relevantAccount.has_value()); + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + EXPECT_EQ(*result.relevantAccount, kAccountOwner); +} + +TEST_F(DelegateTransactionFilterTest, RoleAuthorizer_FailsWhenUserIsNotSigner) +{ + // I am Account C. I query for authorizer work. + DelegateFilter const filterParams{ + .delegateType = DelegateFilter::Role::Authorizer, .counterParty = std::nullopt + }; + DelegateTransactionFilter const filter(filterParams, kAccountDestination); + + // Tx: Owner/authorizer=A, Signer/actor=B (C is not involved) + auto blob = createBlob(to_string(kAccountOwner), to_string(kAccountDelegator)); + + auto const& result = filter.check(blob); + EXPECT_FALSE(result.shouldInclude); +} + +TEST_F(DelegateTransactionFilterTest, RoleAuthorizer_WithCounterparty_Match) +{ + // I am Account B (Signer). I want to see work I did specifically for Account A. + DelegateFilter const filterParams{ + .delegateType = DelegateFilter::Role::Authorizer, .counterParty = to_string(kAccountOwner) + }; + DelegateTransactionFilter const filter(filterParams, kAccountDelegator); + + // Tx: Owner/authorizer=A, Signer/actor=B + auto blob = createBlob(to_string(kAccountOwner), to_string(kAccountDelegator)); + + auto const& result = filter.check(blob); + EXPECT_TRUE(result.shouldInclude); + ASSERT_TRUE(result.relevantAccount.has_value()); + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + EXPECT_EQ(*result.relevantAccount, kAccountOwner); +} + +TEST_F(DelegateTransactionFilterTest, RoleAuthorizer_WithCounterparty_Mismatch) +{ + // I am Account B (Signer). I want to see work I did for Account C. + DelegateFilter const filterParams{ + .delegateType = DelegateFilter::Role::Authorizer, + .counterParty = to_string(kAccountDestination) + }; + DelegateTransactionFilter const filter(filterParams, kAccountDelegator); + + // Tx: Owner/authorizer=A, Signer/actor=B (Owner A != Counterparty C) + auto blob = createBlob(to_string(kAccountOwner), to_string(kAccountDelegator)); + + auto const& result = filter.check(blob); + EXPECT_FALSE(result.shouldInclude); +} + +TEST_F(DelegateTransactionFilterTest, RoleActor_MatchesWhenUserIsOwner) +{ + // I am Account A (Owner). I want to see who signed for me. + DelegateFilter const filterParams{ + .delegateType = DelegateFilter::Role::Actor, .counterParty = std::nullopt + }; + DelegateTransactionFilter const filter(filterParams, kAccountOwner); + + // Tx: Owner/authorizer=A, Signer/actor=B + auto blob = createBlob(to_string(kAccountOwner), to_string(kAccountDelegator)); + + auto const& result = filter.check(blob); + EXPECT_TRUE(result.shouldInclude); + ASSERT_TRUE(result.relevantAccount.has_value()); + // Should return Signer. + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + EXPECT_EQ(*result.relevantAccount, kAccountDelegator); +} + +TEST_F(DelegateTransactionFilterTest, RoleActor_FailsWhenUserIsNotOwner) +{ + // I am Account C. I query for actor work. + DelegateFilter const filterParams{ + .delegateType = DelegateFilter::Role::Actor, .counterParty = std::nullopt + }; + DelegateTransactionFilter const filter(filterParams, kAccountDestination); + + // Tx: Owner/authorizer=A, Signer/actor=B (C is not involved) + auto blob = createBlob(to_string(kAccountOwner), to_string(kAccountDelegator)); + + auto const& result = filter.check(blob); + EXPECT_FALSE(result.shouldInclude); +} + +TEST_F(DelegateTransactionFilterTest, RoleActor_WithCounterparty_Match) +{ + // I am Account A (Owner). I want to see work signed specifically by B. + DelegateFilter const filterParams{ + .delegateType = DelegateFilter::Role::Actor, .counterParty = to_string(kAccountDelegator) + }; + DelegateTransactionFilter const filter(filterParams, kAccountOwner); + + // Tx: Owner/authorizer=A, Signer/actor=B + auto blob = createBlob(to_string(kAccountOwner), to_string(kAccountDelegator)); + + auto const& result = filter.check(blob); + EXPECT_TRUE(result.shouldInclude); + ASSERT_TRUE(result.relevantAccount.has_value()); + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + EXPECT_EQ(*result.relevantAccount, kAccountDelegator); +} + +TEST_F(DelegateTransactionFilterTest, RoleActor_WithCounterparty_Mismatch) +{ + // I am Account A (Owner). I want to see work signed by C. + DelegateFilter const filterParams{ + .delegateType = DelegateFilter::Role::Actor, .counterParty = to_string(kAccountDestination) + }; + DelegateTransactionFilter const filter(filterParams, kAccountOwner); + + // Tx: Owner/authorizer=A, Signer/actor=B (Signer B != Counterparty C) + auto blob = createBlob(to_string(kAccountOwner), to_string(kAccountDelegator)); + + 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 7709d08ce..74b6c5fee 100644 --- a/tests/unit/rpc/handlers/AccountTxTests.cpp +++ b/tests/unit/rpc/handlers/AccountTxTests.cpp @@ -391,6 +391,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 = "delegateNotObject" + }, + 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 'actor' or 'authorizer'." + }, + AccountTxParamTestCaseBundle{ + .testName = "DelegateCounterpartyInvalid", + .testJson = R"JSON({ + "account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", + "delegate": { + "delegate_filter": "actor", + "counter_party": "not_an_account" + } + })JSON", + .expectedError = "actMalformed", + .expectedErrorMessage = "Field 'counter_party' value must be a valid account." + }, + AccountTxParamTestCaseBundle{ + .testName = "DelegateOnlyCounterparty", + .testJson = R"JSON({ + "account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", + "delegate": { + "counter_party": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn" + } + })JSON", + .expectedError = "invalidParams", + .expectedErrorMessage = "Field 'delegate_filter' is required but missing." } }; }; @@ -1208,6 +1259,166 @@ TEST_F(RPCAccountTxHandlerTest, TxLargerThanMaxSeq) }); } +TEST_F(RPCAccountTxHandlerTest, WithDelegateAgent) +{ + auto transactions = genTransactions(kMinSeq + 1, kMaxSeq - 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 = boost::json::parse(R"JSON({ + "account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", + "delegate": { + "delegate_filter": "authorizer" + } + })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 authorizer + EXPECT_TRUE(txs[0].as_object().contains("authorizer")); + EXPECT_TRUE(txs[1].as_object().contains("authorizer")); + }); +} + +TEST_F(RPCAccountTxHandlerTest, WithDelegateFromAndCounterparty) +{ + auto transactions = genTransactions(kMinSeq + 1, kMaxSeq - 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 = boost::json::parse( + fmt::format( + R"JSON({{ + "account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", + "delegate": {{ + "delegate_filter": "actor", + "counter_party": "{}" + }} + }})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("actor")); + EXPECT_EQ(txs[0].at("actor").as_string(), kCounterparty); + }); +} + +TEST_F(RPCAccountTxHandlerTest, WithDelegateAgent_PartialPageMatch) +{ + // The fetched page can contain a mix of delegated and non-delegated transactions; only the + // ones matching the filter should be in output, so the result can be smaller than the page. + auto transactions = genTransactions(kMinSeq + 1, kMaxSeq - 1); + transactions[0].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 = boost::json::parse(R"JSON({ + "account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", + "delegate": { + "delegate_filter": "authorizer" + } + })JSON"); + + auto const output = handler.process(kInput, Context{yield}); + ASSERT_TRUE(output); + + auto const& txs = output.result->at("transactions").as_array(); + ASSERT_EQ(txs.size(), 1); + EXPECT_TRUE(txs[0].as_object().contains("authorizer")); + + EXPECT_EQ( + output.result->at("marker").as_object(), + boost::json::parse(R"JSON({"ledger": 12, "seq": 34})JSON") + ); + }); +} + +TEST_F(RPCAccountTxHandlerTest, WithDelegateAgent_ResumesFromMarker) +{ + // A marker (from any prior page) combined with a delegate filter should just resume the raw + // scan from that position and filter from there; the two are independent of each other. + auto transactions = genTransactions(kMinSeq + 1, kMaxSeq - 1); + + for (auto& txn : transactions) { + txn.transaction = createDelegateBlob(kAccount2, kAccount); + } + + auto const transCursor = TransactionsAndCursor{.txns = transactions, .cursor = std::nullopt}; + 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 = boost::json::parse(R"JSON({ + "account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", + "marker": {"ledger": 12, "seq": 34}, + "delegate": { + "delegate_filter": "authorizer" + } + })JSON"); + + 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_FALSE(output.result->as_object().contains("marker")); + }); +} + TEST_F(RPCAccountTxHandlerTest, NFTTxs_API_v1) { auto const out = R"JSON({