From 147da573485e074e9c3e2a925134b8e07f591b3c Mon Sep 17 00:00:00 2001 From: Vito Tumas <5780819+Tapanito@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:22:32 +0200 Subject: [PATCH 1/2] feat: Add cleanup amendment for 3.2.0 (#7037) --- include/xrpl/protocol/detail/features.macro | 1 + 1 file changed, 1 insertion(+) diff --git a/include/xrpl/protocol/detail/features.macro b/include/xrpl/protocol/detail/features.macro index 494b3fa6cd..bad43dd6ed 100644 --- a/include/xrpl/protocol/detail/features.macro +++ b/include/xrpl/protocol/detail/features.macro @@ -15,6 +15,7 @@ // Add new amendments to the top of this list. // Keep it sorted in reverse chronological order. +XRPL_FIX (Cleanup3_2_0, Supported::no, VoteBehavior::DefaultNo) XRPL_FEATURE(MPTokensV2, Supported::no, VoteBehavior::DefaultNo) XRPL_FIX (Security3_1_3, Supported::no, VoteBehavior::DefaultNo) XRPL_FIX (PermissionedDomainInvariant, Supported::yes, VoteBehavior::DefaultNo) From 46b997b774b27a1260a8085586c9d33fcdbf9c6e Mon Sep 17 00:00:00 2001 From: Jingchen Date: Tue, 28 Apr 2026 15:16:10 +0100 Subject: [PATCH 2/2] feat: Create new transaction testing framework `TxTest` (#6537) Signed-off-by: JCW Co-authored-by: xrplf-ai-reviewer[bot] <266832837+xrplf-ai-reviewer[bot]@users.noreply.github.com> Co-authored-by: Copilot --- .../scripts/levelization/results/ordering.txt | 6 + .../xrpl/protocol_autogen/LedgerEntryBase.h | 13 + src/tests/libxrpl/CMakeLists.txt | 11 +- src/tests/libxrpl/helpers/Account.cpp | 19 + src/tests/libxrpl/helpers/Account.h | 81 ++ src/tests/libxrpl/helpers/IOU.h | 132 +++ src/tests/libxrpl/helpers/TestFamily.h | 111 +++ .../libxrpl/helpers/TestServiceRegistry.h | 378 ++++++++ src/tests/libxrpl/helpers/TxTest.cpp | 252 ++++++ src/tests/libxrpl/helpers/TxTest.h | 364 ++++++++ src/tests/libxrpl/tx/AccountSet.cpp | 804 ++++++++++++++++++ src/tests/libxrpl/tx/main.cpp | 8 + 12 files changed, 2177 insertions(+), 2 deletions(-) create mode 100644 src/tests/libxrpl/helpers/Account.cpp create mode 100644 src/tests/libxrpl/helpers/Account.h create mode 100644 src/tests/libxrpl/helpers/IOU.h create mode 100644 src/tests/libxrpl/helpers/TestFamily.h create mode 100644 src/tests/libxrpl/helpers/TestServiceRegistry.h create mode 100644 src/tests/libxrpl/helpers/TxTest.cpp create mode 100644 src/tests/libxrpl/helpers/TxTest.h create mode 100644 src/tests/libxrpl/tx/AccountSet.cpp create mode 100644 src/tests/libxrpl/tx/main.cpp diff --git a/.github/scripts/levelization/results/ordering.txt b/.github/scripts/levelization/results/ordering.txt index d2a1894585..c2000d1768 100644 --- a/.github/scripts/levelization/results/ordering.txt +++ b/.github/scripts/levelization/results/ordering.txt @@ -188,10 +188,16 @@ test.toplevel > xrpl.json test.unit_test > xrpl.basics test.unit_test > xrpl.protocol tests.libxrpl > xrpl.basics +tests.libxrpl > xrpl.core tests.libxrpl > xrpl.json +tests.libxrpl > xrpl.ledger tests.libxrpl > xrpl.net +tests.libxrpl > xrpl.nodestore tests.libxrpl > xrpl.protocol tests.libxrpl > xrpl.protocol_autogen +tests.libxrpl > xrpl.server +tests.libxrpl > xrpl.shamap +tests.libxrpl > xrpl.tx xrpl.conditions > xrpl.basics xrpl.conditions > xrpl.protocol xrpl.core > xrpl.basics diff --git a/include/xrpl/protocol_autogen/LedgerEntryBase.h b/include/xrpl/protocol_autogen/LedgerEntryBase.h index 0c5b367391..ad513992c7 100644 --- a/include/xrpl/protocol_autogen/LedgerEntryBase.h +++ b/include/xrpl/protocol_autogen/LedgerEntryBase.h @@ -130,6 +130,19 @@ public: return sle_->at(sfFlags); } + /** + * @brief Check if a specific flag is set. + * + * @param f The flag bitmask to check + * @return true if all bits in f are set in the flags field + */ + [[nodiscard]] + bool + isFlag(std::uint32_t f) const + { + return sle_->isFlag(f); + } + /** * @brief Get the underlying SLE object. * diff --git a/src/tests/libxrpl/CMakeLists.txt b/src/tests/libxrpl/CMakeLists.txt index 0b666441d1..ee07698519 100644 --- a/src/tests/libxrpl/CMakeLists.txt +++ b/src/tests/libxrpl/CMakeLists.txt @@ -8,9 +8,12 @@ add_custom_target(xrpl.tests) # Test helpers add_library(xrpl.helpers.test STATIC) -target_sources(xrpl.helpers.test PRIVATE helpers/TestSink.cpp) +target_sources( + xrpl.helpers.test + PRIVATE helpers/Account.cpp helpers/TestSink.cpp helpers/TxTest.cpp +) target_include_directories(xrpl.helpers.test PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) -target_link_libraries(xrpl.helpers.test PRIVATE xrpl.libxrpl) +target_link_libraries(xrpl.helpers.test PUBLIC xrpl.libxrpl gtest::gtest) # Common library dependencies for the rest of the tests. add_library(xrpl.imports.test INTERFACE) @@ -32,6 +35,10 @@ xrpl_add_test(json) target_link_libraries(xrpl.test.json PRIVATE xrpl.imports.test) add_dependencies(xrpl.tests xrpl.test.json) +xrpl_add_test(tx) +target_link_libraries(xrpl.test.tx PRIVATE xrpl.imports.test) +add_dependencies(xrpl.tests xrpl.test.tx) + xrpl_add_test(protocol_autogen) target_link_libraries(xrpl.test.protocol_autogen PRIVATE xrpl.imports.test) add_dependencies(xrpl.tests xrpl.test.protocol_autogen) diff --git a/src/tests/libxrpl/helpers/Account.cpp b/src/tests/libxrpl/helpers/Account.cpp new file mode 100644 index 0000000000..736ae0a24b --- /dev/null +++ b/src/tests/libxrpl/helpers/Account.cpp @@ -0,0 +1,19 @@ +#include + +#include +#include +#include +#include + +namespace xrpl::test { + +Account const Account::master{"masterpassphrase"}; + +Account::Account(std::string_view name, KeyType type) + : name_(name) + , keyPair_(generateKeyPair(type, generateSeed(name_))) + , id_(calcAccountID(keyPair_.first)) +{ +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/helpers/Account.h b/src/tests/libxrpl/helpers/Account.h new file mode 100644 index 0000000000..9c3ad19bbb --- /dev/null +++ b/src/tests/libxrpl/helpers/Account.h @@ -0,0 +1,81 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include +#include + +namespace xrpl::test { + +/** + * @brief A test account with cryptographic keys. + * + * Generates keys deterministically from a name, making tests reproducible. + * The same name always produces the same AccountID and keys. + */ +class Account +{ +public: + /** + * @brief The master account that holds all XRP in genesis. + * + * This account is created in the genesis ledger with all 100 billion XRP. + * It uses the well-known seed "masterpassphrase". + */ + static Account const master; + + /** + * @brief Create an account from a name. + * + * Keys are derived deterministically from the name. + * + * @param name Human-readable name for the account. + * @param type Key type to use (defaults to secp256k1). + */ + explicit Account(std::string_view name, KeyType type = KeyType::secp256k1); + + /** @brief Return the human-readable name. */ + std::string const& + name() const noexcept + { + return name_; + } + + /** @brief Return the AccountID. */ + AccountID const& + id() const noexcept + { + return id_; + } + + /** @brief Return the public key. */ + PublicKey const& + pk() const noexcept + { + return keyPair_.first; + } + + /** @brief Return the secret key. */ + SecretKey const& + sk() const noexcept + { + return keyPair_.second; + } + + /** @brief Implicit conversion to AccountID. */ + operator AccountID const&() const noexcept + { + return id_; + } + +private: + std::string name_; + std::pair keyPair_; + AccountID id_; +}; + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/helpers/IOU.h b/src/tests/libxrpl/helpers/IOU.h new file mode 100644 index 0000000000..1e845f5ae6 --- /dev/null +++ b/src/tests/libxrpl/helpers/IOU.h @@ -0,0 +1,132 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +namespace xrpl::test { + +/** + * @brief Represents an IOU (issued currency) for testing. + * + * Provides a clear, explicit API for creating currencies issued by an account. + * This replaces the cryptic `Account::operator[]` from the jtx framework. + * + * @code + * Account gw("gateway"); + * IOU USD("USD", gw); + * + * auto issue = USD.issue(); // Get the Issue + * auto asset = USD.asset(); // Get the Asset + * auto amt = USD.amount(100); // Get STAmount of 100 USD + * @endcode + */ +class IOU +{ +public: + /** + * @brief Construct an IOU from a currency code and issuing account. + * @param currencyCode A 3-character ISO currency code (e.g., "USD"). + * @param issuer The account that issues this currency. + */ + IOU(std::string_view currencyCode, Account const& issuer) + : currency_(to_currency(std::string(currencyCode))), issuer_(issuer.id()) + { + XRPL_ASSERT(!isXRP(currency_), "IOU: currency code must not resolve to XRP"); + } + + /** + * @brief Construct an IOU from a Currency and issuing account. + * @param currency The Currency object. + * @param issuer The account that issues this currency. + */ + IOU(Currency currency, Account const& issuer) + : currency_(std::move(currency)), issuer_(issuer.id()) + { + XRPL_ASSERT(!isXRP(currency_), "IOU: currency code must not resolve to XRP"); + } + + /** + * @brief Get the Issue (currency + issuer pair). + * @return An Issue object representing this IOU. + */ + [[nodiscard]] Issue + issue() const + { + return Issue{currency_, issuer_}; + } + + /** + * @brief Get the Asset. + * @return An Asset object representing this IOU. + */ + [[nodiscard]] Asset + asset() const + { + return Asset{issue()}; + } + + /** + * @brief Create an STAmount of this IOU. + * + * Works with any arithmetic type (int, double, etc.) by converting + * to string and parsing. This matches the jtx IOU behaviour. + * + * @tparam T An arithmetic type. + * @param value The amount as any arithmetic type. + * @return An STAmount representing value units of this IOU. + */ + template + requires std::is_arithmetic_v + [[nodiscard]] STAmount + amount(T value) const + { + return amountFromString(issue(), to_string(value)); + } + + /** + * @brief Create an STAmount of this IOU from a Number. + * @param value The amount as a Number. + * @return An STAmount representing value units of this IOU. + */ + [[nodiscard]] STAmount + amount(Number const& value) const + { + return STAmount{issue(), value}; + } + + /** + * @brief Get the currency. + * @return The currency. + */ + [[nodiscard]] Currency const& + currency() const + { + return currency_; + } + + /** + * @brief Get the issuer account ID. + * @return The issuer's AccountID. + */ + [[nodiscard]] AccountID const& + issuer() const + { + return issuer_; + } + +private: + Currency currency_; + AccountID issuer_; +}; + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/helpers/TestFamily.h b/src/tests/libxrpl/helpers/TestFamily.h new file mode 100644 index 0000000000..98c5a379e4 --- /dev/null +++ b/src/tests/libxrpl/helpers/TestFamily.h @@ -0,0 +1,111 @@ +#pragma once + +#include +#include +#include +#include + +#include + +namespace xrpl { +namespace test { + +/** Test implementation of Family for unit tests. + + Uses an in-memory NodeStore database and simple caches. + The missingNode methods throw since tests shouldn't encounter missing nodes. +*/ +class TestFamily : public Family +{ +private: + std::unique_ptr db_; + TestStopwatch clock_; + std::shared_ptr fbCache_; + std::shared_ptr tnCache_; + NodeStore::DummyScheduler scheduler_; + beast::Journal j_; + +public: + explicit TestFamily(beast::Journal j) + : fbCache_(std::make_shared("TestFamily full below cache", clock_, j)) + , tnCache_( + std::make_shared( + "TestFamily tree node cache", + 65536, + std::chrono::minutes{1}, + clock_, + j)) + , j_(j) + { + Section config; + config.set("type", "memory"); + config.set("path", "TestFamily"); + db_ = NodeStore::Manager::instance().make_Database(megabytes(4), scheduler_, 1, config, j); + } + + NodeStore::Database& + db() override + { + return *db_; + } + + NodeStore::Database const& + db() const override + { + return *db_; + } + + beast::Journal const& + journal() override + { + return j_; + } + + std::shared_ptr + getFullBelowCache() override + { + return fbCache_; + } + + std::shared_ptr + getTreeNodeCache() override + { + return tnCache_; + } + + void + sweep() override + { + fbCache_->sweep(); + tnCache_->sweep(); + } + + void + missingNodeAcquireBySeq(std::uint32_t refNum, uint256 const& nodeHash) override + { + Throw("TestFamily: missing node (by seq)"); + } + + void + missingNodeAcquireByHash(uint256 const& refHash, std::uint32_t refNum) override + { + Throw("TestFamily: missing node (by hash)"); + } + + void + reset() override + { + fbCache_->reset(); + tnCache_->reset(); + } + + /** Access the test clock for time manipulation in tests. */ + TestStopwatch& + clock() + { + return clock_; + } +}; + +} // namespace test +} // namespace xrpl diff --git a/src/tests/libxrpl/helpers/TestServiceRegistry.h b/src/tests/libxrpl/helpers/TestServiceRegistry.h new file mode 100644 index 0000000000..4f39124087 --- /dev/null +++ b/src/tests/libxrpl/helpers/TestServiceRegistry.h @@ -0,0 +1,378 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +#include +#include + +namespace xrpl { +namespace test { + +/** Logs implementation that creates TestSink instances. */ +class TestLogs : public Logs +{ +public: + explicit TestLogs(beast::severities::Severity level = beast::severities::kWarning) : Logs(level) + { + } + + std::unique_ptr + makeSink(std::string const&, beast::severities::Severity threshold) override + { + return std::make_unique(threshold); + } +}; + +/** Simple NetworkIDService implementation for tests. */ +class TestNetworkIDService final : public NetworkIDService +{ +public: + explicit TestNetworkIDService(std::uint32_t networkID = 0) : networkID_(networkID) + { + } + + [[nodiscard]] std::uint32_t + getNetworkID() const noexcept override + { + return networkID_; + } + +private: + std::uint32_t networkID_; +}; + +/** Test implementation of ServiceRegistry for unit tests. + + This class provides real implementations for services that can be + instantiated from libxrpl (such as Logs, io_context, caches), and + throws std::logic_error for services that require the full Application. + + Tests can subclass this to provide additional services they need. +*/ +class TestServiceRegistry : public ServiceRegistry +{ + TestLogs logs_{beast::severities::kWarning}; + boost::asio::io_context io_context_; + TestFamily family_{logs_.journal("TestFamily")}; + LoadFeeTrack feeTrack_{logs_.journal("LoadFeeTrack")}; + TestNetworkIDService networkIDService_; + HashRouter hashRouter_{HashRouter::Setup{}, stopwatch()}; + NodeCache tempNodeCache_{ + "TempNodeCache", + 16384, + std::chrono::minutes{1}, + stopwatch(), + logs_.journal("TaggedCache")}; + CachedSLEs cachedSLEs_{ + "CachedSLEs", + 16384, + std::chrono::minutes{1}, + stopwatch(), + logs_.journal("TaggedCache")}; + PendingSaves pendingSaves_; + std::optional trapTxID_; + +public: + TestServiceRegistry() = default; + ~TestServiceRegistry() override = default; + + // Core infrastructure services + CollectorManager& + getCollectorManager() override + { + throw std::logic_error("TestServiceRegistry::getCollectorManager() not implemented"); + } + + Family& + getNodeFamily() override + { + return family_; + } + + TimeKeeper& + getTimeKeeper() override + { + throw std::logic_error("TestServiceRegistry::timeKeeper() not implemented"); + } + + JobQueue& + getJobQueue() override + { + throw std::logic_error("TestServiceRegistry::getJobQueue() not implemented"); + } + + NodeCache& + getTempNodeCache() override + { + return tempNodeCache_; + } + + CachedSLEs& + getCachedSLEs() override + { + return cachedSLEs_; + } + + NetworkIDService& + getNetworkIDService() override + { + return networkIDService_; + } + + // Protocol and validation services + AmendmentTable& + getAmendmentTable() override + { + throw std::logic_error("TestServiceRegistry::getAmendmentTable() not implemented"); + } + + HashRouter& + getHashRouter() override + { + return hashRouter_; + } + + LoadFeeTrack& + getFeeTrack() override + { + return feeTrack_; + } + + LoadManager& + getLoadManager() override + { + throw std::logic_error("TestServiceRegistry::getLoadManager() not implemented"); + } + + RCLValidations& + getValidations() override + { + throw std::logic_error("TestServiceRegistry::getValidations() not implemented"); + } + + ValidatorList& + getValidators() override + { + throw std::logic_error("TestServiceRegistry::validators() not implemented"); + } + + ValidatorSite& + getValidatorSites() override + { + throw std::logic_error("TestServiceRegistry::validatorSites() not implemented"); + } + + ManifestCache& + getValidatorManifests() override + { + throw std::logic_error("TestServiceRegistry::validatorManifests() not implemented"); + } + + ManifestCache& + getPublisherManifests() override + { + throw std::logic_error("TestServiceRegistry::publisherManifests() not implemented"); + } + + // Network services + Overlay& + getOverlay() override + { + throw std::logic_error("TestServiceRegistry::overlay() not implemented"); + } + + Cluster& + getCluster() override + { + throw std::logic_error("TestServiceRegistry::cluster() not implemented"); + } + + PeerReservationTable& + getPeerReservations() override + { + throw std::logic_error("TestServiceRegistry::peerReservations() not implemented"); + } + + Resource::Manager& + getResourceManager() override + { + throw std::logic_error("TestServiceRegistry::getResourceManager() not implemented"); + } + + // Storage services + NodeStore::Database& + getNodeStore() override + { + throw std::logic_error("TestServiceRegistry::getNodeStore() not implemented"); + } + + SHAMapStore& + getSHAMapStore() override + { + throw std::logic_error("TestServiceRegistry::getSHAMapStore() not implemented"); + } + + RelationalDatabase& + getRelationalDatabase() override + { + throw std::logic_error("TestServiceRegistry::getRelationalDatabase() not implemented"); + } + + // Ledger services + InboundLedgers& + getInboundLedgers() override + { + throw std::logic_error("TestServiceRegistry::getInboundLedgers() not implemented"); + } + + InboundTransactions& + getInboundTransactions() override + { + throw std::logic_error("TestServiceRegistry::getInboundTransactions() not implemented"); + } + + TaggedCache& + getAcceptedLedgerCache() override + { + throw std::logic_error("TestServiceRegistry::getAcceptedLedgerCache() not implemented"); + } + + LedgerMaster& + getLedgerMaster() override + { + throw std::logic_error("TestServiceRegistry::getLedgerMaster() not implemented"); + } + + LedgerCleaner& + getLedgerCleaner() override + { + throw std::logic_error("TestServiceRegistry::getLedgerCleaner() not implemented"); + } + + LedgerReplayer& + getLedgerReplayer() override + { + throw std::logic_error("TestServiceRegistry::getLedgerReplayer() not implemented"); + } + + PendingSaves& + getPendingSaves() override + { + return pendingSaves_; + } + + OpenLedger& + getOpenLedger() override + { + throw std::logic_error("TestServiceRegistry::openLedger() not implemented"); + } + + OpenLedger const& + getOpenLedger() const override + { + throw std::logic_error("TestServiceRegistry::openLedger() const not implemented"); + } + + // Transaction and operation services + NetworkOPs& + getOPs() override + { + throw std::logic_error("TestServiceRegistry::getOPs() not implemented"); + } + + OrderBookDB& + getOrderBookDB() override + { + throw std::logic_error("TestServiceRegistry::getOrderBookDB() not implemented"); + } + + TransactionMaster& + getMasterTransaction() override + { + throw std::logic_error("TestServiceRegistry::getMasterTransaction() not implemented"); + } + + TxQ& + getTxQ() override + { + throw std::logic_error("TestServiceRegistry::getTxQ() not implemented"); + } + + PathRequestManager& + getPathRequestManager() override + { + throw std::logic_error("TestServiceRegistry::getPathRequestManager() not implemented"); + } + + // Server services + ServerHandler& + getServerHandler() override + { + throw std::logic_error("TestServiceRegistry::getServerHandler() not implemented"); + } + + perf::PerfLog& + getPerfLog() override + { + throw std::logic_error("TestServiceRegistry::getPerfLog() not implemented"); + } + + // Configuration and state + bool + isStopping() const override + { + return false; + } + + beast::Journal + getJournal(std::string const& name) override + { + return logs_.journal(name); + } + + boost::asio::io_context& + getIOContext() override + { + return io_context_; + } + + Logs& + getLogs() override + { + return logs_; + } + + std::optional const& + getTrapTxID() const override + { + return trapTxID_; + } + + DatabaseCon& + getWalletDB() override + { + throw std::logic_error("TestServiceRegistry::getWalletDB() not implemented"); + } + + // Temporary: Get the underlying Application + Application& + getApp() override + { + throw std::logic_error( + "TestServiceRegistry::app() not implemented - no Application available in tests"); + } +}; + +} // namespace test +} // namespace xrpl diff --git a/src/tests/libxrpl/helpers/TxTest.cpp b/src/tests/libxrpl/helpers/TxTest.cpp new file mode 100644 index 0000000000..d88f700356 --- /dev/null +++ b/src/tests/libxrpl/helpers/TxTest.cpp @@ -0,0 +1,252 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace xrpl::test { + +//------------------------------------------------------------------------------ +// Feature helpers +//------------------------------------------------------------------------------ + +FeatureBitset +allFeatures() +{ + static FeatureBitset const features = [] { + auto const& sa = allAmendments(); + std::vector feats; + feats.reserve(sa.size()); + for ([[maybe_unused]] auto const& [name, _] : sa) + { + if (auto const f = getRegisteredFeature(name); f.has_value()) + feats.push_back(*f); + } + return FeatureBitset(feats); + }(); + return features; +} + +//------------------------------------------------------------------------------ +// TxTest +//------------------------------------------------------------------------------ + +TxTest::TxTest(std::optional features) +{ + // Convert FeatureBitset to unordered_set for Rules constructor + auto const featureBits = features.value_or(allFeatures()); + foreachFeature(featureBits, [&](uint256 const& f) { featureSet_.insert(f); }); + + // Create rules with the specified features + rules_.emplace(featureSet_); + + // Default fees for testing + Fees const fees{XRPAmount{10}, XRPAmount{10000000}, XRPAmount{2000000}}; + + // Create a genesis ledger as the base + closedLedger_ = std::make_shared( + create_genesis, + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + *rules_, + fees, + std::vector{featureSet_.begin(), featureSet_.end()}, + registry_.getNodeFamily()); + + // Initialize time from the genesis ledger + now_ = closedLedger_->header().closeTime; + + // Create an open view on top of the genesis ledger + openLedger_ = + std::make_shared(open_ledger, closedLedger_.get(), *rules_, closedLedger_); +} + +bool +TxTest::isEnabled(uint256 const& feature) const +{ + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + return rules_->enabled(feature); +} + +Rules const& +TxTest::getRules() const +{ + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + return *rules_; +} + +[[nodiscard]] TxResult +TxTest::submit(std::shared_ptr stx) +{ + auto result = apply(registry_, *openLedger_, *stx, tapNONE, registry_.getJournal("apply")); + + // Track successfully applied transactions for canonical reordering on close + // We make a copy since the TransactionBase doesn't own the STTx + if (result.applied) + pendingTxs_.push_back(stx); + + return TxResult{ + .ter = result.ter, + .applied = result.applied, + .metadata = std::move(result).metadata, + .tx = std::move(stx)}; +} + +void +TxTest::createAccount(Account const& account, XRPAmount xrp, uint32_t accountFlags) +{ + auto const paymentTer = + submit(transactions::PaymentBuilder{Account::master, account, xrp}, Account::master).ter; + + if (paymentTer != tesSUCCESS) + { + throw std::runtime_error("TxTest::createAccount: failed to create account"); + } + + close(); + + if (accountFlags != 0) + { + auto const accountSetTer = + submit(transactions::AccountSetBuilder{account}.setSetFlag(accountFlags), account).ter; + if (accountSetTer != tesSUCCESS) + { + throw std::runtime_error("TxTest::createAccount: failed to set account flags"); + } + close(); + } +} + +ledger_entries::AccountRoot +TxTest::getAccountRoot(AccountID const& id) const +{ + auto const sle = getOpenLedger().read(keylet::account(id)); + if (!sle) + Throw("TxTest::getAccountRoot: account not found"); + return ledger_entries::AccountRoot{std::const_pointer_cast(sle)}; +} + +OpenView& +TxTest::getOpenLedger() +{ + return *openLedger_; +} + +OpenView const& +TxTest::getOpenLedger() const +{ + return *openLedger_; +} + +ReadView const& +TxTest::getClosedLedger() const +{ + return *closedLedger_; +} + +void +TxTest::close() +{ + // Build a new closed ledger from the previous closed ledger, + // similar to how buildLedgerImpl works: + // 1. Create a new Ledger from the previous closed ledger + // 2. Re-apply transactions in canonical order + // 3. Mark it as accepted/immutable + + auto const& prevLedger = *closedLedger_; + + auto const ledgerCloseTime = now_ + prevLedger.header().closeTimeResolution; + + now_ = ledgerCloseTime; + + auto newLedger = std::make_shared(prevLedger, ledgerCloseTime); + + CanonicalTXSet txSet(prevLedger.header().hash); + for (auto const& tx : pendingTxs_) + txSet.insert(tx); + + { + OpenView accum(&*newLedger); + for (auto const& [key, tx] : txSet) + { + auto result = apply(registry_, accum, *tx, tapNONE, registry_.getJournal("apply")); + if (!result.applied) + { + throw std::runtime_error("TxTest::close: failed to apply transaction"); + } + } + accum.apply(*newLedger); + } + + newLedger->setAccepted(ledgerCloseTime, newLedger->header().closeTimeResolution, true); + + closedLedger_ = newLedger; + + pendingTxs_.clear(); + + openLedger_ = + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + std::make_shared(open_ledger, closedLedger_.get(), *rules_, closedLedger_); +} + +void +TxTest::advanceTime(NetClock::duration duration) +{ + now_ += duration; +} + +NetClock::time_point +TxTest::getCloseTime() const +{ + return now_; +} + +STAmount +TxTest::getBalance(AccountID const& account, IOU const& iou) const +{ + auto const sle = openLedger_->read(keylet::line(account, iou.issue())); + if (!sle) + return STAmount{iou.issue(), 0}; + + auto const rippleState = ledger_entries::RippleState{sle}; + + auto balance = rippleState.getBalance(); + if (iou.issue().account == account) + { + throw std::logic_error("TxTest::getBalance: account is issuer"); + } + + balance.get().account = iou.issue().account; + if (account > iou.issue().account) + balance.negate(); + return balance; +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/helpers/TxTest.h b/src/tests/libxrpl/helpers/TxTest.h new file mode 100644 index 0000000000..864b19c399 --- /dev/null +++ b/src/tests/libxrpl/helpers/TxTest.h @@ -0,0 +1,364 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl::test { + +//------------------------------------------------------------------------------ +// Amount helpers +//------------------------------------------------------------------------------ + +/** + * @brief Convert XRP to drops (integral types). + * @param xrp The amount in XRP. + * @return The equivalent amount in drops as XRPAmount. + */ +template +constexpr XRPAmount +XRP(T xrp) +{ + return XRPAmount{static_cast(xrp) * DROPS_PER_XRP.drops()}; +} + +/** + * @brief Convert XRP to drops (floating point types). + * @param xrp The amount in XRP (may be fractional). + * @return The equivalent amount in drops as XRPAmount. + */ +template +XRPAmount +XRP(T xrp) +{ + return XRPAmount{static_cast(std::round(xrp * DROPS_PER_XRP.drops()))}; +} + +/** + * @brief Convert XRP to drops (Number type). + * @param xrp The amount in XRP as a Number. + * @return The equivalent amount in drops as XRPAmount. + */ +inline XRPAmount +XRP(Number const& xrp) +{ + return XRPAmount{static_cast(xrp * DROPS_PER_XRP.drops())}; +} + +//------------------------------------------------------------------------------ +// Flag helpers +//------------------------------------------------------------------------------ + +/** + * @brief Convert AccountSet flag (asf) to LedgerState flag (lsf). + * @param asf The AccountSet flag value. + * @return The corresponding LedgerState flag. + * @throws std::runtime_error if the flag is not supported. + * + * Supported flags: + * asfRequireDest, asfRequireAuth, asfDisallowXRP, asfDisableMaster, + * asfNoFreeze, asfGlobalFreeze, asfDefaultRipple, asfDepositAuth, + * asfAllowTrustLineClawback, asfDisallowIncomingCheck, + * asfDisallowIncomingNFTokenOffer, asfDisallowIncomingPayChan, + * asfDisallowIncomingTrustline, asfAllowTrustLineLocking + */ +constexpr std::uint32_t +asfToLsf(std::uint32_t asf) +{ + switch (asf) + { + case asfRequireDest: + return lsfRequireDestTag; + case asfRequireAuth: + return lsfRequireAuth; + case asfDisallowXRP: + return lsfDisallowXRP; + case asfDisableMaster: + return lsfDisableMaster; + case asfNoFreeze: + return lsfNoFreeze; + case asfGlobalFreeze: + return lsfGlobalFreeze; + case asfDefaultRipple: + return lsfDefaultRipple; + case asfDepositAuth: + return lsfDepositAuth; + case asfAllowTrustLineClawback: + return lsfAllowTrustLineClawback; + case asfDisallowIncomingCheck: + return lsfDisallowIncomingCheck; + case asfDisallowIncomingNFTokenOffer: + return lsfDisallowIncomingNFTokenOffer; + case asfDisallowIncomingPayChan: + return lsfDisallowIncomingPayChan; + case asfDisallowIncomingTrustline: + return lsfDisallowIncomingTrustline; + case asfAllowTrustLineLocking: + return lsfAllowTrustLineLocking; + default: + throw std::runtime_error("Unknown asf flag"); + } +} + +//------------------------------------------------------------------------------ +// Feature helpers +//------------------------------------------------------------------------------ + +/** + * @brief Returns all testable amendments. + * @note This is similar to jtx::testable_amendments() but for the TxTest framework. + */ +FeatureBitset +allFeatures(); + +//------------------------------------------------------------------------------ +// TxResult +//------------------------------------------------------------------------------ + +/** + * @brief Result of a transaction submission in TxTest. + * + * Contains the TER code, whether the transaction was applied, + * optional metadata, and a reference to the submitted transaction. + * Use standard gtest macros (EXPECT_EQ, EXPECT_TRUE, etc.) to verify results. + */ +struct TxResult +{ + TER ter; /**< The transaction engine result code. */ + bool applied; /**< Whether the transaction was applied to the ledger. */ + std::optional metadata; /**< Transaction metadata, if available. */ + std::shared_ptr tx; /**< Pointer to the submitted transaction. */ +}; + +/** + * @brief A lightweight transaction testing harness. + * + * Unlike the JTx framework which requires a full Application and RPC layer, + * TxTest applies transactions directly to an OpenView using the transactor + * pipeline (preflight -> preclaim -> doApply). + * + * This makes it suitable for: + * - Unit testing individual transactors + * - Testing transaction validation logic + * - Fast, focused tests without full server infrastructure + * + * @code + * TxTest env; + * env.submit(paymentTx).expectSuccess(); + * env.submit(badTx).expectTer(tecNO_ENTRY); + * @endcode + */ +class TxTest +{ +public: + /** + * @brief Construct a TxTest environment. + * + * Creates a genesis ledger and an open view on top of it. + * + * @param features Optional set of features to enable. If not specified, + * uses all testable amendments. + */ + explicit TxTest(std::optional features = std::nullopt); + + /** + * @brief Check if a feature is enabled. + * @param feature The feature to check. + * @return True if the feature is enabled. + */ + [[nodiscard]] bool + isEnabled(uint256 const& feature) const; + + /** + * @brief Get the current rules. + * @return The current consensus rules. + */ + [[nodiscard]] Rules const& + getRules() const; + + /** + * @brief Submit a transaction from a builder. + * + * Convenience overload that accepts transaction builders. + * Automatically sets sequence and fee before submission. + * + * @tparam T A type derived from TransactionBuilderBase. + * @param builder The transaction builder. + * @param signer The account to sign with. + * @return TxResult containing the result code, applied status, and metadata. + */ + template + requires std:: + derived_from, transactions::TransactionBuilderBase>> + [[nodiscard]] TxResult + submit(T&& builder, Account const& signer) + { + auto const& obj = builder.getSTObject(); + auto accountId = obj[sfAccount]; + // Only set sequence if not using a ticket (ticket sets sequence to 0) + if (!obj.isFieldPresent(sfTicketSequence)) + { + builder.setSequence(getAccountRoot(accountId).getSequence()); + } + else + { + builder.setSequence(0); + } + builder.setFee(XRPAmount(10)); + return submit(builder.build(signer.pk(), signer.sk()).getSTTx()); + } + + /** + * @brief Submit a transaction to the open ledger. + * + * Applies the transaction through the full transactor pipeline: + * preflight -> preclaim -> doApply -> invariant checks + * + * Invariant checks are automatically run after doApply. If any + * invariant fails, the result will be tecINVARIANT_FAILED. + * + * @param stx The transaction to submit. + * @return TxResult containing the result code, applied status, and metadata. + */ + [[nodiscard]] TxResult + submit(std::shared_ptr stx); + + /** + * @brief Create a new account in the ledger. + * + * Sends a Payment from the master account to create and fund the account. + * Closes the ledger after creation. If accountFlags is non-zero, submits + * an AccountSet transaction and closes again. + * + * @param account The account to create. + * @param xrp The initial XRP balance. + * @param accountFlags Optional account flags to set. Defaults to 0 + * (no flags). + */ + void + createAccount(Account const& account, XRPAmount xrp, uint32_t accountFlags = 0); + + /** + * @brief Get the account root object from the current open ledger. + * @param id The account ID. + * @return The AccountRoot ledger entry. + * @throws std::runtime_error if the account does not exist. + * @todo Once we make keylet strongly typed, we can ditch this method. + */ + [[nodiscard]] ledger_entries::AccountRoot + getAccountRoot(AccountID const& id) const; + + /** + * @brief Get the current open ledger view. + * @return A mutable reference to the open ledger. + */ + [[nodiscard]] OpenView& + getOpenLedger(); + + /** + * @brief Get the current open ledger view (const). + * @return A const reference to the open ledger. + */ + [[nodiscard]] OpenView const& + getOpenLedger() const; + + /** + * @brief Get the closed (base) ledger view. + * @return A const reference to the closed ledger. + */ + [[nodiscard]] ReadView const& + getClosedLedger() const; + + /** + * @brief Close the current ledger. + * + * Creates a new closed ledger from the current open ledger. + * All pending transactions are re-applied in canonical order. + */ + void + close(); + + /** + * @brief Advance time without closing the ledger. + * + * Useful for testing time-dependent features like escrow release + * times or offer expirations. + * + * @param duration The amount of time to advance. + */ + void + advanceTime(NetClock::duration duration); + + /** + * @brief Get the current ledger close time. + * @return The current close time. + */ + [[nodiscard]] NetClock::time_point + getCloseTime() const; + + /** + * @brief Get the balance of an IOU for an account. + * + * Returns the balance from the perspective of the specified account. + * If the trust line doesn't exist, returns zero. + * + * @param account The account to check. + * @param iou The IOU to check the balance for. + * @return The balance as an STAmount. + * @todo Once we make keylet strongly typed, we can ditch this method. + */ + [[nodiscard]] STAmount + getBalance(AccountID const& account, IOU const& iou) const; + + /** + * @brief Get the service registry. + * @return A reference to the service registry. + */ + ServiceRegistry& + getServiceRegistry() + { + return registry_; + } + +private: + TestServiceRegistry registry_; + std::unordered_set> featureSet_; + std::optional rules_; + std::shared_ptr closedLedger_; + std::shared_ptr openLedger_; + + /** Transactions submitted to the open ledger, for canonical reordering on close. */ + std::vector> pendingTxs_; + + /** Current time (can be advanced arbitrarily for testing). */ + NetClock::time_point now_; +}; + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/tx/AccountSet.cpp b/src/tests/libxrpl/tx/AccountSet.cpp new file mode 100644 index 0000000000..3dbe7a4903 --- /dev/null +++ b/src/tests/libxrpl/tx/AccountSet.cpp @@ -0,0 +1,804 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl::test { + +TEST(AccountSet, NullAccountSet) +{ + TxTest env; + + Account const alice("alice"); + env.createAccount(alice, XRP(10)); + + auto& view = env.getOpenLedger(); + + // ask for the ledger entry - account root, to check its flags + auto sle = view.read(keylet::account(alice)); + + EXPECT_NE(sle, nullptr); + ledger_entries::AccountRoot const accountRoot(sle); + EXPECT_EQ(accountRoot.getFlags(), 0); +} + +TEST(AccountSet, MostFlags) +{ + Account const alice("alice"); + + TxTest env; + env.createAccount(alice, XRP(10000)); + + // Give alice a regular key so she can legally set and clear + // her asfDisableMaster flag. + Account const aliceRegularKey{"aliceRegularKey", KeyType::secp256k1}; + + env.createAccount(aliceRegularKey, XRP(10000)); + env.close(); + + EXPECT_EQ( + env.submit(transactions::SetRegularKeyBuilder{alice}.setRegularKey(aliceRegularKey), alice) + .ter, + tesSUCCESS); + env.close(); + + auto testFlags = [&alice, &aliceRegularKey, &env]( + std::initializer_list goodFlags) { + std::uint32_t const orig_flags = env.getAccountRoot(alice).getFlags(); + for (std::uint32_t flag{1u}; flag < std::numeric_limits::digits; ++flag) + { + if (flag == asfNoFreeze) + { + // The asfNoFreeze flag can't be cleared. It is tested + // elsewhere. + continue; + } + if (flag == asfAuthorizedNFTokenMinter) + { + // The asfAuthorizedNFTokenMinter flag requires the + // presence or absence of the sfNFTokenMinter field in + // the transaction. It is tested elsewhere. + continue; + } + + if (flag == asfDisallowIncomingCheck || flag == asfDisallowIncomingPayChan || + flag == asfDisallowIncomingNFTokenOffer || flag == asfDisallowIncomingTrustline) + { + // These flags are part of the DisallowIncoming amendment + // and are tested elsewhere + continue; + } + if (flag == asfAllowTrustLineClawback) + { + // The asfAllowTrustLineClawback flag can't be cleared. It + // is tested elsewhere. + continue; + } + if (flag == asfAllowTrustLineLocking) + { + // These flags are part of the AllowTokenLocking amendment + // and are tested elsewhere + continue; + } + if (std::ranges::find(goodFlags, flag) != goodFlags.end()) + { + // Good flag + EXPECT_FALSE(env.getAccountRoot(alice).isFlag(asfToLsf(flag))); + + EXPECT_EQ( + env.submit(transactions::AccountSetBuilder{alice}.setSetFlag(flag), alice).ter, + tesSUCCESS); + env.close(); + + EXPECT_TRUE(env.getAccountRoot(alice).isFlag(asfToLsf(flag))); + + EXPECT_EQ( + env.submit( + transactions::AccountSetBuilder{alice}.setClearFlag(flag), + aliceRegularKey) + .ter, + tesSUCCESS); + env.close(); + + EXPECT_FALSE(env.getAccountRoot(alice).isFlag(asfToLsf(flag))); + + std::uint32_t const now_flags = env.getAccountRoot(alice).getFlags(); + EXPECT_EQ(now_flags, orig_flags); + } + else + { + // Bad flag + EXPECT_EQ(env.getAccountRoot(alice).getFlags(), orig_flags); + EXPECT_EQ( + env.submit(transactions::AccountSetBuilder{alice}.setSetFlag(flag), alice).ter, + tesSUCCESS); + env.close(); + EXPECT_EQ(env.getAccountRoot(alice).getFlags(), orig_flags); + + EXPECT_EQ( + env.submit( + transactions::AccountSetBuilder{alice}.setClearFlag(flag), + aliceRegularKey) + .ter, + tesSUCCESS); + env.close(); + EXPECT_EQ(env.getAccountRoot(alice).getFlags(), orig_flags); + } + } + }; + testFlags({ + asfRequireDest, + asfRequireAuth, + asfDisallowXRP, + asfGlobalFreeze, + asfDisableMaster, + asfDefaultRipple, + asfDepositAuth, + }); +} + +TEST(AccountSet, SetAndResetAccountTxnID) +{ + TxTest env; + Account const alice("alice"); + + env.createAccount(alice, XRP(10000)); + + std::uint32_t const orig_flags = env.getAccountRoot(alice).getFlags(); + + // asfAccountTxnID is special and not actually set as a flag, + // so we check the field presence instead + EXPECT_FALSE(env.getAccountRoot(alice).hasAccountTxnID()); + + EXPECT_EQ( + env.submit(transactions::AccountSetBuilder{alice}.setSetFlag(asfAccountTxnID), alice).ter, + tesSUCCESS); + env.close(); + + EXPECT_TRUE(env.getAccountRoot(alice).hasAccountTxnID()); + + EXPECT_EQ( + env.submit(transactions::AccountSetBuilder{alice}.setClearFlag(asfAccountTxnID), alice).ter, + tesSUCCESS); + env.close(); + + EXPECT_FALSE(env.getAccountRoot(alice).hasAccountTxnID()); + + std::uint32_t const now_flags = env.getAccountRoot(alice).getFlags(); + EXPECT_EQ(now_flags, orig_flags); +} + +TEST(AccountSet, SetNoFreeze) +{ + TxTest env; + Account const alice("alice"); + Account const eric("eric"); + + env.createAccount(alice, XRP(10000)); + env.close(); + + // Set eric as alice's regular key (eric doesn't need to be funded) + EXPECT_EQ( + env.submit(transactions::SetRegularKeyBuilder{alice}.setRegularKey(eric), alice).ter, + tesSUCCESS); + env.close(); + + // Verify alice doesn't have NoFreeze flag + EXPECT_FALSE(env.getAccountRoot(alice).isFlag(lsfNoFreeze)); + + // Setting NoFreeze with regular key should fail - requires master key + EXPECT_EQ( + env.submit(transactions::AccountSetBuilder{alice}.setSetFlag(asfNoFreeze), eric).ter, + tecNEED_MASTER_KEY); + env.close(); + + // Setting NoFreeze with master key should succeed + EXPECT_EQ( + env.submit(transactions::AccountSetBuilder{alice}.setSetFlag(asfNoFreeze), alice).ter, + tesSUCCESS); + env.close(); + + // Verify alice now has NoFreeze flag + EXPECT_TRUE(env.getAccountRoot(alice).isFlag(lsfNoFreeze)); + + // Try to clear NoFreeze - transaction succeeds but flag remains set + EXPECT_EQ( + env.submit(transactions::AccountSetBuilder{alice}.setClearFlag(asfNoFreeze), alice).ter, + tesSUCCESS); + env.close(); + + // Verify flag is still set (NoFreeze cannot be cleared once set) + EXPECT_TRUE(env.getAccountRoot(alice).isFlag(lsfNoFreeze)); +} + +TEST(AccountSet, Domain) +{ + TxTest env; + Account const alice("alice"); + + env.createAccount(alice, XRP(10000)); + env.close(); + + // The Domain field is represented as the hex string of the lowercase + // ASCII of the domain. For example, the domain example.com would be + // represented as "6578616d706c652e636f6d". + // + // To remove the Domain field from an account, send an AccountSet with + // the Domain set to an empty string. + std::string const domain = "example.com"; + + // Set domain + EXPECT_EQ( + env.submit(transactions::AccountSetBuilder{alice}.setDomain(makeSlice(domain)), alice).ter, + tesSUCCESS); + env.close(); + + EXPECT_TRUE(env.getAccountRoot(alice).hasDomain()); + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + EXPECT_EQ(*env.getAccountRoot(alice).getDomain(), makeSlice(domain)); + + // Clear domain by setting empty + EXPECT_EQ( + env.submit(transactions::AccountSetBuilder{alice}.setDomain(Slice{}), alice).ter, + tesSUCCESS); + env.close(); + + EXPECT_FALSE(env.getAccountRoot(alice).hasDomain()); + + // The upper limit on the length is 256 bytes + // (defined as DOMAIN_BYTES_MAX in SetAccount) + // test the edge cases: 255, 256, 257. + std::size_t const maxLength = 256; + for (std::size_t len = maxLength - 1; len <= maxLength + 1; ++len) + { + std::string const domain2 = std::string(len - domain.length() - 1, 'a') + "." + domain; + + EXPECT_EQ(domain2.length(), len); + + if (len <= maxLength) + { + EXPECT_EQ( + env.submit( + transactions::AccountSetBuilder{alice}.setDomain(makeSlice(domain2)), alice) + .ter, + tesSUCCESS); + env.close(); + + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + EXPECT_EQ(*env.getAccountRoot(alice).getDomain(), makeSlice(domain2)); + } + else + { + EXPECT_EQ( + env.submit( + transactions::AccountSetBuilder{alice}.setDomain(makeSlice(domain2)), alice) + .ter, + telBAD_DOMAIN); + env.close(); + } + } +} + +TEST(AccountSet, MessageKey) +{ + TxTest env; + Account const alice("alice"); + + env.createAccount(alice, XRP(10000)); + env.close(); + + // Generate a random ed25519 key pair for the message key + auto const rkp = randomKeyPair(KeyType::ed25519); + + // Set the message key + EXPECT_EQ( + env.submit(transactions::AccountSetBuilder{alice}.setMessageKey(rkp.first.slice()), alice) + .ter, + tesSUCCESS); + env.close(); + + EXPECT_TRUE(env.getAccountRoot(alice).hasMessageKey()); + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + EXPECT_EQ(*env.getAccountRoot(alice).getMessageKey(), rkp.first.slice()); + + // Clear the message key by setting to empty + EXPECT_EQ( + env.submit(transactions::AccountSetBuilder{alice}.setMessageKey(Slice{}), alice).ter, + tesSUCCESS); + env.close(); + + EXPECT_FALSE(env.getAccountRoot(alice).hasMessageKey()); + + // Try to set an invalid public key - should fail + using namespace std::string_literals; + EXPECT_EQ( + env.submit( + transactions::AccountSetBuilder{alice}.setMessageKey( + makeSlice("NOT_REALLY_A_PUBKEY"s)), + alice) + .ter, + telBAD_PUBLIC_KEY); +} + +TEST(AccountSet, WalletID) +{ + TxTest env; + Account const alice("alice"); + + env.createAccount(alice, XRP(10000)); + env.close(); + + std::string_view const locator = + "9633EC8AF54F16B5286DB1D7B519EF49EEFC050C0C8AC4384F1D88ACD1BFDF05"; + uint256 locatorHash{}; + EXPECT_TRUE(locatorHash.parseHex(locator)); + + // Set the wallet locator + EXPECT_EQ( + env.submit(transactions::AccountSetBuilder{alice}.setWalletLocator(locatorHash), alice).ter, + tesSUCCESS); + env.close(); + + EXPECT_TRUE(env.getAccountRoot(alice).hasWalletLocator()); + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + EXPECT_EQ(*env.getAccountRoot(alice).getWalletLocator(), locatorHash); + + // Clear the wallet locator by setting to zero + EXPECT_EQ( + env.submit(transactions::AccountSetBuilder{alice}.setWalletLocator(beast::zero), alice).ter, + tesSUCCESS); + env.close(); + + EXPECT_FALSE(env.getAccountRoot(alice).hasWalletLocator()); +} + +TEST(AccountSet, EmailHash) +{ + TxTest env; + Account const alice("alice"); + + env.createAccount(alice, XRP(10000)); + env.close(); + + std::string_view const mh = "5F31A79367DC3137FADA860C05742EE6"; + uint128 emailHash{}; + EXPECT_TRUE(emailHash.parseHex(mh)); + + // Set the email hash + EXPECT_EQ( + env.submit(transactions::AccountSetBuilder{alice}.setEmailHash(emailHash), alice).ter, + tesSUCCESS); + env.close(); + + EXPECT_TRUE(env.getAccountRoot(alice).hasEmailHash()); + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + EXPECT_EQ(*env.getAccountRoot(alice).getEmailHash(), emailHash); + + // Clear the email hash by setting to zero + EXPECT_EQ( + env.submit(transactions::AccountSetBuilder{alice}.setEmailHash(beast::zero), alice).ter, + tesSUCCESS); + env.close(); + + EXPECT_FALSE(env.getAccountRoot(alice).hasEmailHash()); +} + +TEST(AccountSet, TransferRate) +{ + struct TestCase + { + double set; + TER code; + double get; + }; + + // Test data: {rate to set, expected TER, expected stored rate} + std::vector const testData = { + {1.0, tesSUCCESS, 1.0}, + {1.1, tesSUCCESS, 1.1}, + {2.0, tesSUCCESS, 2.0}, + {2.1, temBAD_TRANSFER_RATE, 2.0}, // > 2.0 is invalid + {0.0, tesSUCCESS, 1.0}, // 0 clears the rate (default = 1.0) + {2.0, tesSUCCESS, 2.0}, + {0.9, temBAD_TRANSFER_RATE, 2.0}, // < 1.0 is invalid + }; + + TxTest env; + Account const alice("alice"); + + env.createAccount(alice, XRP(10000)); + env.close(); + + for (auto const& r : testData) + { + auto const rateValue = static_cast(QUALITY_ONE * r.set); + + EXPECT_EQ( + env.submit(transactions::AccountSetBuilder{alice}.setTransferRate(rateValue), alice) + .ter, + r.code); + env.close(); + + // If the field is not present, expect the default value (1.0) + if (!env.getAccountRoot(alice).hasTransferRate()) + { + EXPECT_EQ(r.get, 1.0); + } + else + { + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + EXPECT_EQ( + *env.getAccountRoot(alice).getTransferRate(), + static_cast(r.get * QUALITY_ONE)); + } + } +} + +TEST(AccountSet, BadInputs) +{ + TxTest env; + Account const alice("alice"); + + env.createAccount(alice, XRP(10000)); + env.close(); + + // Setting and clearing the same flag is invalid + EXPECT_EQ( + env.submit( + transactions::AccountSetBuilder{alice} + .setSetFlag(asfDisallowXRP) + .setClearFlag(asfDisallowXRP), + alice) + .ter, + temINVALID_FLAG); + + EXPECT_EQ( + env.submit( + transactions::AccountSetBuilder{alice} + .setSetFlag(asfRequireAuth) + .setClearFlag(asfRequireAuth), + alice) + .ter, + temINVALID_FLAG); + + EXPECT_EQ( + env.submit( + transactions::AccountSetBuilder{alice} + .setSetFlag(asfRequireDest) + .setClearFlag(asfRequireDest), + alice) + .ter, + temINVALID_FLAG); + + // Setting asf flag while also using corresponding tf flag is invalid + EXPECT_EQ( + env.submit( + transactions::AccountSetBuilder{alice} + .setSetFlag(asfDisallowXRP) + .setFlags(tfAllowXRP), + alice) + .ter, + temINVALID_FLAG); + + EXPECT_EQ( + env.submit( + transactions::AccountSetBuilder{alice} + .setSetFlag(asfRequireAuth) + .setFlags(tfOptionalAuth), + alice) + .ter, + temINVALID_FLAG); + + EXPECT_EQ( + env.submit( + transactions::AccountSetBuilder{alice} + .setSetFlag(asfRequireDest) + .setFlags(tfOptionalDestTag), + alice) + .ter, + temINVALID_FLAG); + + // Using invalid flags (mask) is invalid + EXPECT_EQ( + env.submit( + transactions::AccountSetBuilder{alice} + .setSetFlag(asfRequireDest) + .setFlags(tfAccountSetMask), + alice) + .ter, + temINVALID_FLAG); + + // Disabling master key without an alternative key is invalid + EXPECT_EQ( + env.submit(transactions::AccountSetBuilder{alice}.setSetFlag(asfDisableMaster), alice).ter, + tecNO_ALTERNATIVE_KEY); +} + +TEST(AccountSet, RequireAuthWithDir) +{ + TxTest env; + Account const alice("alice"); + Account const bob("bob"); + + env.createAccount(alice, XRP(10000)); + env.close(); + + // alice should have an empty directory + EXPECT_TRUE(dirIsEmpty(env.getClosedLedger(), keylet::ownerDir(alice.id()))); + + // Give alice a signer list, then there will be stuff in the directory + // Build the SignerEntries array + STArray signerEntries(1); + { + signerEntries.push_back(STObject::makeInnerObject(sfSignerEntry)); + STObject& entry = signerEntries.back(); + entry[sfAccount] = bob.id(); + entry[sfSignerWeight] = std::uint16_t{1}; + } + + EXPECT_EQ( + env.submit( + transactions::SignerListSetBuilder{alice, 1}.setSignerEntries(signerEntries), alice) + .ter, + tesSUCCESS); + env.close(); + + EXPECT_FALSE(dirIsEmpty(env.getClosedLedger(), keylet::ownerDir(alice.id()))); + + // Setting RequireAuth should fail because alice has owner objects + EXPECT_EQ( + env.submit(transactions::AccountSetBuilder{alice}.setSetFlag(asfRequireAuth), alice).ter, + tecOWNERS); + + // Remove the signer list (quorum = 0, no entries) + EXPECT_EQ(env.submit(transactions::SignerListSetBuilder{alice, 0}, alice).ter, tesSUCCESS); + env.close(); + + EXPECT_TRUE(dirIsEmpty(env.getClosedLedger(), keylet::ownerDir(alice.id()))); + + // Now setting RequireAuth should succeed + EXPECT_EQ( + env.submit(transactions::AccountSetBuilder{alice}.setSetFlag(asfRequireAuth), alice).ter, + tesSUCCESS); +} + +TEST(AccountSet, Ticket) +{ + TxTest env; + Account const alice("alice"); + + env.createAccount(alice, XRP(10000)); + env.close(); + + // Get alice's current sequence - the ticket will be created at seq + 1 + std::uint32_t const aliceSeqBefore = env.getAccountRoot(alice.id()).getSequence(); + std::uint32_t const ticketSeq = aliceSeqBefore + 1; + + // Create a ticket + EXPECT_EQ(env.submit(transactions::TicketCreateBuilder{alice, 1}, alice).ter, tesSUCCESS); + env.close(); + + // Verify alice has 1 owner object (the ticket) + EXPECT_EQ(env.getAccountRoot(alice.id()).getOwnerCount(), 1u); + // Verify ticket exists + EXPECT_TRUE(env.getClosedLedger().exists(keylet::ticket(alice.id(), ticketSeq))); + + // Try using a ticket that alice doesn't have + EXPECT_EQ( + env.submit(transactions::AccountSetBuilder{alice}.setTicketSequence(ticketSeq + 1), alice) + .ter, + terPRE_TICKET); + env.close(); + + // Verify ticket still exists + EXPECT_TRUE(env.getClosedLedger().exists(keylet::ticket(alice.id(), ticketSeq))); + + // Get alice's sequence before using the ticket + std::uint32_t const aliceSeq = env.getAccountRoot(alice.id()).getSequence(); + + // Actually use alice's ticket (noop AccountSet) + EXPECT_EQ( + env.submit(transactions::AccountSetBuilder{alice}.setTicketSequence(ticketSeq), alice).ter, + tesSUCCESS); + env.close(); + + // Verify ticket is consumed (no owner objects) + EXPECT_EQ(env.getAccountRoot(alice.id()).getOwnerCount(), 0u); + EXPECT_FALSE(env.getClosedLedger().exists(keylet::ticket(alice.id(), ticketSeq))); + + // Verify alice's sequence did NOT advance (ticket use doesn't increment seq) + EXPECT_EQ(env.getAccountRoot(alice.id()).getSequence(), aliceSeq); + + // Try re-using a ticket that alice already used + EXPECT_EQ( + env.submit(transactions::AccountSetBuilder{alice}.setTicketSequence(ticketSeq), alice).ter, + tefNO_TICKET); +} + +TEST(AccountSet, BadSigningKey) +{ + TxTest env; + Account const alice("alice"); + + env.createAccount(alice, XRP(10000)); + env.close(); + + // Build a valid transaction first, then corrupt the signing key + auto stx = transactions::AccountSetBuilder{alice} + .setSequence(env.getAccountRoot(alice.id()).getSequence()) + .setFee(XRPAmount{10}) + .build(alice.pk(), alice.sk()) + .getSTTx(); + + // Create a copy with a bad signing key + STObject obj = *stx; + obj.setFieldVL(sfSigningPubKey, makeSlice(std::string("badkey"))); + + auto result = env.submit(std::make_shared(std::move(obj))); + EXPECT_EQ(result.ter, temBAD_SIGNATURE); + EXPECT_FALSE(result.applied); +} + +TEST(AccountSet, Gateway) +{ + Account const alice("alice"); + Account const bob("bob"); + Account const gw("gateway"); + IOU const USD("USD", gw); + + // Test gateway with a variety of allowed transfer rates + for (double transferRate = 1.0; transferRate <= 2.0; transferRate += 0.03125) + { + TxTest env; + + env.createAccount(gw, XRP(10000), asfDefaultRipple); + env.createAccount(alice, XRP(10000), asfDefaultRipple); + env.createAccount(bob, XRP(10000), asfDefaultRipple); + env.close(); + + // Set up trust lines: alice and bob trust gw for USD + EXPECT_EQ( + env.submit(transactions::TrustSetBuilder{alice}.setLimitAmount(USD.amount(10)), alice) + .ter, + tesSUCCESS); + EXPECT_EQ( + env.submit(transactions::TrustSetBuilder{bob}.setLimitAmount(USD.amount(10)), bob).ter, + tesSUCCESS); + env.close(); + + // Set transfer rate on the gateway + EXPECT_EQ( + env.submit( + transactions::AccountSetBuilder{gw}.setTransferRate( + static_cast(transferRate * QUALITY_ONE)), + gw) + .ter, + tesSUCCESS); + env.close(); + + // Calculate the amount with transfer rate applied + auto const amount = USD.amount(1); + Rate const rate(static_cast(transferRate * QUALITY_ONE)); + auto const amountWithRate = multiply(amount, rate); + + // Gateway pays alice 10 USD + EXPECT_EQ( + env.submit(transactions::PaymentBuilder{gw, alice, USD.amount(10)}, gw).ter, + tesSUCCESS); + env.close(); + + // Alice pays bob 1 USD (with sendmax to cover transfer fee) + EXPECT_EQ( + env.submit( + transactions::PaymentBuilder{alice, bob, USD.amount(1)}.setSendMax( + USD.amount(10)), + alice) + .ter, + tesSUCCESS); + env.close(); + + // Check balances + EXPECT_EQ(env.getBalance(alice.id(), USD), USD.amount(10) - amountWithRate); + EXPECT_EQ(env.getBalance(bob.id(), USD), USD.amount(1)); + } + + // Test out-of-bounds legacy transfer rates (4.0 and 4.294967295) + // These require direct ledger modification since the transactor blocks them + for (std::uint32_t const transferRate : {4000000000U, 4294967295U}) + { + TxTest env; + env.createAccount(gw, XRP(10000), asfDefaultRipple); + env.createAccount(alice, XRP(10000), asfDefaultRipple); + env.createAccount(bob, XRP(10000), asfDefaultRipple); + env.close(); + + // Set up trust lines + EXPECT_EQ( + env.submit(transactions::TrustSetBuilder{alice}.setLimitAmount(USD.amount(10)), alice) + .ter, + tesSUCCESS); + EXPECT_EQ( + env.submit(transactions::TrustSetBuilder{bob}.setLimitAmount(USD.amount(10)), bob).ter, + tesSUCCESS); + env.close(); + + // Set an acceptable transfer rate first (we'll hack it later) + EXPECT_EQ( + env.submit( + transactions::AccountSetBuilder{gw}.setTransferRate( + static_cast(2.0 * QUALITY_ONE)), + gw) + .ter, + tesSUCCESS); + env.close(); + + // Directly modify the ledger to set an out-of-bounds transfer rate + // This bypasses the transactor's validation + auto& view = env.getOpenLedger(); + auto slePtr = view.read(keylet::account(gw.id())); + ASSERT_NE(slePtr, nullptr); + auto sleCopy = std::make_shared(*slePtr); + (*sleCopy)[sfTransferRate] = transferRate; + view.rawReplace(sleCopy); + + // Calculate the amount with the legacy transfer rate + auto const amount = USD.amount(1); + auto const amountWithRate = multiply(amount, Rate(transferRate)); + + // Gateway pays alice 10 USD + EXPECT_EQ( + env.submit(transactions::PaymentBuilder{gw, alice, USD.amount(10)}, gw).ter, + tesSUCCESS); + + // Alice pays bob 1 USD + EXPECT_EQ( + env.submit( + transactions::PaymentBuilder{alice, bob, amount}.setSendMax(USD.amount(10)), + alice) + .ter, + tesSUCCESS); + + // Check balances + EXPECT_EQ(env.getBalance(alice.id(), USD), USD.amount(10) - amountWithRate); + EXPECT_EQ(env.getBalance(bob.id(), USD), amount); + } +} + +} // namespace xrpl::test diff --git a/src/tests/libxrpl/tx/main.cpp b/src/tests/libxrpl/tx/main.cpp new file mode 100644 index 0000000000..5142bbe08a --- /dev/null +++ b/src/tests/libxrpl/tx/main.cpp @@ -0,0 +1,8 @@ +#include + +int +main(int argc, char** argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +}