From e3ba569187c7e435069fa0f03eefe40adf166431 Mon Sep 17 00:00:00 2001 From: Shawn Xie <35279399+shawnxie999@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:33:46 +0000 Subject: [PATCH 01/32] fix: Check credential for LoanBrokerCoverWithdraw and VaultWithdraw (#7107) Co-authored-by: Peter Chen Co-authored-by: Ayaz Salikhov --- include/xrpl/ledger/View.h | 25 +++- .../xrpl/protocol/detail/transactions.macro | 2 + .../transactions/LoanBrokerCoverWithdraw.h | 37 +++++ .../transactions/VaultWithdraw.h | 37 +++++ .../xrpl/tx/transactors/vault/VaultWithdraw.h | 3 + src/libxrpl/ledger/View.cpp | 35 ++++- .../lending/LoanBrokerCoverWithdraw.cpp | 16 ++- .../tx/transactors/vault/VaultWithdraw.cpp | 20 ++- src/test/app/lending/LoanBroker_test.cpp | 131 ++++++++++++++++++ src/test/app/vault/VaultDomain_test.cpp | 110 +++++++++++++++ .../LoanBrokerCoverWithdrawTests.cpp | 21 +++ .../transactions/VaultWithdrawTests.cpp | 21 +++ 12 files changed, 445 insertions(+), 13 deletions(-) diff --git a/include/xrpl/ledger/View.h b/include/xrpl/ledger/View.h index f7fd5b5a8c..bb0817673c 100644 --- a/include/xrpl/ledger/View.h +++ b/include/xrpl/ledger/View.h @@ -24,6 +24,7 @@ #include #include #include +#include namespace xrpl { @@ -198,7 +199,10 @@ dirLink( * if withdrawing to self. * - If withdrawing to self, succeed. * - If not, checks if the receiver requires deposit authorization, and if - * the sender has it. + * the sender has it (account-based or credential-based). + * - Expects any credentials passed in to already exist in the ledger, and + * returns an internal error otherwise. Validate them beforehand with + * credentials::valid(). * - Checks that the receiver will not exceed the limit (IOU trustline limit * or MPT MaximumAmount). */ @@ -209,7 +213,8 @@ canWithdraw( AccountID const& to, SLE::const_ref toSle, STAmount const& amount, - bool hasDestinationTag); + bool hasDestinationTag, + std::optional> const& credentialIDs = std::nullopt); /** * Checks that can withdraw funds from an object to itself or a destination. @@ -222,7 +227,10 @@ canWithdraw( * if withdrawing to self. * - If withdrawing to self, succeed. * - If not, checks if the receiver requires deposit authorization, and if - * the sender has it. + * the sender has it (account-based or credential-based). + * - Expects any credentials passed in to already exist in the ledger, and + * returns an internal error otherwise. Validate them beforehand with + * credentials::valid(). * - Checks that the receiver will not exceed the limit (IOU trustline limit * or MPT MaximumAmount). */ @@ -232,20 +240,25 @@ canWithdraw( AccountID const& from, AccountID const& to, STAmount const& amount, - bool hasDestinationTag); + bool hasDestinationTag, + std::optional> const& credentialIDs = std::nullopt); /** * Checks that can withdraw funds from an object to itself or a destination. * * The receiver may be either the submitting account (sfAccount) or a different - * destination account (sfDestination). + * destination account (sfDestination). Credentials, if any, are taken from the + * transaction's sfCredentialIDs field. * * - Checks that the receiver account exists. * - If the receiver requires a destination tag, check that one exists, even * if withdrawing to self. * - If withdrawing to self, succeed. * - If not, checks if the receiver requires deposit authorization, and if - * the sender has it. + * the sender has it (account-based or credential-based). + * - Expects any credentials in sfCredentialIDs to already exist in the + * ledger, and returns an internal error otherwise. Validate them + * beforehand with credentials::valid(). * - Checks that the receiver will not exceed the limit (IOU trustline limit * or MPT MaximumAmount). */ diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index f8676d3b63..997f368638 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -921,6 +921,7 @@ TRANSACTION(ttVAULT_WITHDRAW, 69, VaultWithdraw, {sfAmount, SoeRequired, SoeMptSupported}, {sfDestination, SoeOptional}, {sfDestinationTag, SoeOptional}, + {sfCredentialIDs, SoeOptional}, })) /** This transaction claws back tokens from a vault. */ @@ -1004,6 +1005,7 @@ TRANSACTION(ttLOAN_BROKER_COVER_WITHDRAW, 77, LoanBrokerCoverWithdraw, {sfAmount, SoeRequired, SoeMptSupported}, {sfDestination, SoeOptional}, {sfDestinationTag, SoeOptional}, + {sfCredentialIDs, SoeOptional}, })) /** This transaction claws back First Loss Capital from a Loan Broker to diff --git a/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverWithdraw.h b/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverWithdraw.h index 56a93acbb4..148db4292c 100644 --- a/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverWithdraw.h +++ b/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverWithdraw.h @@ -121,6 +121,32 @@ public: { return this->tx_->isFieldPresent(sfDestinationTag); } + + /** + * @brief Get sfCredentialIDs (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getCredentialIDs() const + { + if (hasCredentialIDs()) + { + return this->tx_->at(sfCredentialIDs); + } + return std::nullopt; + } + + /** + * @brief Check if sfCredentialIDs is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasCredentialIDs() const + { + return this->tx_->isFieldPresent(sfCredentialIDs); + } }; /** @@ -214,6 +240,17 @@ public: return *this; } + /** + * @brief Set sfCredentialIDs (SoeOptional) + * @return Reference to this builder for method chaining. + */ + LoanBrokerCoverWithdrawBuilder& + setCredentialIDs(std::decay_t const& value) + { + object_[sfCredentialIDs] = value; + return *this; + } + /** * @brief Build and return the LoanBrokerCoverWithdraw wrapper. * @param publicKey The public key for signing. diff --git a/include/xrpl/protocol_autogen/transactions/VaultWithdraw.h b/include/xrpl/protocol_autogen/transactions/VaultWithdraw.h index 3211524e1f..17208cd76c 100644 --- a/include/xrpl/protocol_autogen/transactions/VaultWithdraw.h +++ b/include/xrpl/protocol_autogen/transactions/VaultWithdraw.h @@ -121,6 +121,32 @@ public: { return this->tx_->isFieldPresent(sfDestinationTag); } + + /** + * @brief Get sfCredentialIDs (SoeOptional) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getCredentialIDs() const + { + if (hasCredentialIDs()) + { + return this->tx_->at(sfCredentialIDs); + } + return std::nullopt; + } + + /** + * @brief Check if sfCredentialIDs is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasCredentialIDs() const + { + return this->tx_->isFieldPresent(sfCredentialIDs); + } }; /** @@ -214,6 +240,17 @@ public: return *this; } + /** + * @brief Set sfCredentialIDs (SoeOptional) + * @return Reference to this builder for method chaining. + */ + VaultWithdrawBuilder& + setCredentialIDs(std::decay_t const& value) + { + object_[sfCredentialIDs] = value; + return *this; + } + /** * @brief Build and return the VaultWithdraw wrapper. * @param publicKey The public key for signing. diff --git a/include/xrpl/tx/transactors/vault/VaultWithdraw.h b/include/xrpl/tx/transactors/vault/VaultWithdraw.h index 22ad39d26d..b61af8b323 100644 --- a/include/xrpl/tx/transactors/vault/VaultWithdraw.h +++ b/include/xrpl/tx/transactors/vault/VaultWithdraw.h @@ -20,6 +20,9 @@ public: { } + static bool + checkExtraFeatures(PreflightContext const& ctx); + static NotTEC preflight(PreflightContext const& ctx); diff --git a/src/libxrpl/ledger/View.cpp b/src/libxrpl/ledger/View.cpp index e01ae2e492..0cd082ff47 100644 --- a/src/libxrpl/ledger/View.cpp +++ b/src/libxrpl/ledger/View.cpp @@ -35,6 +35,7 @@ #include #include #include +#include namespace xrpl { @@ -467,7 +468,8 @@ canWithdraw( AccountID const& to, SLE::const_ref toSle, STAmount const& amount, - bool hasDestinationTag) + bool hasDestinationTag, + std::optional> const& credentialIDs) { if (auto const ret = checkDestinationAndTag(toSle, hasDestinationTag)) return ret; @@ -478,7 +480,28 @@ canWithdraw( if (toSle->isFlag(lsfDepositAuth)) { if (!view.exists(keylet::depositPreauth(to, from))) - return tecNO_PERMISSION; + { + if (credentialIDs.has_value()) + { + STVector256 const credIDs{*credentialIDs}; + + // Callers must have validated these in preclaim, so a missing + // credential here is an invariant violation. + for (auto const& h : credIDs) + { + if (!view.exists(keylet::credential(h))) + return tecINTERNAL; // LCOV_EXCL_LINE + } + + if (auto const ret = credentials::authorizedDepositPreauth(view, credIDs, to); + !isTesSuccess(ret)) + return ret; + } + else + { + return tecNO_PERMISSION; + } + } } return withdrawToDestExceedsLimit(view, from, to, amount); @@ -490,11 +513,12 @@ canWithdraw( AccountID const& from, AccountID const& to, STAmount const& amount, - bool hasDestinationTag) + bool hasDestinationTag, + std::optional> const& credentialIDs) { auto const toSle = view.read(keylet::account(to)); - return canWithdraw(view, from, to, toSle, amount, hasDestinationTag); + return canWithdraw(view, from, to, toSle, amount, hasDestinationTag, credentialIDs); } [[nodiscard]] TER @@ -503,7 +527,8 @@ canWithdraw(ReadView const& view, STTx const& tx) auto const from = tx[sfAccount]; auto const to = tx[~sfDestination].value_or(from); - return canWithdraw(view, from, to, tx[sfAmount], tx.isFieldPresent(sfDestinationTag)); + return canWithdraw( + view, from, to, tx[sfAmount], tx.isFieldPresent(sfDestinationTag), tx[~sfCredentialIDs]); } TER diff --git a/src/libxrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.cpp b/src/libxrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.cpp index 498f3c99eb..e914596599 100644 --- a/src/libxrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -25,7 +26,11 @@ namespace xrpl { bool LoanBrokerCoverWithdraw::checkExtraFeatures(PreflightContext const& ctx) { - return checkLendingProtocolDependencies(ctx.rules, ctx.tx); + if (!checkLendingProtocolDependencies(ctx.rules, ctx.tx)) + return false; + + return !ctx.tx.isFieldPresent(sfCredentialIDs) || + (ctx.rules.enabled(featureCredentials) && ctx.rules.enabled(fixCleanup3_4_0)); } NotTEC @@ -49,6 +54,9 @@ LoanBrokerCoverWithdraw::preflight(PreflightContext const& ctx) } } + if (auto const err = credentials::checkFields(ctx.tx, ctx.rules, ctx.j); !isTesSuccess(err)) + return err; + return tesSUCCESS; } @@ -109,6 +117,12 @@ LoanBrokerCoverWithdraw::preclaim(PreclaimContext const& ctx) if (auto const ret = canTransfer(ctx.view, vaultAsset, pseudoAccountID, dstAcct, waive)) return ret; + // Validate credentials (if any) before canWithdraw, since canWithdraw may + // call credentials::authorizedDepositPreauth which assumes credentials + // already exist. + if (auto const err = credentials::valid(ctx.tx, ctx.view, account, ctx.j); !isTesSuccess(err)) + return err; + // Withdrawal to a 3rd party destination account is essentially a transfer. // Enforce all the usual asset transfer checks. AuthType authType = AuthType::WeakAuth; diff --git a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp index 7e32e720d6..40689572a0 100644 --- a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -27,6 +28,13 @@ namespace xrpl { +bool +VaultWithdraw::checkExtraFeatures(PreflightContext const& ctx) +{ + return !ctx.tx.isFieldPresent(sfCredentialIDs) || + (ctx.rules.enabled(featureCredentials) && ctx.rules.enabled(fixCleanup3_4_0)); +} + static WaiveUnrealizedLoss shouldWaiveWithdrawal(ReadView const& view, AccountID const& account, SLE::const_ref issuance) { @@ -59,6 +67,9 @@ VaultWithdraw::preflight(PreflightContext const& ctx) } } + if (auto const err = credentials::checkFields(ctx.tx, ctx.rules, ctx.j); !isTesSuccess(err)) + return err; + return tesSUCCESS; } @@ -113,6 +124,12 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx) // LCOV_EXCL_STOP } + // Validate credentials (if any) before canWithdraw, since canWithdraw may + // call credentials::authorizedDepositPreauth which assumes credentials + // already exist. + if (auto const err = credentials::valid(ctx.tx, ctx.view, account, ctx.j); !isTesSuccess(err)) + return err; + if (fix313Enabled && amount.asset() == vaultShare) { // Post-fixCleanup3_1_3: if the user specified shares, convert @@ -144,7 +161,8 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx) account, dstAcct, *maybeAssets, - ctx.tx.isFieldPresent(sfDestinationTag))) + ctx.tx.isFieldPresent(sfDestinationTag), + ctx.tx[~sfCredentialIDs])) return ret; } catch (std::overflow_error const&) diff --git a/src/test/app/lending/LoanBroker_test.cpp b/src/test/app/lending/LoanBroker_test.cpp index 5efa65d506..321ed5168f 100644 --- a/src/test/app/lending/LoanBroker_test.cpp +++ b/src/test/app/lending/LoanBroker_test.cpp @@ -6,6 +6,8 @@ #include #include #include +#include +#include #include #include #include @@ -2532,6 +2534,132 @@ class LoanBroker_test : public beast::unit_test::Suite testRIPD4274MPT(); } + void + testCoverWithdrawCredentialDepositPreauth(FeatureBitset features) + { + testcase( + std::string{"CoverWithdraw with credential-based deposit preauth "} + + (features[fixCleanup3_4_0] ? "post-fix" : "pre-fix")); + using namespace jtx; + using namespace std::chrono_literals; + + bool const fixEnabled = features[fixCleanup3_4_0]; + + Env env(*this, features); + + Account const broker{"broker"}; + Account const dest{"dest"}; + Account const credIssuer{"credIssuer"}; + char const credType[] = "abcde"; + + env.fund(XRP(10'000), broker, dest, credIssuer); + env(fset(dest, asfDepositAuth)); + env.close(); + + PrettyAsset const asset{xrpIssue(), 1'000'000}; + + Vault const vault(env); + auto const [vaultTx, vaultKeylet] = vault.create({.owner = broker, .asset = asset}); + env(vaultTx); + env.close(); + + env(vault.deposit({.depositor = broker, .id = vaultKeylet.key, .amount = asset(1'000)})); + env.close(); + + auto const brokerKeylet = + keylet::loanBroker(broker.id(), SeqProxy::rawSequence(env.seq(broker))); + env(loan_broker::set(broker, vaultKeylet.key)); + env.close(); + + env(loan_broker::coverDeposit(broker, brokerKeylet.key, asset(500))); + env.close(); + + auto coverWithdrawToDest = [&]() { + return loan_broker::coverWithdraw(broker, brokerKeylet.key, asset(10)); + }; + + // Without any preauth, coverWithdraw to dest fails + env(coverWithdrawToDest(), loan_broker::kDestination(dest), Ter{tecNO_PERMISSION}); + env.close(); + + // Issue and accept a credential for the broker (with expiration) + auto jv = credentials::create(broker, credIssuer, credType); + std::uint32_t const expiration = + env.current()->header().parentCloseTime.time_since_epoch().count() + 100; + jv[sfExpiration.jsonName] = expiration; + env(jv); + env(credentials::accept(broker, credIssuer, credType)); + env.close(); + + auto const credKeylet = credentials::keylet(broker, credIssuer, credType); + auto const credIdx = + credentials::ledgerEntry(env, broker, credIssuer, credType)[jss::result][jss::index] + .asString(); + + // dest authorizes deposits from holders of credentials issued by credIssuer + env(deposit::authCredentials(dest, {{.issuer = credIssuer, .credType = credType}})); + env.close(); + + // Without supplying credentials, still fails + env(coverWithdrawToDest(), loan_broker::kDestination(dest), Ter{tecNO_PERMISSION}); + env.close(); + + if (!fixEnabled) + { + // Pre-fix: sfCredentialIDs in LoanBrokerCoverWithdraw is disabled + env(coverWithdrawToDest(), + loan_broker::kDestination(dest), + credentials::Ids({credIdx}), + Ter{temDISABLED}); + env.close(); + return; + } + + // With credentials, succeeds + env(coverWithdrawToDest(), loan_broker::kDestination(dest), credentials::Ids({credIdx})); + env.close(); + + // Bad credential id is rejected + std::string const invalidIdx = + "0E0B04ED60588A758B67E21FBBE95AC5A63598BA951761DC0EC9C08D7E01E034"; + env(coverWithdrawToDest(), + loan_broker::kDestination(dest), + credentials::Ids({invalidIdx}), + Ter{tecBAD_CREDENTIALS}); + env.close(); + + // Malformed credential array (duplicates) is rejected by checkFields + env(coverWithdrawToDest(), + loan_broker::kDestination(dest), + credentials::Ids({credIdx, credIdx}), + Ter{temMALFORMED}); + env.close(); + + // Valid credential not authorized by dest hits authorizedDepositPreauth error path + char const credType2[] = "fghij"; + env(credentials::create(broker, credIssuer, credType2)); + env(credentials::accept(broker, credIssuer, credType2)); + env.close(); + auto const credIdx2 = + credentials::ledgerEntry(env, broker, credIssuer, credType2)[jss::result][jss::index] + .asString(); + env(coverWithdrawToDest(), + loan_broker::kDestination(dest), + credentials::Ids({credIdx2}), + Ter{tecNO_PERMISSION}); + env.close(); + + // Advance time past expiration: credentials yield tecEXPIRED and are deleted + env.close(150s); + BEAST_EXPECT(env.le(credKeylet)); + env(coverWithdrawToDest(), + loan_broker::kDestination(dest), + credentials::Ids({credIdx}), + Ter{tecEXPIRED}); + env.close(); + BEAST_EXPECT(!env.le(credKeylet)); + } + // Exercises canApplyToBrokerCover (fixCleanup3_2_0): a deposit, withdraw, // or clawback whose amount rounds to zero at sfCoverAvailable's precision // scale must be rejected with tecPRECISION_LOSS once the amendment is on, @@ -2770,6 +2898,9 @@ public: testRIPD4274(); + testCoverWithdrawCredentialDepositPreauth(all_ - fixCleanup3_4_0); + testCoverWithdrawCredentialDepositPreauth(all_); + testLoanBrokerDeleteLockedMPT(all_); testLoanBrokerDeleteLockedMPT(all_ - fixCleanup3_2_0); diff --git a/src/test/app/vault/VaultDomain_test.cpp b/src/test/app/vault/VaultDomain_test.cpp index db8943921b..5af0842962 100644 --- a/src/test/app/vault/VaultDomain_test.cpp +++ b/src/test/app/vault/VaultDomain_test.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -18,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -570,6 +572,112 @@ private: } } + void + testWithdrawCredentialDepositPreauth(FeatureBitset features) + { + testcase( + "withdraw with credential-based deposit preauth " + + std::string{features[fixCleanup3_4_0] ? "post-fix" : "pre-fix"}); + using namespace test::jtx; + using namespace std::chrono_literals; + + bool const fixEnabled = features[fixCleanup3_4_0]; + + Env env{*this, features}; + + Account const owner{"owner"}; + Account const depositor{"depositor"}; + Account const dest{"dest"}; + Account const credIssuer{"credIssuer"}; + char const credType[] = "abcde"; + + env.fund(XRP(1000), owner, depositor, dest, credIssuer); + env(fset(dest, asfDepositAuth)); + env.close(); + + PrettyAsset const asset{xrpIssue(), 1'000'000}; + Vault vault{env}; + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + env(tx); + env.close(); + + env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)})); + env.close(); + + auto withdrawToDest = [&]() { + auto wtx = + vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(10)}); + wtx[sfDestination] = dest.human(); + return wtx; + }; + + // Without any preauth, withdraw to dest fails + env(withdrawToDest(), Ter{tecNO_PERMISSION}); + env.close(); + + // Issue and accept a credential for the depositor (with expiration) + auto jv = credentials::create(depositor, credIssuer, credType); + std::uint32_t const expiration = + env.current()->header().parentCloseTime.time_since_epoch().count() + 100; + jv[sfExpiration.jsonName] = expiration; + env(jv); + env(credentials::accept(depositor, credIssuer, credType)); + env.close(); + + auto const credKeylet = credentials::keylet(depositor, credIssuer, credType); + auto const credIdx = + credentials::ledgerEntry(env, depositor, credIssuer, credType)[jss::result][jss::index] + .asString(); + + // dest authorizes deposits from holders of credentials issued by credIssuer + env(deposit::authCredentials(dest, {{.issuer = credIssuer, .credType = credType}})); + env.close(); + + // Withdraw without supplying credentials still fails + env(withdrawToDest(), Ter{tecNO_PERMISSION}); + env.close(); + + if (!fixEnabled) + { + // Pre-fix: sfCredentialIDs in VaultWithdraw is rejected as disabled + env(withdrawToDest(), credentials::Ids({credIdx}), Ter{temDISABLED}); + env.close(); + return; + } + + // Withdraw with credentials succeeds + env(withdrawToDest(), credentials::Ids({credIdx})); + env.close(); + + // Bad credential id is rejected + std::string const invalidIdx = + "0E0B04ED60588A758B67E21FBBE95AC5A63598BA951761DC0EC9C08D7E01E034"; + env(withdrawToDest(), credentials::Ids({invalidIdx}), Ter{tecBAD_CREDENTIALS}); + env.close(); + + // Malformed credential array (duplicates) is rejected by checkFields + env(withdrawToDest(), credentials::Ids({credIdx, credIdx}), Ter{temMALFORMED}); + env.close(); + + // Valid credential not authorized by dest hits authorizedDepositPreauth error path + char const credType2[] = "fghij"; + env(credentials::create(depositor, credIssuer, credType2)); + env(credentials::accept(depositor, credIssuer, credType2)); + env.close(); + auto const credIdx2 = + credentials::ledgerEntry(env, depositor, credIssuer, credType2)[jss::result][jss::index] + .asString(); + env(withdrawToDest(), credentials::Ids({credIdx2}), Ter{tecNO_PERMISSION}); + env.close(); + + // Advance time past expiration: credentials yield tecEXPIRED and are deleted + env.close(150s); + BEAST_EXPECT(env.le(credKeylet)); + env(withdrawToDest(), credentials::Ids({credIdx}), Ter{tecEXPIRED}); + env.close(); + BEAST_EXPECT(!env.le(credKeylet)); + } + public: void run() override @@ -578,6 +686,8 @@ public: testDomainLossAfterAcquisition(); testDomainCheckBuyerSideOffer(); testWithDomainChecXRP(); + testWithdrawCredentialDepositPreauth(all_ - fixCleanup3_4_0); + testWithdrawCredentialDepositPreauth(all_); } }; diff --git a/src/tests/libxrpl/protocol_autogen/transactions/LoanBrokerCoverWithdrawTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/LoanBrokerCoverWithdrawTests.cpp index 5b0a8c9146..043ab0a252 100644 --- a/src/tests/libxrpl/protocol_autogen/transactions/LoanBrokerCoverWithdrawTests.cpp +++ b/src/tests/libxrpl/protocol_autogen/transactions/LoanBrokerCoverWithdrawTests.cpp @@ -33,6 +33,7 @@ TEST(TransactionsLoanBrokerCoverWithdrawTests, BuilderSettersRoundTrip) auto const amountValue = canonical_AMOUNT(); auto const destinationValue = canonical_ACCOUNT(); auto const destinationTagValue = canonical_UINT32(); + auto const credentialIDsValue = canonical_VECTOR256(); LoanBrokerCoverWithdrawBuilder builder{ accountValue, @@ -45,6 +46,7 @@ TEST(TransactionsLoanBrokerCoverWithdrawTests, BuilderSettersRoundTrip) // Set optional fields builder.setDestination(destinationValue); builder.setDestinationTag(destinationTagValue); + builder.setCredentialIDs(credentialIDsValue); auto tx = builder.build(publicKey, secretKey); @@ -90,6 +92,14 @@ TEST(TransactionsLoanBrokerCoverWithdrawTests, BuilderSettersRoundTrip) EXPECT_TRUE(tx.hasDestinationTag()); } + { + auto const& expected = credentialIDsValue; + auto const actualOpt = tx.getCredentialIDs(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfCredentialIDs should be present"; + expectEqualField(expected, *actualOpt, "sfCredentialIDs"); + EXPECT_TRUE(tx.hasCredentialIDs()); + } + } // 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper, @@ -110,6 +120,7 @@ TEST(TransactionsLoanBrokerCoverWithdrawTests, BuilderFromStTxRoundTrip) auto const amountValue = canonical_AMOUNT(); auto const destinationValue = canonical_ACCOUNT(); auto const destinationTagValue = canonical_UINT32(); + auto const credentialIDsValue = canonical_VECTOR256(); // Build an initial transaction LoanBrokerCoverWithdrawBuilder initialBuilder{ @@ -122,6 +133,7 @@ TEST(TransactionsLoanBrokerCoverWithdrawTests, BuilderFromStTxRoundTrip) initialBuilder.setDestination(destinationValue); initialBuilder.setDestinationTag(destinationTagValue); + initialBuilder.setCredentialIDs(credentialIDsValue); auto initialTx = initialBuilder.build(publicKey, secretKey); @@ -166,6 +178,13 @@ TEST(TransactionsLoanBrokerCoverWithdrawTests, BuilderFromStTxRoundTrip) expectEqualField(expected, *actualOpt, "sfDestinationTag"); } + { + auto const& expected = credentialIDsValue; + auto const actualOpt = rebuiltTx.getCredentialIDs(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfCredentialIDs should be present"; + expectEqualField(expected, *actualOpt, "sfCredentialIDs"); + } + } // 3) Verify wrapper throws when constructed from wrong transaction type. @@ -229,6 +248,8 @@ TEST(TransactionsLoanBrokerCoverWithdrawTests, OptionalFieldsReturnNullopt) EXPECT_FALSE(tx.getDestination().has_value()); EXPECT_FALSE(tx.hasDestinationTag()); EXPECT_FALSE(tx.getDestinationTag().has_value()); + EXPECT_FALSE(tx.hasCredentialIDs()); + EXPECT_FALSE(tx.getCredentialIDs().has_value()); } } diff --git a/src/tests/libxrpl/protocol_autogen/transactions/VaultWithdrawTests.cpp b/src/tests/libxrpl/protocol_autogen/transactions/VaultWithdrawTests.cpp index 4067a6551d..518957d47b 100644 --- a/src/tests/libxrpl/protocol_autogen/transactions/VaultWithdrawTests.cpp +++ b/src/tests/libxrpl/protocol_autogen/transactions/VaultWithdrawTests.cpp @@ -33,6 +33,7 @@ TEST(TransactionsVaultWithdrawTests, BuilderSettersRoundTrip) auto const amountValue = canonical_AMOUNT(); auto const destinationValue = canonical_ACCOUNT(); auto const destinationTagValue = canonical_UINT32(); + auto const credentialIDsValue = canonical_VECTOR256(); VaultWithdrawBuilder builder{ accountValue, @@ -45,6 +46,7 @@ TEST(TransactionsVaultWithdrawTests, BuilderSettersRoundTrip) // Set optional fields builder.setDestination(destinationValue); builder.setDestinationTag(destinationTagValue); + builder.setCredentialIDs(credentialIDsValue); auto tx = builder.build(publicKey, secretKey); @@ -90,6 +92,14 @@ TEST(TransactionsVaultWithdrawTests, BuilderSettersRoundTrip) EXPECT_TRUE(tx.hasDestinationTag()); } + { + auto const& expected = credentialIDsValue; + auto const actualOpt = tx.getCredentialIDs(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfCredentialIDs should be present"; + expectEqualField(expected, *actualOpt, "sfCredentialIDs"); + EXPECT_TRUE(tx.hasCredentialIDs()); + } + } // 2 & 4) Start from an STTx, construct a builder from it, build a new wrapper, @@ -110,6 +120,7 @@ TEST(TransactionsVaultWithdrawTests, BuilderFromStTxRoundTrip) auto const amountValue = canonical_AMOUNT(); auto const destinationValue = canonical_ACCOUNT(); auto const destinationTagValue = canonical_UINT32(); + auto const credentialIDsValue = canonical_VECTOR256(); // Build an initial transaction VaultWithdrawBuilder initialBuilder{ @@ -122,6 +133,7 @@ TEST(TransactionsVaultWithdrawTests, BuilderFromStTxRoundTrip) initialBuilder.setDestination(destinationValue); initialBuilder.setDestinationTag(destinationTagValue); + initialBuilder.setCredentialIDs(credentialIDsValue); auto initialTx = initialBuilder.build(publicKey, secretKey); @@ -166,6 +178,13 @@ TEST(TransactionsVaultWithdrawTests, BuilderFromStTxRoundTrip) expectEqualField(expected, *actualOpt, "sfDestinationTag"); } + { + auto const& expected = credentialIDsValue; + auto const actualOpt = rebuiltTx.getCredentialIDs(); + ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfCredentialIDs should be present"; + expectEqualField(expected, *actualOpt, "sfCredentialIDs"); + } + } // 3) Verify wrapper throws when constructed from wrong transaction type. @@ -229,6 +248,8 @@ TEST(TransactionsVaultWithdrawTests, OptionalFieldsReturnNullopt) EXPECT_FALSE(tx.getDestination().has_value()); EXPECT_FALSE(tx.hasDestinationTag()); EXPECT_FALSE(tx.getDestinationTag().has_value()); + EXPECT_FALSE(tx.hasCredentialIDs()); + EXPECT_FALSE(tx.getCredentialIDs().has_value()); } } From 422e5245e4a6d0b5360abc26ddc28c3ea70b6e36 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov Date: Thu, 20 Aug 2026 16:02:26 +0000 Subject: [PATCH 02/32] ci: Save cargo cache only from develop by default (#8063) --- .github/actions/cargo-cache/action.yml | 5 +++-- .github/workflows/reusable-build-test-config.yml | 1 - .github/workflows/reusable-clang-tidy.yml | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/actions/cargo-cache/action.yml b/.github/actions/cargo-cache/action.yml index 1923d8cf64..f716d3e4a4 100644 --- a/.github/actions/cargo-cache/action.yml +++ b/.github/actions/cargo-cache/action.yml @@ -20,9 +20,10 @@ inputs: required: false default: "" save-if: - description: "Condition for saving the cache after the job." + description: > + Condition for saving the cache after the job. Defaults to save only from develop branch required: false - default: "true" + default: ${{ github.ref == 'refs/heads/develop' }} runs: using: composite diff --git a/.github/workflows/reusable-build-test-config.yml b/.github/workflows/reusable-build-test-config.yml index 656e6ec85b..2846c3fb85 100644 --- a/.github/workflows/reusable-build-test-config.yml +++ b/.github/workflows/reusable-build-test-config.yml @@ -167,7 +167,6 @@ jobs: with: cache-directories: ${{ env.BUILD_DIR }}/corrosion key: ${{ inputs.config_name }} - save-if: ${{ github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/heads/release') }} # two workspaces here because build artifacts are located in 2 places: # - crates/target when cargo is called directly # - build/cargo when cargo is called by cmake diff --git a/.github/workflows/reusable-clang-tidy.yml b/.github/workflows/reusable-clang-tidy.yml index 6847ff9b57..ac21c83ea0 100644 --- a/.github/workflows/reusable-clang-tidy.yml +++ b/.github/workflows/reusable-clang-tidy.yml @@ -63,7 +63,6 @@ jobs: uses: ./.github/actions/cargo-cache with: cache-directories: ${{ env.BUILD_DIR }}/corrosion - save-if: ${{ github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/heads/release') }} workspaces: crates -> ../${{ env.BUILD_DIR }}/cargo - name: Setup Conan From cc767085633a6617d7013a5a6dd9d1fafd760d15 Mon Sep 17 00:00:00 2001 From: Timur Yalymov <36795566+tyalymov@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:05:18 +0000 Subject: [PATCH 03/32] fix: Return specific and consistent errors from vault_info (#8015) Co-authored-by: Cursor --- API-CHANGELOG.md | 3 + src/test/app/vault/VaultRPC_test.cpp | 115 ++++++++++++++++++++++----- src/xrpld/rpc/handlers/VaultInfo.cpp | 55 ++++++++----- 3 files changed, 135 insertions(+), 38 deletions(-) diff --git a/API-CHANGELOG.md b/API-CHANGELOG.md index c853cfb07c..d521f9c024 100644 --- a/API-CHANGELOG.md +++ b/API-CHANGELOG.md @@ -54,6 +54,9 @@ This section contains changes targeting a future version. - `submit`: The `fail_hard` field now returns an error if the value is not a boolean. [#6529](https://github.com/XRPLF/rippled/pull/6529) - `subscribe`: The `taker` field in the `books` array now returns `actMalformed` instead of `badIssuer` if the value is not a valid account. [#6529](https://github.com/XRPLF/rippled/pull/6529) - Fixed a bug in `Forwarded` HTTP header parsing where the extracted IP address could be incorrect when no comma or semicolon delimiter follows the address. This could cause the server to misidentify a client's IP address when operating behind a reverse proxy. [#6529](https://github.com/XRPLF/rippled/pull/6529) +- `vault_info`: Errors now identify what the request got wrong instead of reporting every failure as the unregistered token `malformedRequest`, and the `error`, `error_code` and `error_message` fields now agree with each other. An invalid `vault_id` or `seq` returns `invalidParams`, an invalid `owner` returns `actMalformed`, and a request that mixes `vault_id` with `owner`/`seq` or supplies neither returns `invalidParams` with a message naming the accepted combinations. [#8015](https://github.com/XRPLF/rippled/pull/8015) +- `vault_info`: A well-formed all-zero `vault_id` now returns `entryNotFound` instead of being rejected as malformed, and `entryNotFound` responses now include `error_code` and `error_message`. Clients that request `ripplerpc` 3.0 or above therefore receive HTTP 400 with that error rather than HTTP 200. [#8015](https://github.com/XRPLF/rippled/pull/8015) +- `vault_info`: `vault_id` and `owner` must now be strings, matching how `ledger_entry` reads the same fields. An object or an array in either field previously produced an internal error, and a number was silently converted to its decimal text; `vault_id` now returns `invalidParams` and `owner` returns `actMalformed`. [#8015](https://github.com/XRPLF/rippled/pull/8015) - `gateway_balances`: The `account` and `ident` fields now return an `invalidParams` error if the value is not a string, instead of an `internal` error. [#7655](https://github.com/XRPLF/rippled/pull/7655) - `account_lines`: The `peer` field now returns an error if the value is not a string. [#7728](https://github.com/XRPLF/rippled/pull/7728) diff --git a/src/test/app/vault/VaultRPC_test.cpp b/src/test/app/vault/VaultRPC_test.cpp index 2ac092b5a7..dbceb1cb9c 100644 --- a/src/test/app/vault/VaultRPC_test.cpp +++ b/src/test/app/vault/VaultRPC_test.cpp @@ -9,11 +9,13 @@ #include #include #include +#include #include #include #include #include #include +#include #include #include #include @@ -122,6 +124,22 @@ private: } }; + // An error response must carry a registered token together with the matching code and + // message, so that clients dispatching on either of them reach the same conclusion. + auto const checkError = [this]( + json::Value const& result, + std::string const& token, + ErrorCodeI const code, + std::string const& message) { + BEAST_EXPECT(result[jss::error].asString() == token); + BEAST_EXPECT(result[jss::error_code].asInt() == code); + BEAST_EXPECT(result[jss::error_message].asString() == message); + }; + + std::string const badSeqMessage = "Invalid field 'seq', not a positive 32-bit integer."; + std::string const badFieldsMessage = + "Must specify either 'vault_id' or both 'owner' and 'seq'."; + { testcase("RPC ledger_entry selected by key"); json::Value jvParams; @@ -276,16 +294,57 @@ private: jvParams[jss::ledger_index] = jss::validated; jvParams[jss::vault_id] = "foobar"; auto jv = env.rpc("json", "vault_info", to_string(jvParams)); - BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest"); + checkError( + jv[jss::result], + "invalidParams", + RpcInvalidParams, + "Invalid field 'vault_id', not hex string."); } { - testcase("RPC vault_info json invalid index"); + testcase("RPC vault_info json numeric vault_id"); json::Value jvParams; jvParams[jss::ledger_index] = jss::validated; jvParams[jss::vault_id] = 0; auto jv = env.rpc("json", "vault_info", to_string(jvParams)); - BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest"); + checkError( + jv[jss::result], + "invalidParams", + RpcInvalidParams, + "Invalid field 'vault_id', not hex string."); + } + + { + testcase("RPC vault_info json object vault_id"); + json::Value jvParams; + jvParams[jss::ledger_index] = jss::validated; + jvParams[jss::vault_id] = json::Value(json::ValueType::Object); + auto jv = env.rpc("json", "vault_info", to_string(jvParams)); + checkError( + jv[jss::result], + "invalidParams", + RpcInvalidParams, + "Invalid field 'vault_id', not hex string."); + } + + { + // An all-zero key is a well-formed request for a vault that cannot exist, not a + // malformed one. parseHex accepts both the padded form and the short "0". + testcase("RPC vault_info json all zero vault_id"); + json::Value jvParams; + jvParams[jss::ledger_index] = jss::validated; + jvParams[jss::vault_id] = strHex(uint256(beast::kZero)); + auto jv = env.rpc("json", "vault_info", to_string(jvParams)); + checkError(jv[jss::result], "entryNotFound", RpcEntryNotFound, "Entry not found."); + } + + { + testcase("RPC vault_info json short zero vault_id"); + json::Value jvParams; + jvParams[jss::ledger_index] = jss::validated; + jvParams[jss::vault_id] = "0"; + auto jv = env.rpc("json", "vault_info", to_string(jvParams)); + checkError(jv[jss::result], "entryNotFound", RpcEntryNotFound, "Entry not found."); } { @@ -308,7 +367,7 @@ private: jvParams[jss::owner] = owner.human(); jvParams[jss::seq] = "foobar"; auto jv = env.rpc("json", "vault_info", to_string(jvParams)); - BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest"); + checkError(jv[jss::result], "invalidParams", RpcInvalidParams, badSeqMessage); } { @@ -318,7 +377,7 @@ private: jvParams[jss::owner] = owner.human(); jvParams[jss::seq] = 0; auto jv = env.rpc("json", "vault_info", to_string(jvParams)); - BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest"); + checkError(jv[jss::result], "invalidParams", RpcInvalidParams, badSeqMessage); } { @@ -328,7 +387,7 @@ private: jvParams[jss::owner] = owner.human(); jvParams[jss::seq] = -1; auto jv = env.rpc("json", "vault_info", to_string(jvParams)); - BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest"); + checkError(jv[jss::result], "invalidParams", RpcInvalidParams, badSeqMessage); } { @@ -338,7 +397,7 @@ private: jvParams[jss::owner] = owner.human(); jvParams[jss::seq] = 1e20; auto jv = env.rpc("json", "vault_info", to_string(jvParams)); - BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest"); + checkError(jv[jss::result], "invalidParams", RpcInvalidParams, badSeqMessage); } { @@ -348,7 +407,7 @@ private: jvParams[jss::owner] = owner.human(); jvParams[jss::seq] = true; auto jv = env.rpc("json", "vault_info", to_string(jvParams)); - BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest"); + checkError(jv[jss::result], "invalidParams", RpcInvalidParams, badSeqMessage); } { @@ -358,7 +417,25 @@ private: jvParams[jss::owner] = "foobar"; jvParams[jss::seq] = sequence; auto jv = env.rpc("json", "vault_info", to_string(jvParams)); - BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest"); + checkError( + jv[jss::result], + "actMalformed", + RpcActMalformed, + "Invalid field 'owner', not AccountID."); + } + + { + testcase("RPC vault_info json array owner"); + json::Value jvParams; + jvParams[jss::ledger_index] = jss::validated; + jvParams[jss::owner] = json::Value(json::ValueType::Array); + jvParams[jss::seq] = sequence; + auto jv = env.rpc("json", "vault_info", to_string(jvParams)); + checkError( + jv[jss::result], + "actMalformed", + RpcActMalformed, + "Invalid field 'owner', not AccountID."); } { @@ -367,7 +444,7 @@ private: jvParams[jss::ledger_index] = jss::validated; jvParams[jss::owner] = owner.human(); auto jv = env.rpc("json", "vault_info", to_string(jvParams)); - BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest"); + checkError(jv[jss::result], "invalidParams", RpcInvalidParams, badFieldsMessage); } { @@ -376,7 +453,7 @@ private: jvParams[jss::ledger_index] = jss::validated; jvParams[jss::seq] = sequence; auto jv = env.rpc("json", "vault_info", to_string(jvParams)); - BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest"); + checkError(jv[jss::result], "invalidParams", RpcInvalidParams, badFieldsMessage); } { @@ -386,7 +463,7 @@ private: jvParams[jss::vault_id] = strHex(keylet.key); jvParams[jss::seq] = sequence; auto jv = env.rpc("json", "vault_info", to_string(jvParams)); - BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest"); + checkError(jv[jss::result], "invalidParams", RpcInvalidParams, badFieldsMessage); } { @@ -396,7 +473,7 @@ private: jvParams[jss::vault_id] = strHex(keylet.key); jvParams[jss::owner] = owner.human(); auto jv = env.rpc("json", "vault_info", to_string(jvParams)); - BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest"); + checkError(jv[jss::result], "invalidParams", RpcInvalidParams, badFieldsMessage); } { @@ -409,7 +486,7 @@ private: jvParams[jss::seq] = sequence; jvParams[jss::owner] = owner.human(); auto jv = env.rpc("json", "vault_info", to_string(jvParams)); - BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest"); + checkError(jv[jss::result], "invalidParams", RpcInvalidParams, badFieldsMessage); } { @@ -417,7 +494,7 @@ private: json::Value jvParams; jvParams[jss::ledger_index] = jss::validated; auto jv = env.rpc("json", "vault_info", to_string(jvParams)); - BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest"); + checkError(jv[jss::result], "invalidParams", RpcInvalidParams, badFieldsMessage); } { @@ -427,15 +504,15 @@ private: } { - testcase("RPC vault_info command line invalid index"); + testcase("RPC vault_info command line zero index"); json::Value jv = env.rpc("vault_info", "0", "validated"); - BEAST_EXPECT(jv[jss::result][jss::error].asString() == "malformedRequest"); + checkError(jv[jss::result], "entryNotFound", RpcEntryNotFound, "Entry not found."); } { - testcase("RPC vault_info command line invalid index"); + testcase("RPC vault_info command line unknown index"); json::Value jv = env.rpc("vault_info", strHex(uint256(42)), "validated"); - BEAST_EXPECT(jv[jss::result][jss::error].asString() == "entryNotFound"); + checkError(jv[jss::result], "entryNotFound", RpcEntryNotFound, "Entry not found."); } { diff --git a/src/xrpld/rpc/handlers/VaultInfo.cpp b/src/xrpld/rpc/handlers/VaultInfo.cpp index c216192ab3..0aa5334bd2 100644 --- a/src/xrpld/rpc/handlers/VaultInfo.cpp +++ b/src/xrpld/rpc/handlers/VaultInfo.cpp @@ -26,36 +26,48 @@ parseVault(json::Value const& params, json::Value& jvResult) uint256 uNodeIndex = beast::kZero; if (hasVaultId && !hasOwner && !hasSeq) { - if (!uNodeIndex.parseHex(params[jss::vault_id].asString())) + // asString() throws on an object or an array, so the type comes first. + auto const& vaultId = params[jss::vault_id]; + if (!vaultId.isString() || !uNodeIndex.parseHex(vaultId.asString())) { - rpc::injectError(RpcInvalidParams, jvResult); + rpc::injectError( + RpcInvalidParams, rpc::expectedFieldMessage(jss::vault_id, "hex string"), jvResult); return std::nullopt; } // else uNodeIndex holds the value we need } else if (!hasVaultId && hasOwner && hasSeq) { - auto const id = parseBase58(params[jss::owner].asString()); + auto const& owner = params[jss::owner]; + auto const id = owner.isString() ? parseBase58(owner.asString()) + : std::optional{}; if (!id) { - rpc::injectError(RpcActMalformed, jvResult); - return std::nullopt; - } - if (!(params[jss::seq].isInt() || params[jss::seq].isUInt()) || - params[jss::seq].asDouble() <= 0.0 || - params[jss::seq].asDouble() > double(json::Value::kMaxUInt)) - { - rpc::injectError(RpcInvalidParams, jvResult); + rpc::injectError( + RpcActMalformed, rpc::expectedFieldMessage(jss::owner, "AccountID"), jvResult); return std::nullopt; } - auto const seq = SeqProxy::rawSequence(params[jss::seq].asUInt()); + // Int and UInt are both 32 bits wide, so the type check is the only upper bound needed. + auto const& seqField = params[jss::seq]; + if (!(seqField.isInt() || seqField.isUInt()) || seqField.asDouble() <= 0.0) + { + rpc::injectError( + RpcInvalidParams, + rpc::expectedFieldMessage(jss::seq, "a positive 32-bit integer"), + jvResult); + return std::nullopt; + } + + auto const seq = SeqProxy::rawSequence(seqField.asUInt()); uNodeIndex = keylet::vault(*id, seq).key; } else { - // Invalid combination of fields vault_id/owner/seq - rpc::injectError(RpcInvalidParams, jvResult); + rpc::injectError( + RpcInvalidParams, + "Must specify either 'vault_id' or both 'owner' and 'seq'.", + jvResult); return std::nullopt; } @@ -71,20 +83,25 @@ doVaultInfo(rpc::JsonContext& context) if (!lpLedger) return jvResult; - auto const uNodeIndex = parseVault(context.params, jvResult).value_or(beast::kZero); - if (uNodeIndex == beast::kZero) + // No key means the request could not be turned into one, and parseVault has already said why. + auto const uNodeIndex = parseVault(context.params, jvResult); + if (!uNodeIndex) + return jvResult; + + // A zero key names an entry that cannot exist, and the ledger refuses to be asked for one. + if (*uNodeIndex == beast::kZero) { - jvResult[jss::error] = "malformedRequest"; + rpc::injectError(RpcEntryNotFound, jvResult); return jvResult; } - auto const sleVault = lpLedger->read(keylet::vault(uNodeIndex)); + auto const sleVault = lpLedger->read(keylet::vault(*uNodeIndex)); auto const sleIssuance = sleVault == nullptr // ? nullptr : lpLedger->read(keylet::mptokenIssuance(sleVault->at(sfShareMPTID))); if (!sleVault || !sleIssuance) { - jvResult[jss::error] = "entryNotFound"; + rpc::injectError(RpcEntryNotFound, jvResult); return jvResult; } From a1478fac39084c1d1bf4e9c2113eddbb606b70f2 Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Thu, 20 Aug 2026 16:28:43 +0000 Subject: [PATCH 04/32] docs: Fix yum installation baseurl (#8066) --- docs/install.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/install.md b/docs/install.md index a3e2fefa02..ee9c31868b 100644 --- a/docs/install.md +++ b/docs/install.md @@ -92,11 +92,11 @@ wherever it appears in the repository configuration. 2. Add the repository, using the channel you picked in [Release channels](#release-channels): ```bash - cat << REPOFILE | sudo tee /etc/yum.repos.d/xrplf.repo + cat << 'REPOFILE' | sudo tee /etc/yum.repos.d/xrplf.repo [xrplf-stable] name=XRP Ledger Packages enabled=1 - baseurl=https://packages.xrplf.org/repository/rpm-stable/ + baseurl=https://packages.xrplf.org/repository/rpm-stable/$basearch/ gpgcheck=1 repo_gpgcheck=1 gpgkey=https://packages.xrplf.org/xrplf.asc From d0dbf9163c66288e37d1c5bc9dce313d457f732b Mon Sep 17 00:00:00 2001 From: Kassaking7 <96991820+Kassaking7@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:04:04 +0000 Subject: [PATCH 05/32] fix: Prevent AMM auction slots from being acquired at zero cost when trading fee is zero (#7430) --- include/xrpl/protocol/AMMCore.h | 11 +++++ src/libxrpl/tx/transactors/dex/AMMBid.cpp | 30 +++++++------ src/test/app/AMMMPT_test.cpp | 25 +++++++---- src/test/app/AMM_test.cpp | 51 +++++++++++++++++++---- 4 files changed, 87 insertions(+), 30 deletions(-) diff --git a/include/xrpl/protocol/AMMCore.h b/include/xrpl/protocol/AMMCore.h index 1e11f6cd8b..3f6b12f460 100644 --- a/include/xrpl/protocol/AMMCore.h +++ b/include/xrpl/protocol/AMMCore.h @@ -91,6 +91,17 @@ getFee(std::uint16_t tfee) return Number{tfee} / kAuctionSlotFeeScaleFactor; } +/** + * Minimum auction slot price: LPTokens * TradingFee / kAuctionSlotMinFeeFraction + * @param lptAMMBalance AMM LP token balance + * @param tradingFee trading fee in {0, 1000} + */ +inline Number +ammAuctionMinSlotPrice(Number const& lptAMMBalance, std::uint16_t tradingFee) +{ + return lptAMMBalance * getFee(tradingFee) / kAuctionSlotMinFeeFraction; +} + /** * Get fee multiplier (1 - tfee) * @tfee trading fee in basis points diff --git a/src/libxrpl/tx/transactors/dex/AMMBid.cpp b/src/libxrpl/tx/transactors/dex/AMMBid.cpp index 3454559e82..154e64ca8e 100644 --- a/src/libxrpl/tx/transactors/dex/AMMBid.cpp +++ b/src/libxrpl/tx/transactors/dex/AMMBid.cpp @@ -193,10 +193,10 @@ applyBid(ApplyContext& ctx, Sandbox& sb, AccountID const& account, beast::Journa auto const current = duration_cast(ctx.view().header().parentCloseTime.time_since_epoch()).count(); // Auction slot discounted fee - auto const discountedFee = (*ammSle)[sfTradingFee] / kAuctionSlotDiscountedFeeFraction; - auto const tradingFee = getFee((*ammSle)[sfTradingFee]); + auto const ammTradingFee = (*ammSle)[sfTradingFee]; + auto const discountedFee = ammTradingFee / kAuctionSlotDiscountedFeeFraction; // Min price - auto const minSlotPrice = lptAMMBalance * tradingFee / kAuctionSlotMinFeeFraction; + auto const minSlotPrice = ammAuctionMinSlotPrice(lptAMMBalance, ammTradingFee); static constexpr std::uint32_t kTailingSlot = kAuctionSlotTimeIntervals - 1; @@ -260,31 +260,37 @@ applyBid(ApplyContext& ctx, Sandbox& sb, AccountID const& account, beast::Journa auto const bidMax = ctx.tx[~sfBidMax]; auto getPayPrice = [&](Number const& computedPrice) -> std::expected { + auto effectivePrice = computedPrice; + if (ctx.view().rules().enabled(fixCleanup3_4_0) && ammTradingFee == 0) + { + // Prevent zero-fee pools from granting auction slots at zero or dust prices. + effectivePrice = std::max(effectivePrice, ammAuctionMinSlotPrice(lptAMMBalance, 1)); + } auto const payPrice = [&]() -> std::optional { // Both min/max bid price are defined if (bidMin && bidMax) { - if (computedPrice <= *bidMax) - return std::max(computedPrice, Number(*bidMin)); - JLOG(ctx.journal.debug()) << "AMM Bid: not in range " << computedPrice << " " + if (effectivePrice <= *bidMax) + return std::max(effectivePrice, Number(*bidMin)); + JLOG(ctx.journal.debug()) << "AMM Bid: not in range " << effectivePrice << " " << *bidMin << " " << *bidMax; return std::nullopt; } - // Bidder pays max(bidPrice, computedPrice) + // Bidder pays max(bidPrice, effectivePrice) if (bidMin) { - return std::max(computedPrice, Number(*bidMin)); + return std::max(effectivePrice, Number(*bidMin)); } if (bidMax) { - if (computedPrice <= *bidMax) - return computedPrice; + if (effectivePrice <= *bidMax) + return effectivePrice; JLOG(ctx.journal.debug()) - << "AMM Bid: not in range " << computedPrice << " " << *bidMax; + << "AMM Bid: not in range " << effectivePrice << " " << *bidMax; return std::nullopt; } - return computedPrice; + return effectivePrice; }(); if (!payPrice) { diff --git a/src/test/app/AMMMPT_test.cpp b/src/test/app/AMMMPT_test.cpp index bfd2d529b5..ac9728ede1 100644 --- a/src/test/app/AMMMPT_test.cpp +++ b/src/test/app/AMMMPT_test.cpp @@ -3992,24 +3992,30 @@ private: [&](AMM& ammAlice, Env& env) { // Bid a tiny amount auto const tiny = Number{STAmount::kMinValue, STAmount::kMinOffset}; + auto const cleanup340 = env.current()->rules().enabled(fixCleanup3_4_0); + auto const minBidPrice = IOUAmount{ammAuctionMinSlotPrice(ammAlice.tokens(), 1)}; + auto const firstPrice = cleanup340 ? minBidPrice : IOUAmount{tiny}; env(ammAlice.bid({.account = alice_, .bidMin = IOUAmount{tiny}})); - // Auction slot purchase price is equal to the tiny amount - // since the minSlotPrice is 0 with no trading fee. - BEAST_EXPECT(ammAlice.expectAuctionSlot(0, 0, IOUAmount{tiny})); - // The purchase price is too small to affect the total tokens + BEAST_EXPECT(ammAlice.expectAuctionSlot(0, 0, firstPrice)); BEAST_EXPECT(ammAlice.expectBalances( - MPT(ammAlice[0])(10'000'000'000), USD(10'000), ammAlice.tokens())); + MPT(ammAlice[0])(10'000'000'000), + USD(10'000), + cleanup340 ? IOUAmount{Number{ammAlice.tokens()} - Number{minBidPrice}} + : ammAlice.tokens())); // Bid the tiny amount env(ammAlice.bid({ .account = alice_, .bidMin = IOUAmount{STAmount::kMinValue, STAmount::kMinOffset}, })); // Pay slightly higher price - BEAST_EXPECT(ammAlice.expectAuctionSlot(0, 0, IOUAmount{tiny * Number{105, -2}})); - // The purchase price is still too small to affect the total - // tokens + BEAST_EXPECT(ammAlice.expectAuctionSlot( + 0, 0, IOUAmount{Number{firstPrice} * Number{105, -2}})); BEAST_EXPECT(ammAlice.expectBalances( - MPT(ammAlice[0])(10'000'000'000), USD(10'000), ammAlice.tokens())); + MPT(ammAlice[0])(10'000'000'000), + USD(10'000), + cleanup340 + ? IOUAmount{Number{ammAlice.tokens()} - Number{minBidPrice} * Number{11, -1}} + : ammAlice.tokens())); }, {{gAmmmpt(10'000'000'000), USD(10'000)}}); @@ -7489,6 +7495,7 @@ private: testFeeVote(); testInvalidBid(); testBid(all); + testBid(all - fixCleanup3_4_0); testClawback(); testClawbackFromAMMAccount(all); testClawbackFromAMMAccount(all - featureSingleAssetVault); diff --git a/src/test/app/AMM_test.cpp b/src/test/app/AMM_test.cpp index e1732aaf0e..0212035c6e 100644 --- a/src/test/app/AMM_test.cpp +++ b/src/test/app/AMM_test.cpp @@ -3127,27 +3127,59 @@ private: std::nullopt, {features}); + // Zero-fee bid without an explicit price pays a floor with fixCleanup3_4_0. + testAMM( + [&](AMM& ammAlice, Env& env) { + auto const minBidPrice = IOUAmount{ammAuctionMinSlotPrice(ammAlice.tokens(), 1)}; + auto const cleanup340 = features[fixCleanup3_4_0]; + auto const expectedPrice = cleanup340 ? minBidPrice : IOUAmount{0}; + auto const expectedTokens = cleanup340 + ? IOUAmount{Number{ammAlice.tokens()} - Number{minBidPrice}} + : ammAlice.tokens(); + + env.close(seconds(kTotalTimeSlotSecs + 1)); + env.close(); + env(ammAlice.bid({.account = alice_})); + BEAST_EXPECT(ammAlice.expectAuctionSlot(0, 0, expectedPrice)); + BEAST_EXPECT(ammAlice.expectBalances(XRP(10'000), USD(10'000), expectedTokens)); + + ammAlice.vote(alice_, 1'000); + BEAST_EXPECT(ammAlice.expectAuctionSlot(100, 0, expectedPrice)); + }, + std::nullopt, + 0, + std::nullopt, + {features}); + // Bid tiny amount testAMM( [&](AMM& ammAlice, Env& env) { // Bid a tiny amount auto const tiny = Number{STAmount::kMinValue, STAmount::kMinOffset}; + auto const cleanup340 = features[fixCleanup3_4_0]; + auto const minBidPrice = IOUAmount{ammAuctionMinSlotPrice(ammAlice.tokens(), 1)}; + auto const firstPrice = cleanup340 ? minBidPrice : IOUAmount{tiny}; env(ammAlice.bid({.account = alice_, .bidMin = IOUAmount{tiny}})); - // Auction slot purchase price is equal to the tiny amount - // since the minSlotPrice is 0 with no trading fee. - BEAST_EXPECT(ammAlice.expectAuctionSlot(0, 0, IOUAmount{tiny})); - // The purchase price is too small to affect the total tokens - BEAST_EXPECT(ammAlice.expectBalances(XRP(10'000), USD(10'000), ammAlice.tokens())); + BEAST_EXPECT(ammAlice.expectAuctionSlot(0, 0, firstPrice)); + BEAST_EXPECT(ammAlice.expectBalances( + XRP(10'000), + USD(10'000), + cleanup340 ? IOUAmount{Number{ammAlice.tokens()} - Number{minBidPrice}} + : ammAlice.tokens())); // Bid the tiny amount env(ammAlice.bid({ .account = alice_, .bidMin = IOUAmount{STAmount::kMinValue, STAmount::kMinOffset}, })); // Pay slightly higher price - BEAST_EXPECT(ammAlice.expectAuctionSlot(0, 0, IOUAmount{tiny * Number{105, -2}})); - // The purchase price is still too small to affect the total - // tokens - BEAST_EXPECT(ammAlice.expectBalances(XRP(10'000), USD(10'000), ammAlice.tokens())); + BEAST_EXPECT(ammAlice.expectAuctionSlot( + 0, 0, IOUAmount{Number{firstPrice} * Number{105, -2}})); + BEAST_EXPECT(ammAlice.expectBalances( + XRP(10'000), + USD(10'000), + cleanup340 + ? IOUAmount{Number{ammAlice.tokens()} - Number{minBidPrice} * Number{11, -1}} + : ammAlice.tokens())); }, std::nullopt, 0, @@ -7436,6 +7468,7 @@ private: testFeeVote(); testInvalidBid(); testBid(all); + testBid(all - fixCleanup3_4_0); testBid(all - fixAMMv1_3); testBid(all - fixAMMv1_1 - fixAMMv1_3); testInvalidAMMPayment(); From 3ab5288ef24e2c822ec8c95699558f402438e706 Mon Sep 17 00:00:00 2001 From: Gregory Tsipenyuk Date: Thu, 20 Aug 2026 19:05:28 +0000 Subject: [PATCH 06/32] fix: Enforce MPT balance invariants under the latest cleanup amendment (#7889) --- src/libxrpl/tx/invariants/MPTInvariant.cpp | 64 +++- src/test/app/Invariants_test.cpp | 377 ++++++++++++++++++--- 2 files changed, 390 insertions(+), 51 deletions(-) diff --git a/src/libxrpl/tx/invariants/MPTInvariant.cpp b/src/libxrpl/tx/invariants/MPTInvariant.cpp index 9a7e96e44f..045d03ab02 100644 --- a/src/libxrpl/tx/invariants/MPTInvariant.cpp +++ b/src/libxrpl/tx/invariants/MPTInvariant.cpp @@ -144,6 +144,8 @@ ValidMPTIssuance::finalize( // must not dangle outside that controlled lifecycle. if (rules.enabled(fixCleanup3_2_0)) { + // Not an amendment gate like the same-named flags below, just an + // accumulator, so that every violation gets logged before returning. bool invariantPasses = true; if (referenceHoldingMutated_) { @@ -474,7 +476,9 @@ ValidMPTBalanceChanges::finalize( ReadView const& view, beast::Journal const& j) { - if (isTesSuccess(result)) + auto const fix340Enabled = view.rules().enabled(fixCleanup3_4_0); + + if (isTesSuccess(result) || fix340Enabled) { // Confidential transactions are validated by ValidConfidentialMPToken. // They modify encrypted fields and sfConfidentialOutstandingAmount @@ -486,7 +490,9 @@ ValidMPTBalanceChanges::finalize( return true; } - bool const invariantPasses = !view.rules().enabled(featureMPTokensV2); + // Returned when a violation is found below, so this is the log-only + // condition. Either amendment makes the checks enforcing. + auto const invariantPasses = !(view.rules().enabled(featureMPTokensV2) || fix340Enabled); if (overflow_) { JLOG(j.fatal()) << "Invariant failed: OutstandingAmount overflow"; @@ -510,6 +516,18 @@ ValidMPTBalanceChanges::finalize( << " " << data.mptAmount; return invariantPasses; } + + // A failed transaction must not have moved MPT value; the check + // above ties mptAmount to the OutstandingAmount delta. No result + // code is exempt: on any tec the transactor discards the view and + // re-applies only offer, trust line, NFT offer and credential + // deletions (Transactor::typesForResult), none of which touch MPTs. + if (!isTesSuccess(result) && data.mptAmount != 0) + { + JLOG(j.fatal()) << "Invariant failed: OutstandingAmount balance changed on failure " + << tx.getTxnType() << " " << result; + return invariantPasses; + } } } @@ -833,7 +851,7 @@ ValidMPTTransfer::isAuthorized( bool ValidMPTTransfer::finalize( STTx const& tx, - TER const, + TER const result, XRPAmount const, ReadView const& view, beast::Journal const& j) @@ -864,9 +882,19 @@ ValidMPTTransfer::finalize( return txnType == ttAMM_CREATE || txnType == ttAMM_DEPOSIT || txnType == ttOFFER_CREATE; }(); - // Only enforce once MPTokensV2 is enabled to preserve consensus with non-V2 nodes. - // Log invariant failure error even if MPTokensV2 is disabled. - auto const invariantPasses = !view.rules().enabled(featureMPTokensV2); + auto const fix340Enabled = view.rules().enabled(fixCleanup3_4_0); + // Returned when a violation is found below, so this is the log-only + // condition. Either amendment makes the checks enforcing. + auto const invariantPasses = !(view.rules().enabled(featureMPTokensV2) || fix340Enabled); + + // A failed transaction must not persist an MPToken deletion. Pre-loop + // because deletedAuthorized_ is not issuance-scoped and orphans continue. + if (fix340Enabled && !isTesSuccess(result) && !deletedAuthorized_.empty()) + { + JLOG(j.fatal()) << "Invariant failed: MPToken deleted on failure " << txnType << " " + << result; + return invariantPasses; + } for (auto const& [mptID, values] : amount_) { @@ -876,6 +904,20 @@ ValidMPTTransfer::finalize( auto const sleIssuance = view.read(keylet::mptokenIssuance(mptID)); if (!sleIssuance) { + // MPTokenIssuanceDestroy only requires a zero OutstandingAmount, so + // an orphaned MPToken can outlive its issuance and be cleaned up + // later by a transaction of any type. There are no transfer rules + // left to check, but its balance is zero and nothing can raise it, + // so any change other than deletion is a bug. + for (auto const& [account, value] : values) + { + if (value.amtAfter.has_value() && value.amtBefore.value_or(0) != *value.amtAfter) + { + JLOG(j.fatal()) << "Invariant failed: orphaned MPToken balance changed " + << txnType << " " << result; + return invariantPasses; + } + } continue; } @@ -939,6 +981,16 @@ ValidMPTTransfer::finalize( JLOG(j.fatal()) << "Invariant failed: invalid MPToken transfer between holders"; return invariantPasses; } + + // A failed transaction must not have changed a holder's balance. One + // side is enough, unlike the transfer check above, so this also catches + // a lock/unlock moving value between sfMPTAmount and sfLockedAmount. + if (fix340Enabled && !isTesSuccess(result) && (senders > 0 || receivers > 0)) + { + JLOG(j.fatal()) << "Invariant failed: MPToken balance changed on failure " << txnType + << " " << result; + return invariantPasses; + } } return true; diff --git a/src/test/app/Invariants_test.cpp b/src/test/app/Invariants_test.cpp index ced2dea9bb..dcd22ffda6 100644 --- a/src/test/app/Invariants_test.cpp +++ b/src/test/app/Invariants_test.cpp @@ -142,7 +142,11 @@ class Invariants_test : public beast::unit_test::Suite std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, Preclose const& preclose = {}, TxAccount setTxAccount = TxAccount::None, - std::source_location const& loc = std::source_location::current()) + std::source_location const& loc = std::source_location::current(), + // Result fed to the invariant checker on the first pass. Set it to a + // tec to exercise result-dependent invariants; the harness runs no + // transactor, so one never arises on its own. + TER initialResult = tesSUCCESS) { doInvariantCheck( makeEnv(defaultAmendments()), @@ -153,7 +157,8 @@ class Invariants_test : public beast::unit_test::Suite ters, preclose, setTxAccount, - loc); + loc, + initialResult); } void @@ -166,7 +171,8 @@ class Invariants_test : public beast::unit_test::Suite std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, Preclose const& preclose = {}, TxAccount setTxAccount = TxAccount::None, - std::source_location const& loc = std::source_location::current()) + std::source_location const& loc = std::source_location::current(), + TER initialResult = tesSUCCESS) { using namespace test::jtx; @@ -180,7 +186,8 @@ class Invariants_test : public beast::unit_test::Suite if (setTxAccount != TxAccount::None) tx.setAccountID(sfAccount, setTxAccount == TxAccount::A1 ? a1.id() : a2.id()); - doInvariantCheck(std::move(env), a1, a2, expectLogs, precheck, fee, tx, ters, loc); + doInvariantCheck( + std::move(env), a1, a2, expectLogs, precheck, fee, tx, ters, loc, initialResult); } void @@ -194,7 +201,8 @@ class Invariants_test : public beast::unit_test::Suite XRPAmount fee = XRPAmount{}, STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}}, std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - std::source_location const& loc = std::source_location::current()) + std::source_location const& loc = std::source_location::current(), + TER initialResult = tesSUCCESS) { using namespace test::jtx; @@ -213,13 +221,18 @@ class Invariants_test : public beast::unit_test::Suite if (!BEAST_EXPECT(transactor)) return; - // invoke check twice to cover tec and tef cases + // Invoke the check twice to cover the tec and tef cases. Both passes run + // against the same view -- production would discard it in between -- so + // the second sees the same violation and escalates tec -> tef. A + // {tec, tef} pair therefore means "enforced whatever the incoming + // result", not that the transaction ends in tef on ledger. if (!BEAST_EXPECT(ters.size() == 2)) return; - TER terActual = tesSUCCESS; + TER terActual = initialResult; for (TER const& terExpect : ters) { + TER const terInput = terActual; terActual = transactor->checkInvariants(terActual, fee, Transactor::InvariantScope::Full); expect( @@ -229,7 +242,10 @@ class Invariants_test : public beast::unit_test::Suite loc.line()); auto const messages = sink.messages().str(); - if (!isTesSuccess(terActual)) + // checkInvariants returns its input unchanged unless something + // fires, so a changed result means an invariant fired, and a firing + // invariant must log. + if (terActual != terInput) { expect( messages.starts_with("Invariant failed:") || @@ -3441,7 +3457,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp, TxAccount::A2); @@ -3522,7 +3538,7 @@ class Invariants_test : public beast::unit_test::Suite XRPAmount{}, STTx{ ttVAULT_DEPOSIT, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(200)); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp, TxAccount::A2); @@ -3608,7 +3624,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp, TxAccount::A2); @@ -3629,7 +3645,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_DEPOSIT, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp, TxAccount::A2); @@ -3738,6 +3754,7 @@ class Invariants_test : public beast::unit_test::Suite { "created vault must be empty", "create operation must not have updated a vault", + "invalid OutstandingAmount balance 0 9 0", }, [&](Account const& a1, Account const& a2, ApplyContext& ac) { auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); @@ -3754,7 +3771,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_CREATE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, [&](Account const& a1, Account const& a2, Env& env) { Vault const vault{env}; auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()}); @@ -3998,7 +4015,7 @@ class Invariants_test : public beast::unit_test::Suite XRPAmount{}, STTx{ ttVAULT_DEPOSIT, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(200)); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp, TxAccount::A2); @@ -4029,7 +4046,7 @@ class Invariants_test : public beast::unit_test::Suite tx[sfFee] = XRPAmount(100); tx[sfAccount] = a3.id(); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp); doInvariantCheck( @@ -4055,7 +4072,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp, TxAccount::A2); @@ -4077,7 +4094,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp, TxAccount::A2); @@ -4091,7 +4108,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp, TxAccount::A2); @@ -4106,7 +4123,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp, TxAccount::A2); @@ -4124,7 +4141,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(5); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp, TxAccount::A2); @@ -4148,7 +4165,7 @@ class Invariants_test : public beast::unit_test::Suite tx[sfDelegate] = a3.id(); tx[sfFee] = XRPAmount(2000); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp, TxAccount::A2); @@ -4164,7 +4181,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp, TxAccount::A2); @@ -4211,7 +4228,7 @@ class Invariants_test : public beast::unit_test::Suite // This commented out line causes the invariant violation. // tx[sfDestination] = A4.id(); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp); doInvariantCheck( @@ -4239,7 +4256,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp, TxAccount::A2); @@ -4260,7 +4277,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_WITHDRAW, [&](STObject& tx) { tx.setAccountID(sfDestination, a3.id()); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp, TxAccount::A2); @@ -4274,7 +4291,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp, TxAccount::A2); @@ -4288,7 +4305,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp, TxAccount::A2); @@ -4305,7 +4322,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp, TxAccount::A2); @@ -4321,7 +4338,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp, TxAccount::A2); @@ -4345,7 +4362,7 @@ class Invariants_test : public beast::unit_test::Suite tx[sfDelegate] = a3.id(); tx[sfFee] = XRPAmount(2000); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseXrp, TxAccount::A2); @@ -4408,7 +4425,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_WITHDRAW, [&](STObject& tx) { tx[sfAccount] = a3.id(); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseMpt, TxAccount::A2); @@ -4424,7 +4441,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_CLAWBACK, [&](STObject& tx) { tx[sfAccount] = a3.id(); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseMpt); // Not the same as below check: attempt to clawback XRP @@ -4470,7 +4487,7 @@ class Invariants_test : public beast::unit_test::Suite tx[sfAccount] = a3.id(); tx[sfHolder] = a4.id(); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseMpt); doInvariantCheck( @@ -4489,7 +4506,7 @@ class Invariants_test : public beast::unit_test::Suite tx[sfAccount] = a3.id(); tx[sfHolder] = a4.id(); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseMpt); doInvariantCheck( @@ -4512,7 +4529,7 @@ class Invariants_test : public beast::unit_test::Suite tx[sfAccount] = a3.id(); tx[sfHolder] = a4.id(); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseMpt); // ───────────────────────────────────────────────────────────── @@ -4686,7 +4703,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseClosedEnded(/*advanceBySub=*/1, /*doDeposit=*/true), TxAccount::A2); @@ -4701,7 +4718,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseClosedEnded(/*advanceBySub=*/1, /*doDeposit=*/true), TxAccount::A2); @@ -4910,7 +4927,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttPAYMENT, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, [&](Account const& a1, Account const& a2, Env& env) { Account const gw("gw"); env.fund(XRP(1'000), gw); @@ -4940,6 +4957,199 @@ class Invariants_test : public beast::unit_test::Suite return true; }); + // The on-failure MPT checks (OutstandingAmount balance / transfer) apply + // to every non-tesSUCCESS result, with no per-result exemption: on a tec + // the transactor discards the view and re-applies only offer, trust + // line, NFT offer and credential deletions, so an MPT change reaching + // the invariant is a bug whatever the code. Seeded via initialResult. + { + MPTID id; + // preclose: gw issues an MPT held by A1 and A2. + auto const setup = [&](Account const& a1, Account const& a2, Env& env) { + Account const gw("gw"); + env.fund(XRP(1'000), gw); + MPTTester const mpt( + {.env = env, .issuer = gw, .holders = {a1, a2}, .pay = 50, .maxAmt = 1'000}); + id = mpt.issuanceID(); + return true; + }; + + // Consistent mint: OutstandingAmount and A1's balance both grow by + // 10, so conservation holds and only the on-failure check fires. + Precheck const mint = [&](Account const& a1, Account const&, ApplyContext& ac) { + auto sleIss = ac.view().peek(keylet::mptokenIssuance(id)); + auto sleTok = ac.view().peek(keylet::mptoken(id, a1.id())); + if (!sleIss || !sleTok) + return false; + (*sleIss)[sfOutstandingAmount] = (*sleIss)[sfOutstandingAmount] + 10; + (*sleTok)[sfMPTAmount] = (*sleTok)[sfMPTAmount] + 10; + ac.view().update(sleIss); + ac.view().update(sleTok); + return true; + }; + + // Holder-to-holder transfer (A1 -> A2 by 10). OutstandingAmount is + // unchanged, and CanTransfer keeps the ordinary transfer check + // quiet, so only the on-failure check fires. + Precheck const transfer = [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto sleIss = ac.view().peek(keylet::mptokenIssuance(id)); + auto sleA = ac.view().peek(keylet::mptoken(id, a1.id())); + auto sleB = ac.view().peek(keylet::mptoken(id, a2.id())); + if (!sleIss || !sleA || !sleB) + return false; + (*sleIss)[sfFlags] = (*sleIss)[sfFlags] | lsfMPTCanTransfer; + (*sleA)[sfMPTAmount] = (*sleA)[sfMPTAmount] - 10; + (*sleB)[sfMPTAmount] = (*sleB)[sfMPTAmount] + 10; + ac.view().update(sleIss); + ac.view().update(sleA); + ac.view().update(sleB); + return true; + }; + + STTx const payment{ttPAYMENT, [](STObject&) {}}; + + // Negative controls: nothing fires on tesSUCCESS. Without these, the + // cases below would still pass if the result guard were dropped. + doInvariantCheck({}, mint, XRPAmount{}, payment, {tesSUCCESS, tesSUCCESS}, setup); + doInvariantCheck({}, transfer, XRPAmount{}, payment, {tesSUCCESS, tesSUCCESS}, setup); + + // tecKILLED and tecINCOMPLETE are not special: an MPT change paired + // with either fires, as with any other failure. + doInvariantCheck( + {{"OutstandingAmount balance changed on failure"}}, + mint, + XRPAmount{}, + payment, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + setup, + TxAccount::None, + std::source_location::current(), + tecKILLED); + doInvariantCheck( + {{"OutstandingAmount balance changed on failure"}}, + mint, + XRPAmount{}, + payment, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + setup, + TxAccount::None, + std::source_location::current(), + tecINCOMPLETE); + doInvariantCheck( + {{"MPToken balance changed on failure"}}, + transfer, + XRPAmount{}, + payment, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + setup, + TxAccount::None, + std::source_location::current(), + tecKILLED); + doInvariantCheck( + {{"MPToken balance changed on failure"}}, + transfer, + XRPAmount{}, + payment, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + setup, + TxAccount::None, + std::source_location::current(), + tecINCOMPLETE); + // The same change under a third failure result: the check keys off + // "not tesSUCCESS", nothing finer. + doInvariantCheck( + {{"OutstandingAmount balance changed on failure"}}, + mint, + XRPAmount{}, + payment, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + setup, + TxAccount::None, + std::source_location::current(), + tecEXPIRED); + doInvariantCheck( + {{"MPToken balance changed on failure"}}, + transfer, + XRPAmount{}, + payment, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + setup, + TxAccount::None, + std::source_location::current(), + tecEXPIRED); + + // A lock moves value within one holder, so it is not a two-sided + // transfer and the `senders || receivers` form is what catches it. + // OutstandingAmount and the holder total are unchanged, so the + // balance check stays quiet. + Precheck const lock = [&](Account const& a1, Account const&, ApplyContext& ac) { + auto sleTok = ac.view().peek(keylet::mptoken(id, a1.id())); + if (!sleTok || (*sleTok)[sfMPTAmount] < 10) + return false; + // A fresh MPToken has no locked amount, so set it directly. + (*sleTok)[sfMPTAmount] = (*sleTok)[sfMPTAmount] - 10; + sleTok->setFieldU64(sfLockedAmount, 10); + ac.view().update(sleTok); + return true; + }; + // Negative control: a lock is legitimate on tesSUCCESS. + doInvariantCheck({}, lock, XRPAmount{}, payment, {tesSUCCESS, tesSUCCESS}, setup); + doInvariantCheck( + {{"MPToken balance changed on failure"}}, + lock, + XRPAmount{}, + payment, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + setup, + TxAccount::None, + std::source_location::current(), + tecKILLED); + // The lock is caught under any failure result. + doInvariantCheck( + {{"MPToken balance changed on failure"}}, + lock, + XRPAmount{}, + payment, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + setup, + TxAccount::None, + std::source_location::current(), + tecEXPIRED); + + // A deleted MPToken has no amtAfter, so the sender/receiver counts + // skip it and only the deletedAuthorized_ term can catch it. That + // needs holders authorized but never paid, so the MPToken can be + // erased with a zero balance and OutstandingAmount untouched -- + // otherwise the holder would register as a sender instead. + MPTID emptyId; + auto const setupEmpty = [&](Account const& a1, Account const& a2, Env& env) { + Account const gw("gw"); + env.fund(XRP(1'000), gw); + MPTTester const mpt({.env = env, .issuer = gw, .holders = {a1, a2}, .maxAmt = 100}); + emptyId = mpt.issuanceID(); + return true; + }; + Precheck const eraseToken = [&](Account const& a1, Account const&, ApplyContext& ac) { + auto sleTok = ac.view().peek(keylet::mptoken(emptyId, a1.id())); + if (!sleTok || (*sleTok)[sfMPTAmount] != 0) + return false; + ac.view().erase(sleTok); + return true; + }; + // ValidMPTIssuance also reports the deletion, so assert on + // ValidMPTTransfer's message, which only the new check can produce. + doInvariantCheck( + {{"MPToken deleted on failure"}}, + eraseToken, + XRPAmount{}, + payment, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + setupEmpty, + TxAccount::None, + std::source_location::current(), + tecEXPIRED); + } + // Invalid IOU clawback delta must fail once MPTokensV2 enforces before/after validation. { Env env(*this, defaultAmendments()); @@ -5635,7 +5845,13 @@ class Invariants_test : public beast::unit_test::Suite std::make_pair(ttAMM_WITHDRAW, false), std::make_pair(ttPAYMENT, false), std::make_pair(ttPAYMENT, true)}; - for (auto const enabled : {true, false}) + // The two amendments that gate enforcement, in all four combinations. + FeatureBitset const gatesEnabled{featureMPTokensV2, fixCleanup3_4_0}; + for (auto const gates : + {gatesEnabled, + gatesEnabled - featureMPTokensV2, + gatesEnabled - fixCleanup3_4_0, + FeatureBitset{}}) { for (auto const& [tx, crossCurrencyPayment] : invalidTransferTests) { @@ -5646,7 +5862,7 @@ class Invariants_test : public beast::unit_test::Suite 0u}) { MPTID id{}; - auto const isSuccess = !enabled || flag == 0 || + auto const isSuccess = !gates.any() || flag == 0 || (tx == ttPAYMENT && !crossCurrencyPayment && (flag == ~lsfMPTCanTrade)) || (tx == ttAMM_WITHDRAW && (flag == ~lsfMPTCanTrade || flag == ~lsfMPTCanTransfer)); @@ -5697,16 +5913,83 @@ class Invariants_test : public beast::unit_test::Suite MPTTester const usd( {.env = env, .issuer = gw, .holders = {a1, a2}, .pay = 100}); id = usd.issuanceID(); - if (!enabled) - { + // Either gate enforces, so both must be off to stay + // advisory. Disable after setting up the MPT; the + // next env.close() is what makes it take effect. + if (!gates[featureMPTokensV2]) env.disableFeature(featureMPTokensV2); - } + if (!gates[fixCleanup3_4_0]) + env.disableFeature(fixCleanup3_4_0); return true; }); } } } + // An orphan has a zero balance, so only deletion is legitimate (see + // "Skipping Deleted MPTs" in testConfidentialMPTTransfer). + { + MPTID orphanID; + auto const setupOrphan = [&](Account const& a1, Account const& a2, Env& env) { + MPTTester mpt(env, a1, {.holders = {a2}, .fund = false}); + mpt.create({.flags = tfMPTCanTransfer}); + orphanID = mpt.issuanceID(); + // A2 is authorized but never paid, so its balance is zero and + // the issuance can be destroyed while its MPToken lives on. + mpt.authorize({.account = a2}); + mpt.destroy(); + return true; + }; + // ValidMPTBalanceChanges also reports this, so assert on the + // orphan message, which only the missing-issuance branch produces. + doInvariantCheck( + {{"orphaned MPToken balance changed"}}, + [&](Account const&, Account const& a2, ApplyContext& ac) { + auto sleTok = ac.view().peek(keylet::mptoken(orphanID, a2.id())); + if (!sleTok || (*sleTok)[sfMPTAmount] != 0) + return false; + (*sleTok)[sfMPTAmount] = (*sleTok)[sfMPTAmount] + 10; + ac.view().update(sleTok); + return true; + }, + XRPAmount{}, + STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + setupOrphan); + // Negative control: erasing the orphan is how it gets cleaned up. + doInvariantCheck( + {}, + [&](Account const&, Account const& a2, ApplyContext& ac) { + auto sleTok = ac.view().peek(keylet::mptoken(orphanID, a2.id())); + if (!sleTok) + return false; + ac.view().erase(sleTok); + return true; + }, + XRPAmount{}, + STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, + {tesSUCCESS, tesSUCCESS}, + setupOrphan); + // The same erase on a failure. The orphan branch continues, so only + // the pre-loop deletion check can report this one. + doInvariantCheck( + {{"MPToken deleted on failure"}}, + [&](Account const&, Account const& a2, ApplyContext& ac) { + auto sleTok = ac.view().peek(keylet::mptoken(orphanID, a2.id())); + if (!sleTok) + return false; + ac.view().erase(sleTok); + return true; + }, + XRPAmount{}, + STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + setupOrphan, + TxAccount::None, + std::source_location::current(), + tecEXPIRED); + } + // Vault-share freeze invariant: isVaultPseudoAccountFrozen descends // through sfReferenceHolding to test the vault's underlying asset for // each changed holder. @@ -5881,7 +6164,9 @@ class Invariants_test : public beast::unit_test::Suite for (bool const isMPT : {false, true}) { - auto const error = isMPT ? TER(tecINVARIANT_FAILED) : TER(tefINVARIANT_FAILED); + // Under fixCleanup3_4_0 the MPT balance invariants also fire on the + // second pass, so both IOU and MPT pools now escalate to tef. + auto const error = TER(tefINVARIANT_FAILED); for (auto txType : {ttAMM_CREATE, ttAMM_DEPOSIT, ttAMM_CLAWBACK, ttAMM_WITHDRAW}) { test(txType, deleteAMMAccount, isMPT, tefINVARIANT_FAILED); @@ -6700,7 +6985,9 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttCONFIDENTIAL_MPT_SEND, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + // Second pass is tef: the bumped MPTAmount also trips + // ValidMPTTransfer's on-failure check, which escalates the tec. + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, precloseConfidential); // badVersion From 85512541ad78f61555e6f06b8463190a0bbcf908 Mon Sep 17 00:00:00 2001 From: Mayukha Vadari Date: Thu, 20 Aug 2026 19:25:27 +0000 Subject: [PATCH 07/32] refactor: Collapse transactions.macro settings into a TxSettings struct (#8001) Co-authored-by: Timur Yalymov <36795566+tyalymov@users.noreply.github.com> Co-authored-by: Cursor Co-authored-by: Vito Tumas <5780819+Tapanito@users.noreply.github.com> Co-authored-by: Ayaz Salikhov --- cmake/scripts/codegen/generate_tx_classes.py | 82 ++- include/xrpl/protocol/Permissions.h | 8 +- include/xrpl/protocol/TxSettings.h | 96 ++++ .../xrpl/protocol/detail/transactions.macro | 500 ++++++++---------- .../protocol_autogen/transactions/AMMBid.h | 2 +- .../transactions/AMMClawback.h | 2 +- .../protocol_autogen/transactions/AMMCreate.h | 2 +- .../protocol_autogen/transactions/AMMDelete.h | 2 +- .../transactions/AMMDeposit.h | 2 +- .../protocol_autogen/transactions/AMMVote.h | 2 +- .../transactions/AMMWithdraw.h | 2 +- .../transactions/AccountDelete.h | 2 +- .../transactions/AccountSet.h | 2 +- .../protocol_autogen/transactions/Batch.h | 2 +- .../transactions/CheckCancel.h | 2 +- .../protocol_autogen/transactions/CheckCash.h | 2 +- .../transactions/CheckCreate.h | 2 +- .../protocol_autogen/transactions/Clawback.h | 2 +- .../transactions/ConfidentialMPTClawback.h | 2 +- .../transactions/ConfidentialMPTConvert.h | 2 +- .../transactions/ConfidentialMPTConvertBack.h | 2 +- .../transactions/ConfidentialMPTMergeInbox.h | 2 +- .../transactions/ConfidentialMPTSend.h | 2 +- .../transactions/CredentialAccept.h | 2 +- .../transactions/CredentialCreate.h | 2 +- .../transactions/CredentialDelete.h | 2 +- .../protocol_autogen/transactions/DIDDelete.h | 2 +- .../protocol_autogen/transactions/DIDSet.h | 2 +- .../transactions/DelegateSet.h | 2 +- .../transactions/DepositPreauth.h | 2 +- .../transactions/EnableAmendment.h | 2 +- .../transactions/EscrowCancel.h | 2 +- .../transactions/EscrowCreate.h | 2 +- .../transactions/EscrowFinish.h | 2 +- .../transactions/LedgerStateFix.h | 2 +- .../transactions/LoanBrokerCoverClawback.h | 2 +- .../transactions/LoanBrokerCoverDeposit.h | 2 +- .../transactions/LoanBrokerCoverWithdraw.h | 2 +- .../transactions/LoanBrokerDelete.h | 2 +- .../transactions/LoanBrokerSet.h | 2 +- .../transactions/LoanDelete.h | 2 +- .../transactions/LoanManage.h | 2 +- .../protocol_autogen/transactions/LoanPay.h | 2 +- .../protocol_autogen/transactions/LoanSet.h | 2 +- .../transactions/MPTokenAuthorize.h | 2 +- .../transactions/MPTokenIssuanceCreate.h | 2 +- .../transactions/MPTokenIssuanceDestroy.h | 2 +- .../transactions/MPTokenIssuanceSet.h | 2 +- .../transactions/NFTokenAcceptOffer.h | 2 +- .../transactions/NFTokenBurn.h | 2 +- .../transactions/NFTokenCancelOffer.h | 2 +- .../transactions/NFTokenCreateOffer.h | 2 +- .../transactions/NFTokenMint.h | 2 +- .../transactions/NFTokenModify.h | 2 +- .../transactions/OfferCancel.h | 2 +- .../transactions/OfferCreate.h | 2 +- .../transactions/OracleDelete.h | 2 +- .../protocol_autogen/transactions/OracleSet.h | 2 +- .../protocol_autogen/transactions/Payment.h | 2 +- .../transactions/PaymentChannelClaim.h | 2 +- .../transactions/PaymentChannelCreate.h | 2 +- .../transactions/PaymentChannelFund.h | 2 +- .../transactions/PermissionedDomainDelete.h | 2 +- .../transactions/PermissionedDomainSet.h | 2 +- .../protocol_autogen/transactions/SetFee.h | 2 +- .../transactions/SetRegularKey.h | 2 +- .../transactions/SignerListSet.h | 2 +- .../transactions/SponsorshipSet.h | 2 +- .../transactions/SponsorshipTransfer.h | 2 +- .../transactions/TicketCreate.h | 2 +- .../protocol_autogen/transactions/TrustSet.h | 2 +- .../protocol_autogen/transactions/UNLModify.h | 2 +- .../transactions/VaultClawback.h | 2 +- .../transactions/VaultCreate.h | 2 +- .../transactions/VaultDelete.h | 2 +- .../transactions/VaultDeposit.h | 2 +- .../protocol_autogen/transactions/VaultSet.h | 2 +- .../transactions/VaultWithdraw.h | 2 +- .../transactions/XChainAccountCreateCommit.h | 2 +- .../XChainAddAccountCreateAttestation.h | 2 +- .../transactions/XChainAddClaimAttestation.h | 2 +- .../transactions/XChainClaim.h | 2 +- .../transactions/XChainCommit.h | 2 +- .../transactions/XChainCreateBridge.h | 2 +- .../transactions/XChainCreateClaimID.h | 2 +- .../transactions/XChainModifyBridge.h | 2 +- .../tx/invariants/InvariantCheckPrivilege.h | 37 +- src/libxrpl/protocol/Permissions.cpp | 15 +- src/libxrpl/protocol/TxFormats.cpp | 2 +- src/libxrpl/tx/invariants/FreezeInvariant.cpp | 3 +- src/libxrpl/tx/invariants/InvariantCheck.cpp | 21 +- src/libxrpl/tx/invariants/MPTInvariant.cpp | 17 +- src/libxrpl/tx/invariants/NFTInvariant.cpp | 2 +- src/libxrpl/tx/invariants/VaultInvariant.cpp | 5 +- src/test/app/Delegate_test.cpp | 14 +- 95 files changed, 520 insertions(+), 446 deletions(-) create mode 100644 include/xrpl/protocol/TxSettings.h diff --git a/cmake/scripts/codegen/generate_tx_classes.py b/cmake/scripts/codegen/generate_tx_classes.py index 07baefd8b6..09fb898840 100644 --- a/cmake/scripts/codegen/generate_tx_classes.py +++ b/cmake/scripts/codegen/generate_tx_classes.py @@ -8,6 +8,7 @@ Uses pcpp to preprocess the macro file and pyparsing to parse the DSL. import io import argparse +import re from pathlib import Path import pyparsing as pp @@ -53,28 +54,89 @@ def create_transaction_parser(): return macro_parser +# Defaults for xrpl::TxSettings members, mirroring +# include/xrpl/protocol/TxSettings.h. A transaction's settings blob only names +# the members that differ from these. +SETTING_DEFAULTS = { + "delegable": "Delegation::NotDelegable", + "amendment": "uint256{}", + "privileges": "Privilege::NoPriv", +} + + +def parse_settings(settings_str): + """Parse a TxSettings blob into a dict, filling in defaults. + + Args: + settings_str: A string like '({.delegable = Delegation::NotDelegable, + .privileges = Privilege::CreateAcct})', or '({})'. + + Returns: + A dict with a value for every key in SETTING_DEFAULTS. + """ + body = settings_str.strip() + if not (body.startswith("(") and body.endswith(")")): + raise ValueError( + f"Malformed settings blob, expected '({{...}})': {settings_str!r}" + ) + body = body[1:-1].strip() + if not (body.startswith("{") and body.endswith("}")): + raise ValueError( + f"Malformed settings blob, expected '({{...}})': {settings_str!r}" + ) + body = body[1:-1] + + # Strip comments, which may be interleaved with the designated initializers. + body = re.sub(r"//[^\n]*", "", body) + + settings = dict(SETTING_DEFAULTS) + seen = set() + # Each entry runs from '.key =' up to the next '.key =' or the end. + for key, value in re.findall( + r"\.(\w+)\s*=\s*(.*?)(?=,\s*\.\w+\s*=|,?\s*$)", body, re.S + ): + if key not in SETTING_DEFAULTS: + raise ValueError(f"Unknown TxSettings member '.{key}' in {settings_str!r}") + settings[key] = " ".join(value.split()).rstrip(",") + seen.add(key) + + # Catch a typo'd or unparsed initializer rather than silently defaulting it. + # Every '.member' in the blob must have been consumed above. + if len(re.findall(r"\.\w+", body)) != len(seen): + raise ValueError(f"Could not parse every setting in {settings_str!r}") + + # A blob with content but no designated initializer is positional, which + # would otherwise be read as "all defaults" and silently generate the + # wrong output. + if body.strip() and not seen: + raise ValueError( + "TxSettings requires designated initializers (.member = value), " + f"got {settings_str!r}" + ) + + return settings + + def parse_transaction_args(args_list): """Parse the arguments of a TRANSACTION macro call. Args: args_list: A list of parsed arguments from pyparsing, e.g., - ['ttPAYMENT', '0', 'Payment', 'Delegation::delegable', - 'uint256{}', 'createAcct', '({...})'] + ['ttPAYMENT', '0', 'Payment', + '({.privileges = Privilege::CreateAcct})', '({...})'] Returns: A dict with parsed transaction information. """ - if len(args_list) < 7: + if len(args_list) < 5: raise ValueError( - f"Expected at least 7 parts in TRANSACTION, got {len(args_list)}: {args_list}" + f"Expected at least 5 parts in TRANSACTION, got {len(args_list)}: {args_list}" ) tag = args_list[0] value = args_list[1] name = args_list[2] - delegable = args_list[3] - amendments = args_list[4] - privileges = args_list[5] + settings = parse_settings(args_list[3]) fields_str = args_list[-1] # Parse fields: ({field1, field2, ...}) @@ -84,9 +146,9 @@ def parse_transaction_args(args_list): "tag": tag, "value": value, "name": name, - "delegable": delegable, - "amendments": amendments, - "privileges": privileges, + "delegable": settings["delegable"], + "amendments": settings["amendment"], + "privileges": settings["privileges"], "fields": fields, } diff --git a/include/xrpl/protocol/Permissions.h b/include/xrpl/protocol/Permissions.h index 703a0939c9..2a3f561a10 100644 --- a/include/xrpl/protocol/Permissions.h +++ b/include/xrpl/protocol/Permissions.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -38,11 +39,6 @@ enum GranularPermissionType : std::uint32_t { #pragma pop_macro("GRANULAR_PERMISSION") }; -// Injected bare enumerators (xrpl::delegable / xrpl::notDelegable) are required by preprocessor -// tricks in tests and macro-generated code; enum class would break that. -// NOLINTNEXTLINE(cppcoreguidelines-use-enum-class) -enum Delegation { Delegable, NotDelegable }; - class Permission { private: @@ -65,7 +61,7 @@ private: struct TxDelegationEntry { uint256 amendment; - Delegation delegable{NotDelegable}; + Delegation delegable{Delegation::NotDelegable}; }; std::unordered_set granularTxTypes_; diff --git a/include/xrpl/protocol/TxSettings.h b/include/xrpl/protocol/TxSettings.h new file mode 100644 index 0000000000..8ea249856a --- /dev/null +++ b/include/xrpl/protocol/TxSettings.h @@ -0,0 +1,96 @@ +#pragma once + +#include +#include + +#include +#include + +namespace xrpl { + +enum class Delegation { Delegable, NotDelegable }; + +/** + * Operations a transaction is permitted to perform, as a bitfield. + * + * These are declared per-transaction in transactions.macro (via + * TxSettings::privileges) and enforced in InvariantCheck.cpp. + */ +enum class Privilege : std::uint16_t { + NoPriv = 0x0000, // The transaction can not do any of the enumerated operations + CreateAcct = 0x0001, // The transaction can create a new ACCOUNT_ROOT object. + CreatePseudoAcct = 0x0002, // The transaction can create a pseudo account, + // which implies createAcct + MustDeleteAcct = 0x0004, // The transaction must delete an ACCOUNT_ROOT object + MayDeleteAcct = 0x0008, // The transaction may delete an ACCOUNT_ROOT + // object, but does not have to + OverrideFreeze = 0x0010, // The transaction can override some freeze rules + ChangeNftCounts = 0x0020, // The transaction can mint or burn an NFT + CreateMptIssuance = 0x0040, // The transaction can create a new MPT issuance + DestroyMptIssuance = 0x0080, // The transaction can destroy an MPT issuance + MustAuthorizeMpt = 0x0100, // The transaction MUST create or delete an MPT + // object (except by issuer) + MayAuthorizeMpt = 0x0200, // The transaction MAY create or delete an MPT + // object (except by issuer) + MayDeleteMpt = 0x0400, // The transaction MAY delete an MPT object. May not create. + MustModifyVault = 0x0800, // The transaction must modify, delete or create, a vault + MayModifyVault = 0x1000, // The transaction MAY modify, delete or create, a vault + MayCreateMpt = 0x2000, // The transaction MAY create an MPT object, except for issuer. +}; + +// The inner static_cast is not redundant: the underlying type is narrower than +// `int`, so the operands integer-promote and the result has to be narrowed back. +// safeCast rejects that narrowing, but every input bit is a Privilege bit by +// construction, so the result is always representable. +constexpr Privilege +operator|(Privilege lhs, Privilege rhs) +{ + using Underlying = std::underlying_type_t; + return static_cast( + static_cast(safeCast(lhs) | safeCast(rhs))); +} + +constexpr Privilege +operator&(Privilege lhs, Privilege rhs) +{ + using Underlying = std::underlying_type_t; + return static_cast( + static_cast(safeCast(lhs) & safeCast(rhs))); +} + +/** + * Per-transaction metadata declared in transactions.macro. + * + * Every member has a default, so a transaction only needs to name the settings + * that differ from the common case. See the documentation at the top of + * transactions.macro for the authoring syntax. + * + * This is deliberately not a constexpr-friendly type: amendment identifiers are + * runtime-initialized `extern uint256 const` globals (see Feature.h), so a + * TxSettings can only be built at runtime. + */ +struct TxSettings +{ + /** + * Whether an account may delegate this transaction to another account. + */ + Delegation delegable{Delegation::NotDelegable}; + + /** + * The amendment gating this transaction, or uint256{} if always available. + */ + // The `{}` looks redundant, because BaseUInt's default constructor already + // zeroes the value. It is not: without a default member initializer here, + // every partial designated initializer in transactions.macro trips the + // missing-designated-field-initializers warning, which the build treats as + // an error. + // NOLINTNEXTLINE(readability-redundant-member-init) + uint256 amendment{}; + + /** + * Operations this transaction is permitted to perform. + */ + Privilege privileges{Privilege::NoPriv}; +}; + +} // namespace xrpl diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index 997f368638..dbf9b66ac7 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -3,7 +3,7 @@ #endif /** - * TRANSACTION(tag, value, name, delegable, amendments, privileges, fields) + * TRANSACTION(tag, value, name, settings, fields) * * To ease maintenance, you may replace any unneeded values with "..." * e.g. #define TRANSACTION(tag, value, name, ...) @@ -15,9 +15,31 @@ * # include * #endif * - * The `privileges` parameter of the TRANSACTION macro is a bitfield - * defining which operations the transaction can perform. - * The values are defined and used in InvariantCheck.cpp + * `settings` is a parenthesized brace-init-list for xrpl::TxSettings, declared + * in : + * + * struct TxSettings + * { + * Delegation delegable{Delegation::NotDelegable}; + * uint256 amendment{}; + * Privilege privileges{Privilege::NoPriv}; + * }; + * + * Name only the settings that differ from those defaults, in declaration + * order; use `({})` when none of them do: + * + * ({.delegable = Delegation::Delegable, .amendment = featureFoo}) + * + * You must use designated initializers, as shown above. Positional + * initialization such as `({Delegation::NotDelegable})` is not supported, + * because the code generator reads these settings by member name. + * + * The `privileges` setting is a bitfield defining which operations the + * transaction can perform. The values are defined in TxSettings.h and + * enforced in InvariantCheck.cpp. + * + * A consumer that only needs some of the settings can unwrap the blob with + * `#define UNWRAP(...) __VA_ARGS__` and write `TxSettings UNWRAP settings`. */ /** This transaction type executes a payment. */ @@ -25,9 +47,7 @@ # include #endif TRANSACTION(ttPAYMENT, 0, Payment, - Delegation::Delegable, - uint256{}, - CreateAcct | MayCreateMpt, + ({.delegable = Delegation::Delegable, .privileges = Privilege::CreateAcct | Privilege::MayCreateMpt}), ({ {sfDestination, SoeRequired}, {sfAmount, SoeRequired, SoeMptSupported}, @@ -44,11 +64,7 @@ TRANSACTION(ttPAYMENT, 0, Payment, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttESCROW_CREATE, 1, EscrowCreate, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttESCROW_CREATE, 1, EscrowCreate, ({.delegable = Delegation::Delegable}), ({ {sfDestination, SoeRequired}, {sfAmount, SoeRequired, SoeMptSupported}, {sfCondition, SoeOptional}, @@ -61,11 +77,7 @@ TRANSACTION(ttESCROW_CREATE, 1, EscrowCreate, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttESCROW_FINISH, 2, EscrowFinish, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttESCROW_FINISH, 2, EscrowFinish, ({.delegable = Delegation::Delegable}), ({ {sfOwner, SoeRequired}, {sfOfferSequence, SoeRequired}, {sfFulfillment, SoeOptional}, @@ -79,9 +91,7 @@ TRANSACTION(ttESCROW_FINISH, 2, EscrowFinish, # include #endif TRANSACTION(ttACCOUNT_SET, 3, AccountSet, - Delegation::NotDelegable, - uint256{}, - NoPriv, + ({}), ({ {sfEmailHash, SoeOptional}, {sfWalletLocator, SoeOptional}, @@ -99,11 +109,7 @@ TRANSACTION(ttACCOUNT_SET, 3, AccountSet, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttESCROW_CANCEL, 4, EscrowCancel, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttESCROW_CANCEL, 4, EscrowCancel, ({.delegable = Delegation::Delegable}), ({ {sfOwner, SoeRequired}, {sfOfferSequence, SoeRequired}, })) @@ -113,9 +119,7 @@ TRANSACTION(ttESCROW_CANCEL, 4, EscrowCancel, # include #endif TRANSACTION(ttREGULAR_KEY_SET, 5, SetRegularKey, - Delegation::NotDelegable, - uint256{}, - NoPriv, + ({}), ({ {sfRegularKey, SoeOptional}, })) @@ -127,9 +131,7 @@ TRANSACTION(ttREGULAR_KEY_SET, 5, SetRegularKey, # include #endif TRANSACTION(ttOFFER_CREATE, 7, OfferCreate, - Delegation::Delegable, - uint256{}, - MayCreateMpt, + ({.delegable = Delegation::Delegable, .privileges = Privilege::MayCreateMpt}), ({ {sfTakerPays, SoeRequired, SoeMptSupported}, {sfTakerGets, SoeRequired, SoeMptSupported}, @@ -142,11 +144,7 @@ TRANSACTION(ttOFFER_CREATE, 7, OfferCreate, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttOFFER_CANCEL, 8, OfferCancel, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttOFFER_CANCEL, 8, OfferCancel, ({.delegable = Delegation::Delegable}), ({ {sfOfferSequence, SoeRequired}, })) @@ -156,11 +154,7 @@ TRANSACTION(ttOFFER_CANCEL, 8, OfferCancel, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttTICKET_CREATE, 10, TicketCreate, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttTICKET_CREATE, 10, TicketCreate, ({.delegable = Delegation::Delegable}), ({ {sfTicketCount, SoeRequired}, })) @@ -173,9 +167,7 @@ TRANSACTION(ttTICKET_CREATE, 10, TicketCreate, # include #endif TRANSACTION(ttSIGNER_LIST_SET, 12, SignerListSet, - Delegation::NotDelegable, - uint256{}, - NoPriv, + ({}), ({ {sfSignerQuorum, SoeRequired}, {sfSignerEntries, SoeOptional}, @@ -185,11 +177,7 @@ TRANSACTION(ttSIGNER_LIST_SET, 12, SignerListSet, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttPAYCHAN_CREATE, 13, PaymentChannelCreate, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttPAYCHAN_CREATE, 13, PaymentChannelCreate, ({.delegable = Delegation::Delegable}), ({ {sfDestination, SoeRequired}, {sfAmount, SoeRequired}, {sfSettleDelay, SoeRequired}, @@ -202,11 +190,7 @@ TRANSACTION(ttPAYCHAN_CREATE, 13, PaymentChannelCreate, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttPAYCHAN_FUND, 14, PaymentChannelFund, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttPAYCHAN_FUND, 14, PaymentChannelFund, ({.delegable = Delegation::Delegable}), ({ {sfChannel, SoeRequired}, {sfAmount, SoeRequired}, {sfExpiration, SoeOptional}, @@ -216,11 +200,7 @@ TRANSACTION(ttPAYCHAN_FUND, 14, PaymentChannelFund, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttPAYCHAN_CLAIM, 15, PaymentChannelClaim, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttPAYCHAN_CLAIM, 15, PaymentChannelClaim, ({.delegable = Delegation::Delegable}), ({ {sfChannel, SoeRequired}, {sfAmount, SoeOptional}, {sfBalance, SoeOptional}, @@ -233,11 +213,7 @@ TRANSACTION(ttPAYCHAN_CLAIM, 15, PaymentChannelClaim, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttCHECK_CREATE, 16, CheckCreate, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttCHECK_CREATE, 16, CheckCreate, ({.delegable = Delegation::Delegable}), ({ {sfDestination, SoeRequired}, {sfSendMax, SoeRequired, SoeMptSupported}, {sfExpiration, SoeOptional}, @@ -250,9 +226,7 @@ TRANSACTION(ttCHECK_CREATE, 16, CheckCreate, # include #endif TRANSACTION(ttCHECK_CASH, 17, CheckCash, - Delegation::Delegable, - uint256{}, - MayCreateMpt, + ({.delegable = Delegation::Delegable, .privileges = Privilege::MayCreateMpt}), ({ {sfCheckID, SoeRequired}, {sfAmount, SoeOptional, SoeMptSupported}, @@ -263,11 +237,7 @@ TRANSACTION(ttCHECK_CASH, 17, CheckCash, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttCHECK_CANCEL, 18, CheckCancel, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttCHECK_CANCEL, 18, CheckCancel, ({.delegable = Delegation::Delegable}), ({ {sfCheckID, SoeRequired}, })) @@ -275,11 +245,7 @@ TRANSACTION(ttCHECK_CANCEL, 18, CheckCancel, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttDEPOSIT_PREAUTH, 19, DepositPreauth, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttDEPOSIT_PREAUTH, 19, DepositPreauth, ({.delegable = Delegation::Delegable}), ({ {sfAuthorize, SoeOptional}, {sfUnauthorize, SoeOptional}, {sfAuthorizeCredentials, SoeOptional}, @@ -290,11 +256,7 @@ TRANSACTION(ttDEPOSIT_PREAUTH, 19, DepositPreauth, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttTRUST_SET, 20, TrustSet, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttTRUST_SET, 20, TrustSet, ({.delegable = Delegation::Delegable}), ({ {sfLimitAmount, SoeOptional}, {sfQualityIn, SoeOptional}, {sfQualityOut, SoeOptional}, @@ -305,9 +267,9 @@ TRANSACTION(ttTRUST_SET, 20, TrustSet, # include #endif TRANSACTION(ttACCOUNT_DELETE, 21, AccountDelete, - Delegation::NotDelegable, - uint256{}, - MustDeleteAcct, + ({ + .privileges = Privilege::MustDeleteAcct, + }), ({ {sfDestination, SoeRequired}, {sfDestinationTag, SoeOptional}, @@ -321,9 +283,7 @@ TRANSACTION(ttACCOUNT_DELETE, 21, AccountDelete, # include #endif TRANSACTION(ttNFTOKEN_MINT, 25, NFTokenMint, - Delegation::Delegable, - uint256{}, - ChangeNftCounts, + ({.delegable = Delegation::Delegable, .privileges = Privilege::ChangeNftCounts}), ({ {sfNFTokenTaxon, SoeRequired}, {sfTransferFee, SoeOptional}, @@ -339,9 +299,7 @@ TRANSACTION(ttNFTOKEN_MINT, 25, NFTokenMint, # include #endif TRANSACTION(ttNFTOKEN_BURN, 26, NFTokenBurn, - Delegation::Delegable, - uint256{}, - ChangeNftCounts, + ({.delegable = Delegation::Delegable, .privileges = Privilege::ChangeNftCounts}), ({ {sfNFTokenID, SoeRequired}, {sfOwner, SoeOptional}, @@ -351,11 +309,7 @@ TRANSACTION(ttNFTOKEN_BURN, 26, NFTokenBurn, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttNFTOKEN_CREATE_OFFER, 27, NFTokenCreateOffer, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttNFTOKEN_CREATE_OFFER, 27, NFTokenCreateOffer, ({.delegable = Delegation::Delegable}), ({ {sfNFTokenID, SoeRequired}, {sfAmount, SoeRequired}, {sfDestination, SoeOptional}, @@ -367,11 +321,7 @@ TRANSACTION(ttNFTOKEN_CREATE_OFFER, 27, NFTokenCreateOffer, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttNFTOKEN_CANCEL_OFFER, 28, NFTokenCancelOffer, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttNFTOKEN_CANCEL_OFFER, 28, NFTokenCancelOffer, ({.delegable = Delegation::Delegable}), ({ {sfNFTokenOffers, SoeRequired}, })) @@ -379,11 +329,7 @@ TRANSACTION(ttNFTOKEN_CANCEL_OFFER, 28, NFTokenCancelOffer, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttNFTOKEN_ACCEPT_OFFER, 29, NFTokenAcceptOffer, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttNFTOKEN_ACCEPT_OFFER, 29, NFTokenAcceptOffer, ({.delegable = Delegation::Delegable}), ({ {sfNFTokenBuyOffer, SoeOptional}, {sfNFTokenSellOffer, SoeOptional}, {sfNFTokenBrokerFee, SoeOptional}, @@ -393,11 +339,7 @@ TRANSACTION(ttNFTOKEN_ACCEPT_OFFER, 29, NFTokenAcceptOffer, #if TRANSACTION_INCLUDE # include #endif -TRANSACTION(ttCLAWBACK, 30, Clawback, - Delegation::Delegable, - uint256{}, - NoPriv, - ({ +TRANSACTION(ttCLAWBACK, 30, Clawback, ({.delegable = Delegation::Delegable}), ({ {sfAmount, SoeRequired, SoeMptSupported}, {sfHolder, SoeOptional}, })) @@ -407,9 +349,12 @@ TRANSACTION(ttCLAWBACK, 30, Clawback, # include #endif TRANSACTION(ttAMM_CLAWBACK, 31, AMMClawback, - Delegation::Delegable, - featureAMMClawback, - MayDeleteAcct | OverrideFreeze | MayAuthorizeMpt, + ({ + .delegable = Delegation::Delegable, + .amendment = featureAMMClawback, + .privileges = Privilege::MayDeleteAcct | Privilege::OverrideFreeze | + Privilege::MayAuthorizeMpt, + }), ({ {sfHolder, SoeRequired}, {sfAsset, SoeRequired, SoeMptSupported}, @@ -422,9 +367,11 @@ TRANSACTION(ttAMM_CLAWBACK, 31, AMMClawback, # include #endif TRANSACTION(ttAMM_CREATE, 35, AMMCreate, - Delegation::Delegable, - featureAMM, - CreatePseudoAcct | MayCreateMpt, + ({ + .delegable = Delegation::Delegable, + .amendment = featureAMM, + .privileges = Privilege::CreatePseudoAcct | Privilege::MayCreateMpt, + }), ({ {sfAmount, SoeRequired, SoeMptSupported}, {sfAmount2, SoeRequired, SoeMptSupported}, @@ -436,9 +383,7 @@ TRANSACTION(ttAMM_CREATE, 35, AMMCreate, # include #endif TRANSACTION(ttAMM_DEPOSIT, 36, AMMDeposit, - Delegation::Delegable, - featureAMM, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureAMM}), ({ {sfAsset, SoeRequired, SoeMptSupported}, {sfAsset2, SoeRequired, SoeMptSupported}, @@ -454,9 +399,11 @@ TRANSACTION(ttAMM_DEPOSIT, 36, AMMDeposit, # include #endif TRANSACTION(ttAMM_WITHDRAW, 37, AMMWithdraw, - Delegation::Delegable, - featureAMM, - MayDeleteAcct | MayAuthorizeMpt, + ({ + .delegable = Delegation::Delegable, + .amendment = featureAMM, + .privileges = Privilege::MayDeleteAcct | Privilege::MayAuthorizeMpt, + }), ({ {sfAsset, SoeRequired, SoeMptSupported}, {sfAsset2, SoeRequired, SoeMptSupported}, @@ -471,9 +418,7 @@ TRANSACTION(ttAMM_WITHDRAW, 37, AMMWithdraw, # include #endif TRANSACTION(ttAMM_VOTE, 38, AMMVote, - Delegation::Delegable, - featureAMM, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureAMM}), ({ {sfAsset, SoeRequired, SoeMptSupported}, {sfAsset2, SoeRequired, SoeMptSupported}, @@ -485,9 +430,7 @@ TRANSACTION(ttAMM_VOTE, 38, AMMVote, # include #endif TRANSACTION(ttAMM_BID, 39, AMMBid, - Delegation::Delegable, - featureAMM, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureAMM}), ({ {sfAsset, SoeRequired, SoeMptSupported}, {sfAsset2, SoeRequired, SoeMptSupported}, @@ -501,9 +444,11 @@ TRANSACTION(ttAMM_BID, 39, AMMBid, # include #endif TRANSACTION(ttAMM_DELETE, 40, AMMDelete, - Delegation::Delegable, - featureAMM, - MustDeleteAcct | MayDeleteMpt, + ({ + .delegable = Delegation::Delegable, + .amendment = featureAMM, + .privileges = Privilege::MustDeleteAcct | Privilege::MayDeleteMpt, + }), ({ {sfAsset, SoeRequired, SoeMptSupported}, {sfAsset2, SoeRequired, SoeMptSupported}, @@ -514,9 +459,7 @@ TRANSACTION(ttAMM_DELETE, 40, AMMDelete, # include #endif TRANSACTION(ttXCHAIN_CREATE_CLAIM_ID, 41, XChainCreateClaimID, - Delegation::Delegable, - featureXChainBridge, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureXChainBridge}), ({ {sfXChainBridge, SoeRequired}, {sfSignatureReward, SoeRequired}, @@ -525,9 +468,7 @@ TRANSACTION(ttXCHAIN_CREATE_CLAIM_ID, 41, XChainCreateClaimID, /** This transactions initiates a crosschain transaction */ TRANSACTION(ttXCHAIN_COMMIT, 42, XChainCommit, - Delegation::Delegable, - featureXChainBridge, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureXChainBridge}), ({ {sfXChainBridge, SoeRequired}, {sfXChainClaimID, SoeRequired}, @@ -537,9 +478,7 @@ TRANSACTION(ttXCHAIN_COMMIT, 42, XChainCommit, /** This transaction completes a crosschain transaction */ TRANSACTION(ttXCHAIN_CLAIM, 43, XChainClaim, - Delegation::Delegable, - featureXChainBridge, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureXChainBridge}), ({ {sfXChainBridge, SoeRequired}, {sfXChainClaimID, SoeRequired}, @@ -550,9 +489,7 @@ TRANSACTION(ttXCHAIN_CLAIM, 43, XChainClaim, /** This transaction initiates a crosschain account create transaction */ TRANSACTION(ttXCHAIN_ACCOUNT_CREATE_COMMIT, 44, XChainAccountCreateCommit, - Delegation::Delegable, - featureXChainBridge, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureXChainBridge}), ({ {sfXChainBridge, SoeRequired}, {sfDestination, SoeRequired}, @@ -562,9 +499,11 @@ TRANSACTION(ttXCHAIN_ACCOUNT_CREATE_COMMIT, 44, XChainAccountCreateCommit, /** This transaction adds an attestation to a claim */ TRANSACTION(ttXCHAIN_ADD_CLAIM_ATTESTATION, 45, XChainAddClaimAttestation, - Delegation::Delegable, - featureXChainBridge, - CreateAcct, + ({ + .delegable = Delegation::Delegable, + .amendment = featureXChainBridge, + .privileges = Privilege::CreateAcct, + }), ({ {sfXChainBridge, SoeRequired}, @@ -581,11 +520,12 @@ TRANSACTION(ttXCHAIN_ADD_CLAIM_ATTESTATION, 45, XChainAddClaimAttestation, })) /** This transaction adds an attestation to an account */ -TRANSACTION(ttXCHAIN_ADD_ACCOUNT_CREATE_ATTESTATION, 46, - XChainAddAccountCreateAttestation, - Delegation::Delegable, - featureXChainBridge, - CreateAcct, +TRANSACTION(ttXCHAIN_ADD_ACCOUNT_CREATE_ATTESTATION, 46, XChainAddAccountCreateAttestation, + ({ + .delegable = Delegation::Delegable, + .amendment = featureXChainBridge, + .privileges = Privilege::CreateAcct, + }), ({ {sfXChainBridge, SoeRequired}, @@ -604,9 +544,7 @@ TRANSACTION(ttXCHAIN_ADD_ACCOUNT_CREATE_ATTESTATION, 46, /** This transaction modifies a sidechain */ TRANSACTION(ttXCHAIN_MODIFY_BRIDGE, 47, XChainModifyBridge, - Delegation::Delegable, - featureXChainBridge, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureXChainBridge}), ({ {sfXChainBridge, SoeRequired}, {sfSignatureReward, SoeOptional}, @@ -615,9 +553,7 @@ TRANSACTION(ttXCHAIN_MODIFY_BRIDGE, 47, XChainModifyBridge, /** This transactions creates a sidechain */ TRANSACTION(ttXCHAIN_CREATE_BRIDGE, 48, XChainCreateBridge, - Delegation::Delegable, - featureXChainBridge, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureXChainBridge}), ({ {sfXChainBridge, SoeRequired}, {sfSignatureReward, SoeRequired}, @@ -629,9 +565,7 @@ TRANSACTION(ttXCHAIN_CREATE_BRIDGE, 48, XChainCreateBridge, # include #endif TRANSACTION(ttDID_SET, 49, DIDSet, - Delegation::Delegable, - featureDID, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureDID}), ({ {sfDIDDocument, SoeOptional}, {sfURI, SoeOptional}, @@ -643,9 +577,7 @@ TRANSACTION(ttDID_SET, 49, DIDSet, # include #endif TRANSACTION(ttDID_DELETE, 50, DIDDelete, - Delegation::Delegable, - featureDID, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureDID}), ({})) /** This transaction type creates an Oracle instance */ @@ -653,9 +585,7 @@ TRANSACTION(ttDID_DELETE, 50, DIDDelete, # include #endif TRANSACTION(ttORACLE_SET, 51, OracleSet, - Delegation::Delegable, - featurePriceOracle, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featurePriceOracle}), ({ {sfOracleDocumentID, SoeRequired}, {sfProvider, SoeOptional}, @@ -670,9 +600,7 @@ TRANSACTION(ttORACLE_SET, 51, OracleSet, # include #endif TRANSACTION(ttORACLE_DELETE, 52, OracleDelete, - Delegation::Delegable, - featurePriceOracle, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featurePriceOracle}), ({ {sfOracleDocumentID, SoeRequired}, })) @@ -682,9 +610,7 @@ TRANSACTION(ttORACLE_DELETE, 52, OracleDelete, # include #endif TRANSACTION(ttLEDGER_STATE_FIX, 53, LedgerStateFix, - Delegation::Delegable, - fixNFTokenPageLinks, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = fixNFTokenPageLinks}), ({ {sfLedgerFixType, SoeRequired}, {sfOwner, SoeOptional}, @@ -696,9 +622,11 @@ TRANSACTION(ttLEDGER_STATE_FIX, 53, LedgerStateFix, # include #endif TRANSACTION(ttMPTOKEN_ISSUANCE_CREATE, 54, MPTokenIssuanceCreate, - Delegation::Delegable, - featureMPTokensV1, - CreateMptIssuance, + ({ + .delegable = Delegation::Delegable, + .amendment = featureMPTokensV1, + .privileges = Privilege::CreateMptIssuance, + }), ({ {sfAssetScale, SoeOptional}, {sfTransferFee, SoeOptional}, @@ -713,9 +641,11 @@ TRANSACTION(ttMPTOKEN_ISSUANCE_CREATE, 54, MPTokenIssuanceCreate, # include #endif TRANSACTION(ttMPTOKEN_ISSUANCE_DESTROY, 55, MPTokenIssuanceDestroy, - Delegation::Delegable, - featureMPTokensV1, - DestroyMptIssuance, + ({ + .delegable = Delegation::Delegable, + .amendment = featureMPTokensV1, + .privileges = Privilege::DestroyMptIssuance, + }), ({ {sfMPTokenIssuanceID, SoeRequired}, })) @@ -725,9 +655,7 @@ TRANSACTION(ttMPTOKEN_ISSUANCE_DESTROY, 55, MPTokenIssuanceDestroy, # include #endif TRANSACTION(ttMPTOKEN_ISSUANCE_SET, 56, MPTokenIssuanceSet, - Delegation::Delegable, - featureMPTokensV1, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureMPTokensV1}), ({ {sfMPTokenIssuanceID, SoeRequired}, {sfHolder, SoeOptional}, @@ -744,9 +672,11 @@ TRANSACTION(ttMPTOKEN_ISSUANCE_SET, 56, MPTokenIssuanceSet, # include #endif TRANSACTION(ttMPTOKEN_AUTHORIZE, 57, MPTokenAuthorize, - Delegation::Delegable, - featureMPTokensV1, - MustAuthorizeMpt, + ({ + .delegable = Delegation::Delegable, + .amendment = featureMPTokensV1, + .privileges = Privilege::MustAuthorizeMpt, + }), ({ {sfMPTokenIssuanceID, SoeRequired}, {sfHolder, SoeOptional}, @@ -757,9 +687,7 @@ TRANSACTION(ttMPTOKEN_AUTHORIZE, 57, MPTokenAuthorize, # include #endif TRANSACTION(ttCREDENTIAL_CREATE, 58, CredentialCreate, - Delegation::Delegable, - featureCredentials, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureCredentials}), ({ {sfSubject, SoeRequired}, {sfCredentialType, SoeRequired}, @@ -772,9 +700,7 @@ TRANSACTION(ttCREDENTIAL_CREATE, 58, CredentialCreate, # include #endif TRANSACTION(ttCREDENTIAL_ACCEPT, 59, CredentialAccept, - Delegation::Delegable, - featureCredentials, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureCredentials}), ({ {sfIssuer, SoeRequired}, {sfCredentialType, SoeRequired}, @@ -785,9 +711,7 @@ TRANSACTION(ttCREDENTIAL_ACCEPT, 59, CredentialAccept, # include #endif TRANSACTION(ttCREDENTIAL_DELETE, 60, CredentialDelete, - Delegation::Delegable, - featureCredentials, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureCredentials}), ({ {sfSubject, SoeOptional}, {sfIssuer, SoeOptional}, @@ -799,9 +723,7 @@ TRANSACTION(ttCREDENTIAL_DELETE, 60, CredentialDelete, # include #endif TRANSACTION(ttNFTOKEN_MODIFY, 61, NFTokenModify, - Delegation::Delegable, - featureDynamicNFT, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureDynamicNFT}), ({ {sfNFTokenID, SoeRequired}, {sfOwner, SoeOptional}, @@ -813,9 +735,7 @@ TRANSACTION(ttNFTOKEN_MODIFY, 61, NFTokenModify, # include #endif TRANSACTION(ttPERMISSIONED_DOMAIN_SET, 62, PermissionedDomainSet, - Delegation::Delegable, - featurePermissionedDomains, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featurePermissionedDomains}), ({ {sfDomainID, SoeOptional}, {sfAcceptedCredentials, SoeRequired}, @@ -826,9 +746,7 @@ TRANSACTION(ttPERMISSIONED_DOMAIN_SET, 62, PermissionedDomainSet, # include #endif TRANSACTION(ttPERMISSIONED_DOMAIN_DELETE, 63, PermissionedDomainDelete, - Delegation::Delegable, - featurePermissionedDomains, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featurePermissionedDomains}), ({ {sfDomainID, SoeRequired}, })) @@ -838,9 +756,9 @@ TRANSACTION(ttPERMISSIONED_DOMAIN_DELETE, 63, PermissionedDomainDelete, # include #endif TRANSACTION(ttDELEGATE_SET, 64, DelegateSet, - Delegation::NotDelegable, - featurePermissionDelegationV1_1, - NoPriv, + ({ + .amendment = featurePermissionDelegationV1_1, + }), ({ {sfAuthorize, SoeRequired}, {sfPermissions, SoeRequired}, @@ -851,9 +769,11 @@ TRANSACTION(ttDELEGATE_SET, 64, DelegateSet, # include #endif TRANSACTION(ttVAULT_CREATE, 65, VaultCreate, - Delegation::NotDelegable, - featureSingleAssetVault, - CreatePseudoAcct | CreateMptIssuance | MustModifyVault, + ({ + .amendment = featureSingleAssetVault, + .privileges = Privilege::CreatePseudoAcct | Privilege::CreateMptIssuance | + Privilege::MustModifyVault, + }), ({ {sfAsset, SoeRequired, SoeMptSupported}, {sfAssetsMaximum, SoeOptional}, @@ -872,9 +792,10 @@ TRANSACTION(ttVAULT_CREATE, 65, VaultCreate, # include #endif TRANSACTION(ttVAULT_SET, 66, VaultSet, - Delegation::NotDelegable, - featureSingleAssetVault, - MustModifyVault, + ({ + .amendment = featureSingleAssetVault, + .privileges = Privilege::MustModifyVault, + }), ({ {sfVaultID, SoeRequired}, {sfAssetsMaximum, SoeOptional}, @@ -887,9 +808,11 @@ TRANSACTION(ttVAULT_SET, 66, VaultSet, # include #endif TRANSACTION(ttVAULT_DELETE, 67, VaultDelete, - Delegation::NotDelegable, - featureSingleAssetVault, - MustDeleteAcct | DestroyMptIssuance | MustModifyVault, + ({ + .amendment = featureSingleAssetVault, + .privileges = Privilege::MustDeleteAcct | Privilege::DestroyMptIssuance | + Privilege::MustModifyVault, + }), ({ {sfVaultID, SoeRequired}, {sfMemoData, SoeOptional}, @@ -900,9 +823,10 @@ TRANSACTION(ttVAULT_DELETE, 67, VaultDelete, # include #endif TRANSACTION(ttVAULT_DEPOSIT, 68, VaultDeposit, - Delegation::NotDelegable, - featureSingleAssetVault, - MayAuthorizeMpt | MustModifyVault, + ({ + .amendment = featureSingleAssetVault, + .privileges = Privilege::MayAuthorizeMpt | Privilege::MustModifyVault, + }), ({ {sfVaultID, SoeRequired}, {sfAmount, SoeRequired, SoeMptSupported}, @@ -913,9 +837,11 @@ TRANSACTION(ttVAULT_DEPOSIT, 68, VaultDeposit, # include #endif TRANSACTION(ttVAULT_WITHDRAW, 69, VaultWithdraw, - Delegation::NotDelegable, - featureSingleAssetVault, - MayDeleteMpt | MayAuthorizeMpt | MustModifyVault, + ({ + .amendment = featureSingleAssetVault, + .privileges = Privilege::MayDeleteMpt | Privilege::MayAuthorizeMpt | + Privilege::MustModifyVault, + }), ({ {sfVaultID, SoeRequired}, {sfAmount, SoeRequired, SoeMptSupported}, @@ -929,9 +855,10 @@ TRANSACTION(ttVAULT_WITHDRAW, 69, VaultWithdraw, # include #endif TRANSACTION(ttVAULT_CLAWBACK, 70, VaultClawback, - Delegation::NotDelegable, - featureSingleAssetVault, - MayDeleteMpt | MustModifyVault, + ({ + .amendment = featureSingleAssetVault, + .privileges = Privilege::MayDeleteMpt | Privilege::MustModifyVault, + }), ({ {sfVaultID, SoeRequired}, {sfHolder, SoeRequired}, @@ -943,9 +870,9 @@ TRANSACTION(ttVAULT_CLAWBACK, 70, VaultClawback, # include #endif TRANSACTION(ttBATCH, 71, Batch, - Delegation::NotDelegable, - featureBatchV1_1, - NoPriv, + ({ + .amendment = featureBatchV1_1, + }), ({ {sfRawTransactions, SoeRequired}, {sfBatchSigners, SoeOptional}, @@ -958,9 +885,11 @@ TRANSACTION(ttBATCH, 71, Batch, # include #endif TRANSACTION(ttLOAN_BROKER_SET, 74, LoanBrokerSet, - Delegation::NotDelegable, - featureLendingProtocol, - CreatePseudoAcct | MayAuthorizeMpt, ({ + ({ + .amendment = featureLendingProtocol, + .privileges = Privilege::CreatePseudoAcct | Privilege::MayAuthorizeMpt, + }), + ({ {sfVaultID, SoeRequired}, {sfLoanBrokerID, SoeOptional}, {sfData, SoeOptional}, @@ -975,9 +904,11 @@ TRANSACTION(ttLOAN_BROKER_SET, 74, LoanBrokerSet, # include #endif TRANSACTION(ttLOAN_BROKER_DELETE, 75, LoanBrokerDelete, - Delegation::NotDelegable, - featureLendingProtocol, - MustDeleteAcct | MayAuthorizeMpt, ({ + ({ + .amendment = featureLendingProtocol, + .privileges = Privilege::MustDeleteAcct | Privilege::MayAuthorizeMpt, + }), + ({ {sfLoanBrokerID, SoeRequired}, })) @@ -986,9 +917,10 @@ TRANSACTION(ttLOAN_BROKER_DELETE, 75, LoanBrokerDelete, # include #endif TRANSACTION(ttLOAN_BROKER_COVER_DEPOSIT, 76, LoanBrokerCoverDeposit, - Delegation::NotDelegable, - featureLendingProtocol, - NoPriv, ({ + ({ + .amendment = featureLendingProtocol, + }), + ({ {sfLoanBrokerID, SoeRequired}, {sfAmount, SoeRequired, SoeMptSupported}, })) @@ -998,9 +930,11 @@ TRANSACTION(ttLOAN_BROKER_COVER_DEPOSIT, 76, LoanBrokerCoverDeposit, # include #endif TRANSACTION(ttLOAN_BROKER_COVER_WITHDRAW, 77, LoanBrokerCoverWithdraw, - Delegation::NotDelegable, - featureLendingProtocol, - MayAuthorizeMpt, ({ + ({ + .amendment = featureLendingProtocol, + .privileges = Privilege::MayAuthorizeMpt, + }), + ({ {sfLoanBrokerID, SoeRequired}, {sfAmount, SoeRequired, SoeMptSupported}, {sfDestination, SoeOptional}, @@ -1014,9 +948,10 @@ TRANSACTION(ttLOAN_BROKER_COVER_WITHDRAW, 77, LoanBrokerCoverWithdraw, # include #endif TRANSACTION(ttLOAN_BROKER_COVER_CLAWBACK, 78, LoanBrokerCoverClawback, - Delegation::NotDelegable, - featureLendingProtocol, - NoPriv, ({ + ({ + .amendment = featureLendingProtocol, + }), + ({ {sfLoanBrokerID, SoeOptional}, {sfAmount, SoeOptional, SoeMptSupported}, })) @@ -1026,9 +961,11 @@ TRANSACTION(ttLOAN_BROKER_COVER_CLAWBACK, 78, LoanBrokerCoverClawback, # include #endif TRANSACTION(ttLOAN_SET, 80, LoanSet, - Delegation::NotDelegable, - featureLendingProtocol, - MayAuthorizeMpt | MustModifyVault, ({ + ({ + .amendment = featureLendingProtocol, + .privileges = Privilege::MayAuthorizeMpt | Privilege::MustModifyVault, + }), + ({ {sfLoanBrokerID, SoeRequired}, {sfData, SoeOptional}, {sfCounterparty, SoeOptional}, @@ -1053,9 +990,10 @@ TRANSACTION(ttLOAN_SET, 80, LoanSet, # include #endif TRANSACTION(ttLOAN_DELETE, 81, LoanDelete, - Delegation::NotDelegable, - featureLendingProtocol, - NoPriv, ({ + ({ + .amendment = featureLendingProtocol, + }), + ({ {sfLoanID, SoeRequired}, })) @@ -1064,12 +1002,14 @@ TRANSACTION(ttLOAN_DELETE, 81, LoanDelete, # include #endif TRANSACTION(ttLOAN_MANAGE, 82, LoanManage, - Delegation::NotDelegable, - featureLendingProtocol, - // All of the LoanManage options will modify the vault, but the - // transaction can succeed without options, essentially making it - // a noop. - MayModifyVault, ({ + ({ + .amendment = featureLendingProtocol, + // All of the LoanManage options will modify the vault, but the + // transaction can succeed without options, essentially making it + // a noop. + .privileges = Privilege::MayModifyVault, + }), + ({ {sfLoanID, SoeRequired}, })) @@ -1078,9 +1018,11 @@ TRANSACTION(ttLOAN_MANAGE, 82, LoanManage, # include #endif TRANSACTION(ttLOAN_PAY, 84, LoanPay, - Delegation::NotDelegable, - featureLendingProtocol, - MayAuthorizeMpt | MustModifyVault, ({ + ({ + .amendment = featureLendingProtocol, + .privileges = Privilege::MayAuthorizeMpt | Privilege::MustModifyVault, + }), + ({ {sfLoanID, SoeRequired}, {sfAmount, SoeRequired, SoeMptSupported}, })) @@ -1090,9 +1032,9 @@ TRANSACTION(ttLOAN_PAY, 84, LoanPay, # include #endif TRANSACTION(ttCONFIDENTIAL_MPT_CONVERT, 85, ConfidentialMPTConvert, - Delegation::NotDelegable, - featureConfidentialTransfer, - NoPriv, + ({ + .amendment = featureConfidentialTransfer, + }), ({ {sfMPTokenIssuanceID, SoeRequired}, {sfMPTAmount, SoeRequired}, @@ -1109,9 +1051,7 @@ TRANSACTION(ttCONFIDENTIAL_MPT_CONVERT, 85, ConfidentialMPTConvert, # include #endif TRANSACTION(ttCONFIDENTIAL_MPT_MERGE_INBOX, 86, ConfidentialMPTMergeInbox, - Delegation::Delegable, - featureConfidentialTransfer, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureConfidentialTransfer}), ({ {sfMPTokenIssuanceID, SoeRequired}, })) @@ -1121,9 +1061,7 @@ TRANSACTION(ttCONFIDENTIAL_MPT_MERGE_INBOX, 86, ConfidentialMPTMergeInbox, # include #endif TRANSACTION(ttCONFIDENTIAL_MPT_CONVERT_BACK, 87, ConfidentialMPTConvertBack, - Delegation::Delegable, - featureConfidentialTransfer, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureConfidentialTransfer}), ({ {sfMPTokenIssuanceID, SoeRequired}, {sfMPTAmount, SoeRequired}, @@ -1139,9 +1077,7 @@ TRANSACTION(ttCONFIDENTIAL_MPT_CONVERT_BACK, 87, ConfidentialMPTConvertBack, # include #endif TRANSACTION(ttCONFIDENTIAL_MPT_SEND, 88, ConfidentialMPTSend, - Delegation::Delegable, - featureConfidentialTransfer, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureConfidentialTransfer}), ({ {sfMPTokenIssuanceID, SoeRequired}, {sfDestination, SoeRequired}, @@ -1160,9 +1096,7 @@ TRANSACTION(ttCONFIDENTIAL_MPT_SEND, 88, ConfidentialMPTSend, # include #endif TRANSACTION(ttCONFIDENTIAL_MPT_CLAWBACK, 89, ConfidentialMPTClawback, - Delegation::Delegable, - featureConfidentialTransfer, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureConfidentialTransfer}), ({ {sfMPTokenIssuanceID, SoeRequired}, {sfHolder, SoeRequired}, @@ -1175,9 +1109,9 @@ TRANSACTION(ttCONFIDENTIAL_MPT_CLAWBACK, 89, ConfidentialMPTClawback, # include #endif TRANSACTION(ttSPONSORSHIP_TRANSFER, 90, SponsorshipTransfer, - Delegation::NotDelegable, - featureSponsor, - NoPriv, + ({ + .amendment = featureSponsor, + }), ({ {sfObjectID, SoeOptional}, {sfSponsee, SoeOptional}, @@ -1188,9 +1122,7 @@ TRANSACTION(ttSPONSORSHIP_TRANSFER, 90, SponsorshipTransfer, # include #endif TRANSACTION(ttSPONSORSHIP_SET, 91, SponsorshipSet, - Delegation::Delegable, - featureSponsor, - NoPriv, + ({.delegable = Delegation::Delegable, .amendment = featureSponsor}), ({ {sfCounterpartySponsor, SoeOptional}, {sfSponsee, SoeOptional}, @@ -1207,9 +1139,7 @@ TRANSACTION(ttSPONSORSHIP_SET, 91, SponsorshipSet, # include #endif TRANSACTION(ttAMENDMENT, 100, EnableAmendment, - Delegation::NotDelegable, - uint256{}, - NoPriv, + ({}), ({ {sfLedgerSequence, SoeRequired}, {sfAmendment, SoeRequired}, @@ -1219,9 +1149,7 @@ TRANSACTION(ttAMENDMENT, 100, EnableAmendment, For details, see: https://xrpl.org/fee-voting.html */ TRANSACTION(ttFEE, 101, SetFee, - Delegation::NotDelegable, - uint256{}, - NoPriv, + ({}), ({ {sfLedgerSequence, SoeOptional}, // Old version uses raw numbers @@ -1240,9 +1168,7 @@ TRANSACTION(ttFEE, 101, SetFee, For details, see: https://xrpl.org/negative-unl.html */ TRANSACTION(ttUNL_MODIFY, 102, UNLModify, - Delegation::NotDelegable, - uint256{}, - NoPriv, + ({}), ({ {sfUNLModifyDisabling, SoeRequired}, {sfLedgerSequence, SoeRequired}, diff --git a/include/xrpl/protocol_autogen/transactions/AMMBid.h b/include/xrpl/protocol_autogen/transactions/AMMBid.h index 30a2b6f2ab..94d0672699 100644 --- a/include/xrpl/protocol_autogen/transactions/AMMBid.h +++ b/include/xrpl/protocol_autogen/transactions/AMMBid.h @@ -21,7 +21,7 @@ class AMMBidBuilder; * Type: ttAMM_BID (39) * Delegable: Delegation::Delegable * Amendment: featureAMM - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use AMMBidBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/AMMClawback.h b/include/xrpl/protocol_autogen/transactions/AMMClawback.h index 38aba892c4..c837b5cee6 100644 --- a/include/xrpl/protocol_autogen/transactions/AMMClawback.h +++ b/include/xrpl/protocol_autogen/transactions/AMMClawback.h @@ -21,7 +21,7 @@ class AMMClawbackBuilder; * Type: ttAMM_CLAWBACK (31) * Delegable: Delegation::Delegable * Amendment: featureAMMClawback - * Privileges: MayDeleteAcct | OverrideFreeze | MayAuthorizeMpt + * Privileges: Privilege::MayDeleteAcct | Privilege::OverrideFreeze | Privilege::MayAuthorizeMpt * * Immutable wrapper around STTx providing type-safe field access. * Use AMMClawbackBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/AMMCreate.h b/include/xrpl/protocol_autogen/transactions/AMMCreate.h index c6ccd4e860..e2e50f87ff 100644 --- a/include/xrpl/protocol_autogen/transactions/AMMCreate.h +++ b/include/xrpl/protocol_autogen/transactions/AMMCreate.h @@ -21,7 +21,7 @@ class AMMCreateBuilder; * Type: ttAMM_CREATE (35) * Delegable: Delegation::Delegable * Amendment: featureAMM - * Privileges: CreatePseudoAcct | MayCreateMpt + * Privileges: Privilege::CreatePseudoAcct | Privilege::MayCreateMpt * * Immutable wrapper around STTx providing type-safe field access. * Use AMMCreateBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/AMMDelete.h b/include/xrpl/protocol_autogen/transactions/AMMDelete.h index 05899a46c8..86e91bf52b 100644 --- a/include/xrpl/protocol_autogen/transactions/AMMDelete.h +++ b/include/xrpl/protocol_autogen/transactions/AMMDelete.h @@ -21,7 +21,7 @@ class AMMDeleteBuilder; * Type: ttAMM_DELETE (40) * Delegable: Delegation::Delegable * Amendment: featureAMM - * Privileges: MustDeleteAcct | MayDeleteMpt + * Privileges: Privilege::MustDeleteAcct | Privilege::MayDeleteMpt * * Immutable wrapper around STTx providing type-safe field access. * Use AMMDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/AMMDeposit.h b/include/xrpl/protocol_autogen/transactions/AMMDeposit.h index 5416547dab..fed1bd3195 100644 --- a/include/xrpl/protocol_autogen/transactions/AMMDeposit.h +++ b/include/xrpl/protocol_autogen/transactions/AMMDeposit.h @@ -21,7 +21,7 @@ class AMMDepositBuilder; * Type: ttAMM_DEPOSIT (36) * Delegable: Delegation::Delegable * Amendment: featureAMM - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use AMMDepositBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/AMMVote.h b/include/xrpl/protocol_autogen/transactions/AMMVote.h index 7dce3c252f..3fca42a232 100644 --- a/include/xrpl/protocol_autogen/transactions/AMMVote.h +++ b/include/xrpl/protocol_autogen/transactions/AMMVote.h @@ -21,7 +21,7 @@ class AMMVoteBuilder; * Type: ttAMM_VOTE (38) * Delegable: Delegation::Delegable * Amendment: featureAMM - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use AMMVoteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/AMMWithdraw.h b/include/xrpl/protocol_autogen/transactions/AMMWithdraw.h index 81258f22d6..e177011801 100644 --- a/include/xrpl/protocol_autogen/transactions/AMMWithdraw.h +++ b/include/xrpl/protocol_autogen/transactions/AMMWithdraw.h @@ -21,7 +21,7 @@ class AMMWithdrawBuilder; * Type: ttAMM_WITHDRAW (37) * Delegable: Delegation::Delegable * Amendment: featureAMM - * Privileges: MayDeleteAcct | MayAuthorizeMpt + * Privileges: Privilege::MayDeleteAcct | Privilege::MayAuthorizeMpt * * Immutable wrapper around STTx providing type-safe field access. * Use AMMWithdrawBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/AccountDelete.h b/include/xrpl/protocol_autogen/transactions/AccountDelete.h index cf6e97bb63..87ecab0c7b 100644 --- a/include/xrpl/protocol_autogen/transactions/AccountDelete.h +++ b/include/xrpl/protocol_autogen/transactions/AccountDelete.h @@ -21,7 +21,7 @@ class AccountDeleteBuilder; * Type: ttACCOUNT_DELETE (21) * Delegable: Delegation::NotDelegable * Amendment: uint256{} - * Privileges: MustDeleteAcct + * Privileges: Privilege::MustDeleteAcct * * Immutable wrapper around STTx providing type-safe field access. * Use AccountDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/AccountSet.h b/include/xrpl/protocol_autogen/transactions/AccountSet.h index 55c449e78e..9f85603e22 100644 --- a/include/xrpl/protocol_autogen/transactions/AccountSet.h +++ b/include/xrpl/protocol_autogen/transactions/AccountSet.h @@ -21,7 +21,7 @@ class AccountSetBuilder; * Type: ttACCOUNT_SET (3) * Delegable: Delegation::NotDelegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use AccountSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/Batch.h b/include/xrpl/protocol_autogen/transactions/Batch.h index 1a59d2b4c0..f92aaa5348 100644 --- a/include/xrpl/protocol_autogen/transactions/Batch.h +++ b/include/xrpl/protocol_autogen/transactions/Batch.h @@ -21,7 +21,7 @@ class BatchBuilder; * Type: ttBATCH (71) * Delegable: Delegation::NotDelegable * Amendment: featureBatchV1_1 - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use BatchBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/CheckCancel.h b/include/xrpl/protocol_autogen/transactions/CheckCancel.h index b75b717e3f..cf300d3b9b 100644 --- a/include/xrpl/protocol_autogen/transactions/CheckCancel.h +++ b/include/xrpl/protocol_autogen/transactions/CheckCancel.h @@ -21,7 +21,7 @@ class CheckCancelBuilder; * Type: ttCHECK_CANCEL (18) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use CheckCancelBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/CheckCash.h b/include/xrpl/protocol_autogen/transactions/CheckCash.h index c742a15154..b80429875f 100644 --- a/include/xrpl/protocol_autogen/transactions/CheckCash.h +++ b/include/xrpl/protocol_autogen/transactions/CheckCash.h @@ -21,7 +21,7 @@ class CheckCashBuilder; * Type: ttCHECK_CASH (17) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: MayCreateMpt + * Privileges: Privilege::MayCreateMpt * * Immutable wrapper around STTx providing type-safe field access. * Use CheckCashBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/CheckCreate.h b/include/xrpl/protocol_autogen/transactions/CheckCreate.h index 63e55f8604..db51b5eb5f 100644 --- a/include/xrpl/protocol_autogen/transactions/CheckCreate.h +++ b/include/xrpl/protocol_autogen/transactions/CheckCreate.h @@ -21,7 +21,7 @@ class CheckCreateBuilder; * Type: ttCHECK_CREATE (16) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use CheckCreateBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/Clawback.h b/include/xrpl/protocol_autogen/transactions/Clawback.h index 9a3a7f9feb..ad79f1d1fe 100644 --- a/include/xrpl/protocol_autogen/transactions/Clawback.h +++ b/include/xrpl/protocol_autogen/transactions/Clawback.h @@ -21,7 +21,7 @@ class ClawbackBuilder; * Type: ttCLAWBACK (30) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use ClawbackBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTClawback.h b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTClawback.h index c80fc81dc5..bf204a35cb 100644 --- a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTClawback.h +++ b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTClawback.h @@ -21,7 +21,7 @@ class ConfidentialMPTClawbackBuilder; * Type: ttCONFIDENTIAL_MPT_CLAWBACK (89) * Delegable: Delegation::Delegable * Amendment: featureConfidentialTransfer - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use ConfidentialMPTClawbackBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvert.h b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvert.h index 284b7f9e70..d23e6409d9 100644 --- a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvert.h +++ b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvert.h @@ -21,7 +21,7 @@ class ConfidentialMPTConvertBuilder; * Type: ttCONFIDENTIAL_MPT_CONVERT (85) * Delegable: Delegation::NotDelegable * Amendment: featureConfidentialTransfer - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use ConfidentialMPTConvertBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvertBack.h b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvertBack.h index 53a8e64125..80ec81e6f3 100644 --- a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvertBack.h +++ b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTConvertBack.h @@ -21,7 +21,7 @@ class ConfidentialMPTConvertBackBuilder; * Type: ttCONFIDENTIAL_MPT_CONVERT_BACK (87) * Delegable: Delegation::Delegable * Amendment: featureConfidentialTransfer - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use ConfidentialMPTConvertBackBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTMergeInbox.h b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTMergeInbox.h index 848da42a41..e3ec886acf 100644 --- a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTMergeInbox.h +++ b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTMergeInbox.h @@ -21,7 +21,7 @@ class ConfidentialMPTMergeInboxBuilder; * Type: ttCONFIDENTIAL_MPT_MERGE_INBOX (86) * Delegable: Delegation::Delegable * Amendment: featureConfidentialTransfer - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use ConfidentialMPTMergeInboxBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTSend.h b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTSend.h index 806a2586e9..b8aac2bd48 100644 --- a/include/xrpl/protocol_autogen/transactions/ConfidentialMPTSend.h +++ b/include/xrpl/protocol_autogen/transactions/ConfidentialMPTSend.h @@ -21,7 +21,7 @@ class ConfidentialMPTSendBuilder; * Type: ttCONFIDENTIAL_MPT_SEND (88) * Delegable: Delegation::Delegable * Amendment: featureConfidentialTransfer - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use ConfidentialMPTSendBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/CredentialAccept.h b/include/xrpl/protocol_autogen/transactions/CredentialAccept.h index f2ab546320..7ee2464460 100644 --- a/include/xrpl/protocol_autogen/transactions/CredentialAccept.h +++ b/include/xrpl/protocol_autogen/transactions/CredentialAccept.h @@ -21,7 +21,7 @@ class CredentialAcceptBuilder; * Type: ttCREDENTIAL_ACCEPT (59) * Delegable: Delegation::Delegable * Amendment: featureCredentials - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use CredentialAcceptBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/CredentialCreate.h b/include/xrpl/protocol_autogen/transactions/CredentialCreate.h index 6cf09c852b..6ccc4e3059 100644 --- a/include/xrpl/protocol_autogen/transactions/CredentialCreate.h +++ b/include/xrpl/protocol_autogen/transactions/CredentialCreate.h @@ -21,7 +21,7 @@ class CredentialCreateBuilder; * Type: ttCREDENTIAL_CREATE (58) * Delegable: Delegation::Delegable * Amendment: featureCredentials - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use CredentialCreateBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/CredentialDelete.h b/include/xrpl/protocol_autogen/transactions/CredentialDelete.h index 24a2bfa62a..74039e50bf 100644 --- a/include/xrpl/protocol_autogen/transactions/CredentialDelete.h +++ b/include/xrpl/protocol_autogen/transactions/CredentialDelete.h @@ -21,7 +21,7 @@ class CredentialDeleteBuilder; * Type: ttCREDENTIAL_DELETE (60) * Delegable: Delegation::Delegable * Amendment: featureCredentials - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use CredentialDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/DIDDelete.h b/include/xrpl/protocol_autogen/transactions/DIDDelete.h index 304287883d..885f84718d 100644 --- a/include/xrpl/protocol_autogen/transactions/DIDDelete.h +++ b/include/xrpl/protocol_autogen/transactions/DIDDelete.h @@ -21,7 +21,7 @@ class DIDDeleteBuilder; * Type: ttDID_DELETE (50) * Delegable: Delegation::Delegable * Amendment: featureDID - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use DIDDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/DIDSet.h b/include/xrpl/protocol_autogen/transactions/DIDSet.h index 67e5ba23c5..0679170780 100644 --- a/include/xrpl/protocol_autogen/transactions/DIDSet.h +++ b/include/xrpl/protocol_autogen/transactions/DIDSet.h @@ -21,7 +21,7 @@ class DIDSetBuilder; * Type: ttDID_SET (49) * Delegable: Delegation::Delegable * Amendment: featureDID - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use DIDSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/DelegateSet.h b/include/xrpl/protocol_autogen/transactions/DelegateSet.h index 592a778952..1d70166920 100644 --- a/include/xrpl/protocol_autogen/transactions/DelegateSet.h +++ b/include/xrpl/protocol_autogen/transactions/DelegateSet.h @@ -21,7 +21,7 @@ class DelegateSetBuilder; * Type: ttDELEGATE_SET (64) * Delegable: Delegation::NotDelegable * Amendment: featurePermissionDelegationV1_1 - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use DelegateSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/DepositPreauth.h b/include/xrpl/protocol_autogen/transactions/DepositPreauth.h index b5d575aac5..66c5b390e6 100644 --- a/include/xrpl/protocol_autogen/transactions/DepositPreauth.h +++ b/include/xrpl/protocol_autogen/transactions/DepositPreauth.h @@ -21,7 +21,7 @@ class DepositPreauthBuilder; * Type: ttDEPOSIT_PREAUTH (19) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use DepositPreauthBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/EnableAmendment.h b/include/xrpl/protocol_autogen/transactions/EnableAmendment.h index e811ca16df..08a57540ec 100644 --- a/include/xrpl/protocol_autogen/transactions/EnableAmendment.h +++ b/include/xrpl/protocol_autogen/transactions/EnableAmendment.h @@ -21,7 +21,7 @@ class EnableAmendmentBuilder; * Type: ttAMENDMENT (100) * Delegable: Delegation::NotDelegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use EnableAmendmentBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/EscrowCancel.h b/include/xrpl/protocol_autogen/transactions/EscrowCancel.h index e7e49eca0d..3727bbaa2a 100644 --- a/include/xrpl/protocol_autogen/transactions/EscrowCancel.h +++ b/include/xrpl/protocol_autogen/transactions/EscrowCancel.h @@ -21,7 +21,7 @@ class EscrowCancelBuilder; * Type: ttESCROW_CANCEL (4) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use EscrowCancelBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/EscrowCreate.h b/include/xrpl/protocol_autogen/transactions/EscrowCreate.h index b994e4ec07..3d28a12cee 100644 --- a/include/xrpl/protocol_autogen/transactions/EscrowCreate.h +++ b/include/xrpl/protocol_autogen/transactions/EscrowCreate.h @@ -21,7 +21,7 @@ class EscrowCreateBuilder; * Type: ttESCROW_CREATE (1) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use EscrowCreateBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/EscrowFinish.h b/include/xrpl/protocol_autogen/transactions/EscrowFinish.h index 2476def5c2..1cbc60c738 100644 --- a/include/xrpl/protocol_autogen/transactions/EscrowFinish.h +++ b/include/xrpl/protocol_autogen/transactions/EscrowFinish.h @@ -21,7 +21,7 @@ class EscrowFinishBuilder; * Type: ttESCROW_FINISH (2) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use EscrowFinishBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/LedgerStateFix.h b/include/xrpl/protocol_autogen/transactions/LedgerStateFix.h index af86dea0b0..4c02989f09 100644 --- a/include/xrpl/protocol_autogen/transactions/LedgerStateFix.h +++ b/include/xrpl/protocol_autogen/transactions/LedgerStateFix.h @@ -21,7 +21,7 @@ class LedgerStateFixBuilder; * Type: ttLEDGER_STATE_FIX (53) * Delegable: Delegation::Delegable * Amendment: fixNFTokenPageLinks - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use LedgerStateFixBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverClawback.h b/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverClawback.h index 875e0a4c5e..468ce054c2 100644 --- a/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverClawback.h +++ b/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverClawback.h @@ -21,7 +21,7 @@ class LoanBrokerCoverClawbackBuilder; * Type: ttLOAN_BROKER_COVER_CLAWBACK (78) * Delegable: Delegation::NotDelegable * Amendment: featureLendingProtocol - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use LoanBrokerCoverClawbackBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverDeposit.h b/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverDeposit.h index 38cc113844..0fe1bd7b91 100644 --- a/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverDeposit.h +++ b/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverDeposit.h @@ -21,7 +21,7 @@ class LoanBrokerCoverDepositBuilder; * Type: ttLOAN_BROKER_COVER_DEPOSIT (76) * Delegable: Delegation::NotDelegable * Amendment: featureLendingProtocol - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use LoanBrokerCoverDepositBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverWithdraw.h b/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverWithdraw.h index 148db4292c..4992fb8bbd 100644 --- a/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverWithdraw.h +++ b/include/xrpl/protocol_autogen/transactions/LoanBrokerCoverWithdraw.h @@ -21,7 +21,7 @@ class LoanBrokerCoverWithdrawBuilder; * Type: ttLOAN_BROKER_COVER_WITHDRAW (77) * Delegable: Delegation::NotDelegable * Amendment: featureLendingProtocol - * Privileges: MayAuthorizeMpt + * Privileges: Privilege::MayAuthorizeMpt * * Immutable wrapper around STTx providing type-safe field access. * Use LoanBrokerCoverWithdrawBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/LoanBrokerDelete.h b/include/xrpl/protocol_autogen/transactions/LoanBrokerDelete.h index 29b3a787fd..c449ebaff0 100644 --- a/include/xrpl/protocol_autogen/transactions/LoanBrokerDelete.h +++ b/include/xrpl/protocol_autogen/transactions/LoanBrokerDelete.h @@ -21,7 +21,7 @@ class LoanBrokerDeleteBuilder; * Type: ttLOAN_BROKER_DELETE (75) * Delegable: Delegation::NotDelegable * Amendment: featureLendingProtocol - * Privileges: MustDeleteAcct | MayAuthorizeMpt + * Privileges: Privilege::MustDeleteAcct | Privilege::MayAuthorizeMpt * * Immutable wrapper around STTx providing type-safe field access. * Use LoanBrokerDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/LoanBrokerSet.h b/include/xrpl/protocol_autogen/transactions/LoanBrokerSet.h index 41c87c281d..18f14b7a37 100644 --- a/include/xrpl/protocol_autogen/transactions/LoanBrokerSet.h +++ b/include/xrpl/protocol_autogen/transactions/LoanBrokerSet.h @@ -21,7 +21,7 @@ class LoanBrokerSetBuilder; * Type: ttLOAN_BROKER_SET (74) * Delegable: Delegation::NotDelegable * Amendment: featureLendingProtocol - * Privileges: CreatePseudoAcct | MayAuthorizeMpt + * Privileges: Privilege::CreatePseudoAcct | Privilege::MayAuthorizeMpt * * Immutable wrapper around STTx providing type-safe field access. * Use LoanBrokerSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/LoanDelete.h b/include/xrpl/protocol_autogen/transactions/LoanDelete.h index 8ed537b37a..2696b542da 100644 --- a/include/xrpl/protocol_autogen/transactions/LoanDelete.h +++ b/include/xrpl/protocol_autogen/transactions/LoanDelete.h @@ -21,7 +21,7 @@ class LoanDeleteBuilder; * Type: ttLOAN_DELETE (81) * Delegable: Delegation::NotDelegable * Amendment: featureLendingProtocol - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use LoanDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/LoanManage.h b/include/xrpl/protocol_autogen/transactions/LoanManage.h index 5eb95d21b1..4a665b372f 100644 --- a/include/xrpl/protocol_autogen/transactions/LoanManage.h +++ b/include/xrpl/protocol_autogen/transactions/LoanManage.h @@ -21,7 +21,7 @@ class LoanManageBuilder; * Type: ttLOAN_MANAGE (82) * Delegable: Delegation::NotDelegable * Amendment: featureLendingProtocol - * Privileges: MayModifyVault + * Privileges: Privilege::MayModifyVault * * Immutable wrapper around STTx providing type-safe field access. * Use LoanManageBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/LoanPay.h b/include/xrpl/protocol_autogen/transactions/LoanPay.h index 8e1faeb981..c9224fd697 100644 --- a/include/xrpl/protocol_autogen/transactions/LoanPay.h +++ b/include/xrpl/protocol_autogen/transactions/LoanPay.h @@ -21,7 +21,7 @@ class LoanPayBuilder; * Type: ttLOAN_PAY (84) * Delegable: Delegation::NotDelegable * Amendment: featureLendingProtocol - * Privileges: MayAuthorizeMpt | MustModifyVault + * Privileges: Privilege::MayAuthorizeMpt | Privilege::MustModifyVault * * Immutable wrapper around STTx providing type-safe field access. * Use LoanPayBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/LoanSet.h b/include/xrpl/protocol_autogen/transactions/LoanSet.h index 2cadebd02e..eb04a468f0 100644 --- a/include/xrpl/protocol_autogen/transactions/LoanSet.h +++ b/include/xrpl/protocol_autogen/transactions/LoanSet.h @@ -21,7 +21,7 @@ class LoanSetBuilder; * Type: ttLOAN_SET (80) * Delegable: Delegation::NotDelegable * Amendment: featureLendingProtocol - * Privileges: MayAuthorizeMpt | MustModifyVault + * Privileges: Privilege::MayAuthorizeMpt | Privilege::MustModifyVault * * Immutable wrapper around STTx providing type-safe field access. * Use LoanSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/MPTokenAuthorize.h b/include/xrpl/protocol_autogen/transactions/MPTokenAuthorize.h index 2fb93eaf35..89d026928d 100644 --- a/include/xrpl/protocol_autogen/transactions/MPTokenAuthorize.h +++ b/include/xrpl/protocol_autogen/transactions/MPTokenAuthorize.h @@ -21,7 +21,7 @@ class MPTokenAuthorizeBuilder; * Type: ttMPTOKEN_AUTHORIZE (57) * Delegable: Delegation::Delegable * Amendment: featureMPTokensV1 - * Privileges: MustAuthorizeMpt + * Privileges: Privilege::MustAuthorizeMpt * * Immutable wrapper around STTx providing type-safe field access. * Use MPTokenAuthorizeBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceCreate.h b/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceCreate.h index 82ffba9996..b83de9d843 100644 --- a/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceCreate.h +++ b/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceCreate.h @@ -21,7 +21,7 @@ class MPTokenIssuanceCreateBuilder; * Type: ttMPTOKEN_ISSUANCE_CREATE (54) * Delegable: Delegation::Delegable * Amendment: featureMPTokensV1 - * Privileges: CreateMptIssuance + * Privileges: Privilege::CreateMptIssuance * * Immutable wrapper around STTx providing type-safe field access. * Use MPTokenIssuanceCreateBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceDestroy.h b/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceDestroy.h index cbcd206097..6d1c9b1eaa 100644 --- a/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceDestroy.h +++ b/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceDestroy.h @@ -21,7 +21,7 @@ class MPTokenIssuanceDestroyBuilder; * Type: ttMPTOKEN_ISSUANCE_DESTROY (55) * Delegable: Delegation::Delegable * Amendment: featureMPTokensV1 - * Privileges: DestroyMptIssuance + * Privileges: Privilege::DestroyMptIssuance * * Immutable wrapper around STTx providing type-safe field access. * Use MPTokenIssuanceDestroyBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceSet.h b/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceSet.h index ed7e1f0f6c..43def05194 100644 --- a/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceSet.h +++ b/include/xrpl/protocol_autogen/transactions/MPTokenIssuanceSet.h @@ -21,7 +21,7 @@ class MPTokenIssuanceSetBuilder; * Type: ttMPTOKEN_ISSUANCE_SET (56) * Delegable: Delegation::Delegable * Amendment: featureMPTokensV1 - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use MPTokenIssuanceSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/NFTokenAcceptOffer.h b/include/xrpl/protocol_autogen/transactions/NFTokenAcceptOffer.h index 325d2d7fbd..6c858be721 100644 --- a/include/xrpl/protocol_autogen/transactions/NFTokenAcceptOffer.h +++ b/include/xrpl/protocol_autogen/transactions/NFTokenAcceptOffer.h @@ -21,7 +21,7 @@ class NFTokenAcceptOfferBuilder; * Type: ttNFTOKEN_ACCEPT_OFFER (29) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use NFTokenAcceptOfferBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/NFTokenBurn.h b/include/xrpl/protocol_autogen/transactions/NFTokenBurn.h index ec423ea468..ac831bf45e 100644 --- a/include/xrpl/protocol_autogen/transactions/NFTokenBurn.h +++ b/include/xrpl/protocol_autogen/transactions/NFTokenBurn.h @@ -21,7 +21,7 @@ class NFTokenBurnBuilder; * Type: ttNFTOKEN_BURN (26) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: ChangeNftCounts + * Privileges: Privilege::ChangeNftCounts * * Immutable wrapper around STTx providing type-safe field access. * Use NFTokenBurnBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/NFTokenCancelOffer.h b/include/xrpl/protocol_autogen/transactions/NFTokenCancelOffer.h index 4c4fb1dc65..81f4f3a848 100644 --- a/include/xrpl/protocol_autogen/transactions/NFTokenCancelOffer.h +++ b/include/xrpl/protocol_autogen/transactions/NFTokenCancelOffer.h @@ -21,7 +21,7 @@ class NFTokenCancelOfferBuilder; * Type: ttNFTOKEN_CANCEL_OFFER (28) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use NFTokenCancelOfferBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/NFTokenCreateOffer.h b/include/xrpl/protocol_autogen/transactions/NFTokenCreateOffer.h index a535a578e0..683436f4fd 100644 --- a/include/xrpl/protocol_autogen/transactions/NFTokenCreateOffer.h +++ b/include/xrpl/protocol_autogen/transactions/NFTokenCreateOffer.h @@ -21,7 +21,7 @@ class NFTokenCreateOfferBuilder; * Type: ttNFTOKEN_CREATE_OFFER (27) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use NFTokenCreateOfferBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/NFTokenMint.h b/include/xrpl/protocol_autogen/transactions/NFTokenMint.h index 5af41eb3dd..5a4e3b5b1c 100644 --- a/include/xrpl/protocol_autogen/transactions/NFTokenMint.h +++ b/include/xrpl/protocol_autogen/transactions/NFTokenMint.h @@ -21,7 +21,7 @@ class NFTokenMintBuilder; * Type: ttNFTOKEN_MINT (25) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: ChangeNftCounts + * Privileges: Privilege::ChangeNftCounts * * Immutable wrapper around STTx providing type-safe field access. * Use NFTokenMintBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/NFTokenModify.h b/include/xrpl/protocol_autogen/transactions/NFTokenModify.h index 9b9701fed6..84f1e395d4 100644 --- a/include/xrpl/protocol_autogen/transactions/NFTokenModify.h +++ b/include/xrpl/protocol_autogen/transactions/NFTokenModify.h @@ -21,7 +21,7 @@ class NFTokenModifyBuilder; * Type: ttNFTOKEN_MODIFY (61) * Delegable: Delegation::Delegable * Amendment: featureDynamicNFT - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use NFTokenModifyBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/OfferCancel.h b/include/xrpl/protocol_autogen/transactions/OfferCancel.h index 5e6010e0dd..3e52ebf24b 100644 --- a/include/xrpl/protocol_autogen/transactions/OfferCancel.h +++ b/include/xrpl/protocol_autogen/transactions/OfferCancel.h @@ -21,7 +21,7 @@ class OfferCancelBuilder; * Type: ttOFFER_CANCEL (8) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use OfferCancelBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/OfferCreate.h b/include/xrpl/protocol_autogen/transactions/OfferCreate.h index ffc1216297..774921d87a 100644 --- a/include/xrpl/protocol_autogen/transactions/OfferCreate.h +++ b/include/xrpl/protocol_autogen/transactions/OfferCreate.h @@ -21,7 +21,7 @@ class OfferCreateBuilder; * Type: ttOFFER_CREATE (7) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: MayCreateMpt + * Privileges: Privilege::MayCreateMpt * * Immutable wrapper around STTx providing type-safe field access. * Use OfferCreateBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/OracleDelete.h b/include/xrpl/protocol_autogen/transactions/OracleDelete.h index ebdc8fb7e9..e50b6f6b02 100644 --- a/include/xrpl/protocol_autogen/transactions/OracleDelete.h +++ b/include/xrpl/protocol_autogen/transactions/OracleDelete.h @@ -21,7 +21,7 @@ class OracleDeleteBuilder; * Type: ttORACLE_DELETE (52) * Delegable: Delegation::Delegable * Amendment: featurePriceOracle - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use OracleDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/OracleSet.h b/include/xrpl/protocol_autogen/transactions/OracleSet.h index 0ec6d5cad0..03e4ffc518 100644 --- a/include/xrpl/protocol_autogen/transactions/OracleSet.h +++ b/include/xrpl/protocol_autogen/transactions/OracleSet.h @@ -21,7 +21,7 @@ class OracleSetBuilder; * Type: ttORACLE_SET (51) * Delegable: Delegation::Delegable * Amendment: featurePriceOracle - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use OracleSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/Payment.h b/include/xrpl/protocol_autogen/transactions/Payment.h index 389900bf12..cb177a8d08 100644 --- a/include/xrpl/protocol_autogen/transactions/Payment.h +++ b/include/xrpl/protocol_autogen/transactions/Payment.h @@ -21,7 +21,7 @@ class PaymentBuilder; * Type: ttPAYMENT (0) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: CreateAcct | MayCreateMpt + * Privileges: Privilege::CreateAcct | Privilege::MayCreateMpt * * Immutable wrapper around STTx providing type-safe field access. * Use PaymentBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/PaymentChannelClaim.h b/include/xrpl/protocol_autogen/transactions/PaymentChannelClaim.h index 4c567b13f4..06892955db 100644 --- a/include/xrpl/protocol_autogen/transactions/PaymentChannelClaim.h +++ b/include/xrpl/protocol_autogen/transactions/PaymentChannelClaim.h @@ -21,7 +21,7 @@ class PaymentChannelClaimBuilder; * Type: ttPAYCHAN_CLAIM (15) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use PaymentChannelClaimBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/PaymentChannelCreate.h b/include/xrpl/protocol_autogen/transactions/PaymentChannelCreate.h index 0a513d575a..2a3aebca4c 100644 --- a/include/xrpl/protocol_autogen/transactions/PaymentChannelCreate.h +++ b/include/xrpl/protocol_autogen/transactions/PaymentChannelCreate.h @@ -21,7 +21,7 @@ class PaymentChannelCreateBuilder; * Type: ttPAYCHAN_CREATE (13) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use PaymentChannelCreateBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/PaymentChannelFund.h b/include/xrpl/protocol_autogen/transactions/PaymentChannelFund.h index 51210dd796..9a8c452b0b 100644 --- a/include/xrpl/protocol_autogen/transactions/PaymentChannelFund.h +++ b/include/xrpl/protocol_autogen/transactions/PaymentChannelFund.h @@ -21,7 +21,7 @@ class PaymentChannelFundBuilder; * Type: ttPAYCHAN_FUND (14) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use PaymentChannelFundBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/PermissionedDomainDelete.h b/include/xrpl/protocol_autogen/transactions/PermissionedDomainDelete.h index 3db921776c..1b16b13116 100644 --- a/include/xrpl/protocol_autogen/transactions/PermissionedDomainDelete.h +++ b/include/xrpl/protocol_autogen/transactions/PermissionedDomainDelete.h @@ -21,7 +21,7 @@ class PermissionedDomainDeleteBuilder; * Type: ttPERMISSIONED_DOMAIN_DELETE (63) * Delegable: Delegation::Delegable * Amendment: featurePermissionedDomains - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use PermissionedDomainDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/PermissionedDomainSet.h b/include/xrpl/protocol_autogen/transactions/PermissionedDomainSet.h index 3e352cad76..30832aec8c 100644 --- a/include/xrpl/protocol_autogen/transactions/PermissionedDomainSet.h +++ b/include/xrpl/protocol_autogen/transactions/PermissionedDomainSet.h @@ -21,7 +21,7 @@ class PermissionedDomainSetBuilder; * Type: ttPERMISSIONED_DOMAIN_SET (62) * Delegable: Delegation::Delegable * Amendment: featurePermissionedDomains - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use PermissionedDomainSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/SetFee.h b/include/xrpl/protocol_autogen/transactions/SetFee.h index 177f39199b..9513723e94 100644 --- a/include/xrpl/protocol_autogen/transactions/SetFee.h +++ b/include/xrpl/protocol_autogen/transactions/SetFee.h @@ -21,7 +21,7 @@ class SetFeeBuilder; * Type: ttFEE (101) * Delegable: Delegation::NotDelegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use SetFeeBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/SetRegularKey.h b/include/xrpl/protocol_autogen/transactions/SetRegularKey.h index a943bb0279..042676251b 100644 --- a/include/xrpl/protocol_autogen/transactions/SetRegularKey.h +++ b/include/xrpl/protocol_autogen/transactions/SetRegularKey.h @@ -21,7 +21,7 @@ class SetRegularKeyBuilder; * Type: ttREGULAR_KEY_SET (5) * Delegable: Delegation::NotDelegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use SetRegularKeyBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/SignerListSet.h b/include/xrpl/protocol_autogen/transactions/SignerListSet.h index 6e9d0e41ba..253bcccc1a 100644 --- a/include/xrpl/protocol_autogen/transactions/SignerListSet.h +++ b/include/xrpl/protocol_autogen/transactions/SignerListSet.h @@ -21,7 +21,7 @@ class SignerListSetBuilder; * Type: ttSIGNER_LIST_SET (12) * Delegable: Delegation::NotDelegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use SignerListSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/SponsorshipSet.h b/include/xrpl/protocol_autogen/transactions/SponsorshipSet.h index dfd12a329f..bb3eb2ccf0 100644 --- a/include/xrpl/protocol_autogen/transactions/SponsorshipSet.h +++ b/include/xrpl/protocol_autogen/transactions/SponsorshipSet.h @@ -21,7 +21,7 @@ class SponsorshipSetBuilder; * Type: ttSPONSORSHIP_SET (91) * Delegable: Delegation::Delegable * Amendment: featureSponsor - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use SponsorshipSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/SponsorshipTransfer.h b/include/xrpl/protocol_autogen/transactions/SponsorshipTransfer.h index ab26e887e3..5bd5bc1319 100644 --- a/include/xrpl/protocol_autogen/transactions/SponsorshipTransfer.h +++ b/include/xrpl/protocol_autogen/transactions/SponsorshipTransfer.h @@ -21,7 +21,7 @@ class SponsorshipTransferBuilder; * Type: ttSPONSORSHIP_TRANSFER (90) * Delegable: Delegation::NotDelegable * Amendment: featureSponsor - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use SponsorshipTransferBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/TicketCreate.h b/include/xrpl/protocol_autogen/transactions/TicketCreate.h index 0d8670a76a..4cb109b8f2 100644 --- a/include/xrpl/protocol_autogen/transactions/TicketCreate.h +++ b/include/xrpl/protocol_autogen/transactions/TicketCreate.h @@ -21,7 +21,7 @@ class TicketCreateBuilder; * Type: ttTICKET_CREATE (10) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use TicketCreateBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/TrustSet.h b/include/xrpl/protocol_autogen/transactions/TrustSet.h index 22891b94ec..9d939eb1d0 100644 --- a/include/xrpl/protocol_autogen/transactions/TrustSet.h +++ b/include/xrpl/protocol_autogen/transactions/TrustSet.h @@ -21,7 +21,7 @@ class TrustSetBuilder; * Type: ttTRUST_SET (20) * Delegable: Delegation::Delegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use TrustSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/UNLModify.h b/include/xrpl/protocol_autogen/transactions/UNLModify.h index 6569e4bf7d..f5c94071d7 100644 --- a/include/xrpl/protocol_autogen/transactions/UNLModify.h +++ b/include/xrpl/protocol_autogen/transactions/UNLModify.h @@ -21,7 +21,7 @@ class UNLModifyBuilder; * Type: ttUNL_MODIFY (102) * Delegable: Delegation::NotDelegable * Amendment: uint256{} - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use UNLModifyBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/VaultClawback.h b/include/xrpl/protocol_autogen/transactions/VaultClawback.h index 270ccc94bb..d859b4a446 100644 --- a/include/xrpl/protocol_autogen/transactions/VaultClawback.h +++ b/include/xrpl/protocol_autogen/transactions/VaultClawback.h @@ -21,7 +21,7 @@ class VaultClawbackBuilder; * Type: ttVAULT_CLAWBACK (70) * Delegable: Delegation::NotDelegable * Amendment: featureSingleAssetVault - * Privileges: MayDeleteMpt | MustModifyVault + * Privileges: Privilege::MayDeleteMpt | Privilege::MustModifyVault * * Immutable wrapper around STTx providing type-safe field access. * Use VaultClawbackBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/VaultCreate.h b/include/xrpl/protocol_autogen/transactions/VaultCreate.h index e206925e02..2925302dec 100644 --- a/include/xrpl/protocol_autogen/transactions/VaultCreate.h +++ b/include/xrpl/protocol_autogen/transactions/VaultCreate.h @@ -21,7 +21,7 @@ class VaultCreateBuilder; * Type: ttVAULT_CREATE (65) * Delegable: Delegation::NotDelegable * Amendment: featureSingleAssetVault - * Privileges: CreatePseudoAcct | CreateMptIssuance | MustModifyVault + * Privileges: Privilege::CreatePseudoAcct | Privilege::CreateMptIssuance | Privilege::MustModifyVault * * Immutable wrapper around STTx providing type-safe field access. * Use VaultCreateBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/VaultDelete.h b/include/xrpl/protocol_autogen/transactions/VaultDelete.h index 67cc32f543..3cef0ce599 100644 --- a/include/xrpl/protocol_autogen/transactions/VaultDelete.h +++ b/include/xrpl/protocol_autogen/transactions/VaultDelete.h @@ -21,7 +21,7 @@ class VaultDeleteBuilder; * Type: ttVAULT_DELETE (67) * Delegable: Delegation::NotDelegable * Amendment: featureSingleAssetVault - * Privileges: MustDeleteAcct | DestroyMptIssuance | MustModifyVault + * Privileges: Privilege::MustDeleteAcct | Privilege::DestroyMptIssuance | Privilege::MustModifyVault * * Immutable wrapper around STTx providing type-safe field access. * Use VaultDeleteBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/VaultDeposit.h b/include/xrpl/protocol_autogen/transactions/VaultDeposit.h index 5bb5362114..099342aa0c 100644 --- a/include/xrpl/protocol_autogen/transactions/VaultDeposit.h +++ b/include/xrpl/protocol_autogen/transactions/VaultDeposit.h @@ -21,7 +21,7 @@ class VaultDepositBuilder; * Type: ttVAULT_DEPOSIT (68) * Delegable: Delegation::NotDelegable * Amendment: featureSingleAssetVault - * Privileges: MayAuthorizeMpt | MustModifyVault + * Privileges: Privilege::MayAuthorizeMpt | Privilege::MustModifyVault * * Immutable wrapper around STTx providing type-safe field access. * Use VaultDepositBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/VaultSet.h b/include/xrpl/protocol_autogen/transactions/VaultSet.h index 14df70f13b..33dfe8bf21 100644 --- a/include/xrpl/protocol_autogen/transactions/VaultSet.h +++ b/include/xrpl/protocol_autogen/transactions/VaultSet.h @@ -21,7 +21,7 @@ class VaultSetBuilder; * Type: ttVAULT_SET (66) * Delegable: Delegation::NotDelegable * Amendment: featureSingleAssetVault - * Privileges: MustModifyVault + * Privileges: Privilege::MustModifyVault * * Immutable wrapper around STTx providing type-safe field access. * Use VaultSetBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/VaultWithdraw.h b/include/xrpl/protocol_autogen/transactions/VaultWithdraw.h index 17208cd76c..dfa662f8fd 100644 --- a/include/xrpl/protocol_autogen/transactions/VaultWithdraw.h +++ b/include/xrpl/protocol_autogen/transactions/VaultWithdraw.h @@ -21,7 +21,7 @@ class VaultWithdrawBuilder; * Type: ttVAULT_WITHDRAW (69) * Delegable: Delegation::NotDelegable * Amendment: featureSingleAssetVault - * Privileges: MayDeleteMpt | MayAuthorizeMpt | MustModifyVault + * Privileges: Privilege::MayDeleteMpt | Privilege::MayAuthorizeMpt | Privilege::MustModifyVault * * Immutable wrapper around STTx providing type-safe field access. * Use VaultWithdrawBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/XChainAccountCreateCommit.h b/include/xrpl/protocol_autogen/transactions/XChainAccountCreateCommit.h index b8d551c5e1..a9aa7c2343 100644 --- a/include/xrpl/protocol_autogen/transactions/XChainAccountCreateCommit.h +++ b/include/xrpl/protocol_autogen/transactions/XChainAccountCreateCommit.h @@ -21,7 +21,7 @@ class XChainAccountCreateCommitBuilder; * Type: ttXCHAIN_ACCOUNT_CREATE_COMMIT (44) * Delegable: Delegation::Delegable * Amendment: featureXChainBridge - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use XChainAccountCreateCommitBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/XChainAddAccountCreateAttestation.h b/include/xrpl/protocol_autogen/transactions/XChainAddAccountCreateAttestation.h index 22b57803dc..9cb1f2eaaf 100644 --- a/include/xrpl/protocol_autogen/transactions/XChainAddAccountCreateAttestation.h +++ b/include/xrpl/protocol_autogen/transactions/XChainAddAccountCreateAttestation.h @@ -21,7 +21,7 @@ class XChainAddAccountCreateAttestationBuilder; * Type: ttXCHAIN_ADD_ACCOUNT_CREATE_ATTESTATION (46) * Delegable: Delegation::Delegable * Amendment: featureXChainBridge - * Privileges: CreateAcct + * Privileges: Privilege::CreateAcct * * Immutable wrapper around STTx providing type-safe field access. * Use XChainAddAccountCreateAttestationBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/XChainAddClaimAttestation.h b/include/xrpl/protocol_autogen/transactions/XChainAddClaimAttestation.h index 5e80c05aae..9184c83958 100644 --- a/include/xrpl/protocol_autogen/transactions/XChainAddClaimAttestation.h +++ b/include/xrpl/protocol_autogen/transactions/XChainAddClaimAttestation.h @@ -21,7 +21,7 @@ class XChainAddClaimAttestationBuilder; * Type: ttXCHAIN_ADD_CLAIM_ATTESTATION (45) * Delegable: Delegation::Delegable * Amendment: featureXChainBridge - * Privileges: CreateAcct + * Privileges: Privilege::CreateAcct * * Immutable wrapper around STTx providing type-safe field access. * Use XChainAddClaimAttestationBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/XChainClaim.h b/include/xrpl/protocol_autogen/transactions/XChainClaim.h index ec403b5eb8..e49434c878 100644 --- a/include/xrpl/protocol_autogen/transactions/XChainClaim.h +++ b/include/xrpl/protocol_autogen/transactions/XChainClaim.h @@ -21,7 +21,7 @@ class XChainClaimBuilder; * Type: ttXCHAIN_CLAIM (43) * Delegable: Delegation::Delegable * Amendment: featureXChainBridge - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use XChainClaimBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/XChainCommit.h b/include/xrpl/protocol_autogen/transactions/XChainCommit.h index 48b2263645..471a58dc53 100644 --- a/include/xrpl/protocol_autogen/transactions/XChainCommit.h +++ b/include/xrpl/protocol_autogen/transactions/XChainCommit.h @@ -21,7 +21,7 @@ class XChainCommitBuilder; * Type: ttXCHAIN_COMMIT (42) * Delegable: Delegation::Delegable * Amendment: featureXChainBridge - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use XChainCommitBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/XChainCreateBridge.h b/include/xrpl/protocol_autogen/transactions/XChainCreateBridge.h index 9614b0bd88..ae1269e825 100644 --- a/include/xrpl/protocol_autogen/transactions/XChainCreateBridge.h +++ b/include/xrpl/protocol_autogen/transactions/XChainCreateBridge.h @@ -21,7 +21,7 @@ class XChainCreateBridgeBuilder; * Type: ttXCHAIN_CREATE_BRIDGE (48) * Delegable: Delegation::Delegable * Amendment: featureXChainBridge - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use XChainCreateBridgeBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/XChainCreateClaimID.h b/include/xrpl/protocol_autogen/transactions/XChainCreateClaimID.h index d17759619f..4c6f98e48f 100644 --- a/include/xrpl/protocol_autogen/transactions/XChainCreateClaimID.h +++ b/include/xrpl/protocol_autogen/transactions/XChainCreateClaimID.h @@ -21,7 +21,7 @@ class XChainCreateClaimIDBuilder; * Type: ttXCHAIN_CREATE_CLAIM_ID (41) * Delegable: Delegation::Delegable * Amendment: featureXChainBridge - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use XChainCreateClaimIDBuilder to construct new transactions. diff --git a/include/xrpl/protocol_autogen/transactions/XChainModifyBridge.h b/include/xrpl/protocol_autogen/transactions/XChainModifyBridge.h index e79c9139ce..a3f2930668 100644 --- a/include/xrpl/protocol_autogen/transactions/XChainModifyBridge.h +++ b/include/xrpl/protocol_autogen/transactions/XChainModifyBridge.h @@ -21,7 +21,7 @@ class XChainModifyBridgeBuilder; * Type: ttXCHAIN_MODIFY_BRIDGE (47) * Delegable: Delegation::Delegable * Amendment: featureXChainBridge - * Privileges: NoPriv + * Privileges: Privilege::NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use XChainModifyBridgeBuilder to construct new transactions. diff --git a/include/xrpl/tx/invariants/InvariantCheckPrivilege.h b/include/xrpl/tx/invariants/InvariantCheckPrivilege.h index b2f1c62a54..ca9755ea1c 100644 --- a/include/xrpl/tx/invariants/InvariantCheckPrivilege.h +++ b/include/xrpl/tx/invariants/InvariantCheckPrivilege.h @@ -1,9 +1,7 @@ #pragma once -#include #include - -#include +#include // IWYU pragma: export namespace xrpl { @@ -26,37 +24,8 @@ not have the relevant amendments enabled_. It's intentionally a pain in the neck so that bad code gets caught and fixed as early as possible. */ -// Bitwise flags, 86 files, used in macros files -// NOLINTNEXTLINE(cppcoreguidelines-use-enum-class) -enum Privilege { - NoPriv = 0x0000, // The transaction can not do any of the enumerated operations - CreateAcct = 0x0001, // The transaction can create a new ACCOUNT_ROOT object. - CreatePseudoAcct = 0x0002, // The transaction can create a pseudo account, - // which implies createAcct - MustDeleteAcct = 0x0004, // The transaction must delete an ACCOUNT_ROOT object - MayDeleteAcct = 0x0008, // The transaction may delete an ACCOUNT_ROOT - // object, but does not have to - OverrideFreeze = 0x0010, // The transaction can override some freeze rules - ChangeNftCounts = 0x0020, // The transaction can mint or burn an NFT - CreateMptIssuance = 0x0040, // The transaction can create a new MPT issuance - DestroyMptIssuance = 0x0080, // The transaction can destroy an MPT issuance - MustAuthorizeMpt = 0x0100, // The transaction MUST create or delete an MPT - // object (except by issuer) - MayAuthorizeMpt = 0x0200, // The transaction MAY create or delete an MPT - // object (except by issuer) - MayDeleteMpt = 0x0400, // The transaction MAY delete an MPT object. May not create. - MustModifyVault = 0x0800, // The transaction must modify, delete or create, a vault - MayModifyVault = 0x1000, // The transaction MAY modify, delete or create, a vault - MayCreateMpt = 0x2000, // The transaction MAY create an MPT object, except for issuer. -}; - -constexpr Privilege -operator|(Privilege lhs, Privilege rhs) -{ - return safeCast( - safeCast>(lhs) | - safeCast>(rhs)); -} +// `enum Privilege` and its `operator|` live in , +// alongside the TxSettings struct that carries them out of transactions.macro. bool hasPrivilege(STTx const& tx, Privilege priv); diff --git a/src/libxrpl/protocol/Permissions.cpp b/src/libxrpl/protocol/Permissions.cpp index 2f3e25f823..a5adb294e9 100644 --- a/src/libxrpl/protocol/Permissions.cpp +++ b/src/libxrpl/protocol/Permissions.cpp @@ -10,6 +10,7 @@ #include #include // IWYU pragma: keep #include +#include #include #include @@ -40,16 +41,24 @@ Permission::GranularPermissionEntry::GranularPermissionEntry( Permission::Permission() { { +#pragma push_macro("UNWRAP") +#undef UNWRAP #pragma push_macro("TRANSACTION") #undef TRANSACTION -#define TRANSACTION(tag, value, name, delegable, amendment, ...) \ - txDelegationMap_[static_cast(value)] = {amendment, delegable}; +#define UNWRAP(...) __VA_ARGS__ +#define TRANSACTION(tag, value, name, settings, ...) \ + { \ + TxSettings const s = UNWRAP settings; \ + txDelegationMap_[static_cast(value)] = {s.amendment, s.delegable}; \ + } #include #undef TRANSACTION #pragma pop_macro("TRANSACTION") +#undef UNWRAP +#pragma pop_macro("UNWRAP") } granularPermissionsByName_ = { @@ -242,7 +251,7 @@ Permission::isDelegable(std::uint32_t permissionValue, Rules const& rules) const // Tx-level permissions require the transaction type itself to be delegable, and // the corresponding amendment enabled. - return txIt != txDelegationMap_.end() && txIt->second.delegable != NotDelegable && + return txIt != txDelegationMap_.end() && txIt->second.delegable != Delegation::NotDelegable && amendmentEnabled(txIt->second); } diff --git a/src/libxrpl/protocol/TxFormats.cpp b/src/libxrpl/protocol/TxFormats.cpp index e4d4c4b03c..c393c606fe 100644 --- a/src/libxrpl/protocol/TxFormats.cpp +++ b/src/libxrpl/protocol/TxFormats.cpp @@ -45,7 +45,7 @@ TxFormats::TxFormats() #undef TRANSACTION #define UNWRAP(...) __VA_ARGS__ -#define TRANSACTION(tag, value, name, delegable, amendment, privileges, fields) \ +#define TRANSACTION(tag, value, name, settings, fields) \ add(jss::name, tag, UNWRAP fields, getCommonFields()); #include diff --git a/src/libxrpl/tx/invariants/FreezeInvariant.cpp b/src/libxrpl/tx/invariants/FreezeInvariant.cpp index d6039eabd8..272e52f09a 100644 --- a/src/libxrpl/tx/invariants/FreezeInvariant.cpp +++ b/src/libxrpl/tx/invariants/FreezeInvariant.cpp @@ -288,7 +288,8 @@ TransfersNotFrozen::validateFrozenState( // individually-frozen or deep-frozen AMM trust lines. // Post-fixCleanup3_4_0: AMMClawbacks are allowed to override all freeze types. bool const isAMMLine = change.line->isFlag(lsfAMMNode); - if ((fixOverrideFreeze || !isAMMLine || globalFreeze) && hasPrivilege(tx, OverrideFreeze)) + if ((fixOverrideFreeze || !isAMMLine || globalFreeze) && + hasPrivilege(tx, Privilege::OverrideFreeze)) { JLOG(j.debug()) << "Invariant check allowing funds to be moved " << (change.balanceChangeSign > 0 ? "to" : "from") diff --git a/src/libxrpl/tx/invariants/InvariantCheck.cpp b/src/libxrpl/tx/invariants/InvariantCheck.cpp index 369206d9e6..aa4df8db42 100644 --- a/src/libxrpl/tx/invariants/InvariantCheck.cpp +++ b/src/libxrpl/tx/invariants/InvariantCheck.cpp @@ -25,6 +25,7 @@ #include #include #include +#include #include #include #include @@ -40,12 +41,15 @@ namespace xrpl { +#pragma push_macro("UNWRAP") +#undef UNWRAP #pragma push_macro("TRANSACTION") #undef TRANSACTION -#define TRANSACTION(tag, value, name, delegable, amendment, privileges, ...) \ - case tag: { \ - return (privileges) & priv; \ +#define UNWRAP(...) __VA_ARGS__ +#define TRANSACTION(tag, value, name, settings, ...) \ + case tag: { \ + return ((TxSettings UNWRAP settings).privileges & priv) != Privilege::NoPriv; \ } bool @@ -63,6 +67,8 @@ hasPrivilege(STTx const& tx, Privilege priv) #undef TRANSACTION #pragma pop_macro("TRANSACTION") +#undef UNWRAP +#pragma pop_macro("UNWRAP") // Returns the human-readable name of a ledger entry's type, falling back to // the numeric type if the format is somehow unknown. @@ -436,7 +442,7 @@ AccountRootsNotDeleted::finalize( // transaction when the total AMM LP Tokens balance goes to 0. // A successful AccountDelete or AMMDelete MUST delete exactly // one account root. - if (hasPrivilege(tx, MustDeleteAcct) && isTesSuccess(result)) + if (hasPrivilege(tx, Privilege::MustDeleteAcct) && isTesSuccess(result)) { if (accountsDeleted_ == 1) return true; @@ -457,7 +463,7 @@ AccountRootsNotDeleted::finalize( // A successful AMMWithdraw/AMMClawback MAY delete one account root // when the total AMM LP Tokens balance goes to 0. Not every AMM withdraw // deletes the AMM account, accountsDeleted_ is set if it is deleted. - if (hasPrivilege(tx, MayDeleteAcct) && isTesSuccess(result) && accountsDeleted_ == 1) + if (hasPrivilege(tx, Privilege::MayDeleteAcct) && isTesSuccess(result) && accountsDeleted_ == 1) return true; if (accountsDeleted_ == 0) @@ -760,14 +766,15 @@ ValidNewAccountRoot::finalize( } // From this point on we know exactly one account was created. - if (hasPrivilege(tx, CreateAcct | CreatePseudoAcct) && isTesSuccess(result)) + if (hasPrivilege(tx, Privilege::CreateAcct | Privilege::CreatePseudoAcct) && + isTesSuccess(result)) { bool const pseudoAccount = (pseudoAccount_ && (view.rules().enabled(featureSingleAssetVault) || view.rules().enabled(featureLendingProtocol))); - if (pseudoAccount && !hasPrivilege(tx, CreatePseudoAcct)) + if (pseudoAccount && !hasPrivilege(tx, Privilege::CreatePseudoAcct)) { JLOG(j.fatal()) << "Invariant failed: pseudo-account created by a " "wrong transaction type"; diff --git a/src/libxrpl/tx/invariants/MPTInvariant.cpp b/src/libxrpl/tx/invariants/MPTInvariant.cpp index 045d03ab02..89ade024e6 100644 --- a/src/libxrpl/tx/invariants/MPTInvariant.cpp +++ b/src/libxrpl/tx/invariants/MPTInvariant.cpp @@ -211,7 +211,7 @@ ValidMPTIssuance::finalize( } auto const txnType = tx.getTxnType(); - if (hasPrivilege(tx, CreateMptIssuance)) + if (hasPrivilege(tx, Privilege::CreateMptIssuance)) { if (mptIssuancesCreated_ == 0) { @@ -232,7 +232,7 @@ ValidMPTIssuance::finalize( return mptIssuancesCreated_ == 1 && mptIssuancesDeleted_ == 0; } - if (hasPrivilege(tx, DestroyMptIssuance)) + if (hasPrivilege(tx, Privilege::DestroyMptIssuance)) { if (mptIssuancesDeleted_ == 0) { @@ -259,7 +259,8 @@ ValidMPTIssuance::finalize( // non-amendment-gated side effects. bool const enforceEscrowFinish = (txnType == ttESCROW_FINISH) && (rules.enabled(featureSingleAssetVault) || lendingProtocolEnabled); - if (hasPrivilege(tx, MustAuthorizeMpt | MayAuthorizeMpt) || enforceEscrowFinish) + if (hasPrivilege(tx, Privilege::MustAuthorizeMpt | Privilege::MayAuthorizeMpt) || + enforceEscrowFinish) { bool const submittedByIssuer = tx.isFieldPresent(sfHolder); @@ -275,7 +276,7 @@ ValidMPTIssuance::finalize( "succeeded but deleted issuances"; return false; } - if (mptV2Enabled && hasPrivilege(tx, MayAuthorizeMpt) && + if (mptV2Enabled && hasPrivilege(tx, Privilege::MayAuthorizeMpt) && (txnType == ttAMM_WITHDRAW || txnType == ttAMM_CLAWBACK)) { if (submittedByIssuer && txnType == ttAMM_WITHDRAW && mptokensCreated_ > 0) @@ -311,7 +312,7 @@ ValidMPTIssuance::finalize( return false; } else if ( - !submittedByIssuer && hasPrivilege(tx, MustAuthorizeMpt) && + !submittedByIssuer && hasPrivilege(tx, Privilege::MustAuthorizeMpt) && (mptokensCreated_ + mptokensDeleted_ != 1)) { // if the holder submitted this tx, then a mptoken must be @@ -324,7 +325,7 @@ ValidMPTIssuance::finalize( return true; } - if (hasPrivilege(tx, MayCreateMpt)) + if (hasPrivilege(tx, Privilege::MayCreateMpt)) { bool const submittedByIssuer = tx.isFieldPresent(sfHolder); @@ -379,7 +380,7 @@ ValidMPTIssuance::finalize( return true; } - if (hasPrivilege(tx, MayDeleteMpt) && + if (hasPrivilege(tx, Privilege::MayDeleteMpt) && ((txnType == ttAMM_DELETE && mptokensDeleted_ <= 2) || mptokensDeleted_ == 1) && mptokensCreated_ == 0 && mptIssuancesCreated_ == 0 && mptIssuancesDeleted_ == 0) return true; @@ -856,7 +857,7 @@ ValidMPTTransfer::finalize( ReadView const& view, beast::Journal const& j) { - if (hasPrivilege(tx, OverrideFreeze)) + if (hasPrivilege(tx, Privilege::OverrideFreeze)) return true; // XLS-0066: a broker must be able to default an already-late loan diff --git a/src/libxrpl/tx/invariants/NFTInvariant.cpp b/src/libxrpl/tx/invariants/NFTInvariant.cpp index 52ecbcd9d1..b3b1601018 100644 --- a/src/libxrpl/tx/invariants/NFTInvariant.cpp +++ b/src/libxrpl/tx/invariants/NFTInvariant.cpp @@ -206,7 +206,7 @@ NFTokenCountTracking::finalize( ReadView const& view, beast::Journal const& j) const { - if (!hasPrivilege(tx, ChangeNftCounts)) + if (!hasPrivilege(tx, Privilege::ChangeNftCounts)) { if (beforeMintedTotal_ != afterMintedTotal_) { diff --git a/src/libxrpl/tx/invariants/VaultInvariant.cpp b/src/libxrpl/tx/invariants/VaultInvariant.cpp index 5c25a22987..7ba42383ad 100644 --- a/src/libxrpl/tx/invariants/VaultInvariant.cpp +++ b/src/libxrpl/tx/invariants/VaultInvariant.cpp @@ -346,7 +346,7 @@ ValidVault::finalize( if (afterVault_.empty() && beforeVault_.empty()) { - if (hasPrivilege(tx, MustModifyVault)) + if (hasPrivilege(tx, Privilege::MustModifyVault)) { JLOG(j.fatal()) << // "Invariant failed: vault operation succeeded without modifying " @@ -357,7 +357,8 @@ ValidVault::finalize( return true; // Not a vault operation } - if (!(hasPrivilege(tx, MustModifyVault) || hasPrivilege(tx, MayModifyVault))) + if (!(hasPrivilege(tx, Privilege::MustModifyVault) || + hasPrivilege(tx, Privilege::MayModifyVault))) { JLOG(j.fatal()) << // "Invariant failed: vault updated by a wrong transaction type"; diff --git a/src/test/app/Delegate_test.cpp b/src/test/app/Delegate_test.cpp index 1166816115..3ff90c2a8f 100644 --- a/src/test/app/Delegate_test.cpp +++ b/src/test/app/Delegate_test.cpp @@ -47,6 +47,7 @@ #include #include #include +#include #include #include @@ -2718,19 +2719,24 @@ class Delegate_test : public beast::unit_test::Suite std::size_t delegableCount = 0; +#pragma push_macro("UNWRAP") +#undef UNWRAP #pragma push_macro("TRANSACTION") #undef TRANSACTION -#define TRANSACTION(tag, value, name, txDelegable, ...) \ - if (txDelegable == xrpl::Delegable) \ - { \ - delegableCount++; \ +#define UNWRAP(...) __VA_ARGS__ +#define TRANSACTION(tag, value, name, settings, ...) \ + if ((xrpl::TxSettings UNWRAP settings).delegable == xrpl::Delegation::Delegable) \ + { \ + delegableCount++; \ } #include #undef TRANSACTION #pragma pop_macro("TRANSACTION") +#undef UNWRAP +#pragma pop_macro("UNWRAP") // ==================================================================== // IMPORTANT NOTICE: From d27beef500943c7fb920a9f51eda87a23ccf8ae3 Mon Sep 17 00:00:00 2001 From: Ed Hennis Date: Thu, 20 Aug 2026 19:40:42 +0000 Subject: [PATCH 08/32] perf: Speed up addition time for drastically different exponents (#7825) --- src/libxrpl/basics/Number.cpp | 60 ++++++++-- src/test/protocol/STNumber_test.cpp | 80 +++++--------- src/tests/libxrpl/basics/Number.cpp | 165 ++++++++++++++++++++++++++++ 3 files changed, 244 insertions(+), 61 deletions(-) diff --git a/src/libxrpl/basics/Number.cpp b/src/libxrpl/basics/Number.cpp index 1f2c41809a..0917627073 100644 --- a/src/libxrpl/basics/Number.cpp +++ b/src/libxrpl/basics/Number.cpp @@ -260,6 +260,11 @@ public: unsigned pop() noexcept; + // if true, there are no recoverable digits in the guard, though there may be dropped digits + // (xbit_) + [[nodiscard]] bool + unrecoverable() const noexcept; + // if true, there are no digits in the guard, including dropped digits (xbit_) [[nodiscard]] bool empty() const noexcept; @@ -277,6 +282,17 @@ public: void doDropDigit(T& mantissa, int& exponent) noexcept; + /** + * Drop a digit from the mantissa, and increment the exponent, storing the dropped digit in + * this Guard. + * + * If a drop will not do anything meaningful (there are no recoverable digits in the guard, and + * the mantissa is 0), and if targetExponent > exponent, simply set exponent to targetExponent. + */ + template + void + doDropDigitWithTarget(T& mantissa, int& exponent, int const targetExponent) noexcept; + // Modify the result to the correctly rounded value template void @@ -374,10 +390,16 @@ Number::Guard::pop() noexcept return d; } +inline bool +Number::Guard::unrecoverable() const noexcept +{ + return digits_ == 0; +} + inline bool Number::Guard::empty() const noexcept { - return digits_ == 0 && !xbit_; + return unrecoverable() && !xbit_; } template @@ -401,6 +423,25 @@ Number::Guard::doDropDigit(uint128_t& mantissa, int& exponent) noexce ++exponent; } +template +void +Number::Guard::doDropDigitWithTarget(T& mantissa, int& exponent, int const targetExponent) noexcept +{ + XRPL_ASSERT( + exponent < targetExponent, "xrpl::Number::Guard::doDropDigitWithTarget : something to do"); + while (exponent < targetExponent) + { + if (mantissa == 0 && unrecoverable()) + { + // No number of dropped digits is going to change anything except the exponent at this + // point, so just jump to the result + exponent = targetExponent; + return; + } + doDropDigit(mantissa, exponent); + } +} + template void Number::Guard::pushOverflow(T mantissa) @@ -928,6 +969,7 @@ Number::operator+=(Number const& y) // to match, if necessary. auto const adjust = [&g, &upperLimit]( uint128_t& expandM, int& expandE, uint128_t& shrinkM, int& shrinkE) { + XRPL_ASSERT(shrinkE < expandE, "xrpl::Number::operator+= : exponents ordered correctly"); // Adjust up and down until the exponents match if (g.cuspRoundingFix == MantissaRange::CuspRoundingFix::Enabled330) { @@ -935,6 +977,8 @@ Number::operator+=(Number const& y) // 1. First, shrink the mantissa of shrinkM/shrinkE while shrinkM ends in 0. while (shrinkE < expandE && shrinkM % 10 == 0) { + // Don't use doDropDigitWithTarget here, because the loop will stop before the + // mantissa gets to 0. g.doDropDigit(shrinkM, shrinkE); } @@ -950,10 +994,11 @@ Number::operator+=(Number const& y) // 3. Finally, shrink the mantissa of shrinkM/shrinkE until the exponents match. Any removed // digits will be put into the Guard. This is the only step for non-Enabled330 modes. - while (shrinkE < expandE) + if (shrinkE < expandE) { - g.doDropDigit(shrinkM, shrinkE); + g.doDropDigitWithTarget(shrinkM, shrinkE, expandE); } + XRPL_ASSERT(shrinkE == expandE, "xrpl::Number::operator+= : exponents are equal"); }; // Shrink the mantissa and raise the exponent of the value with the lower exponent. Store any @@ -996,7 +1041,7 @@ Number::operator+=(Number const& y) // round. XRPL_ASSERT( xm > maxMantissa || g.empty(), - "xrpl::Number::operator+ : rounding state expected after add"); + "xrpl::Number::operator+= : rounding state expected after add"); } else { @@ -1038,7 +1083,7 @@ Number::operator+=(Number const& y) } XRPL_ASSERT( xm > maxMantissa || g.empty(), - "xrpl::Number::operator+ : rounding state expected after subtract"); + "xrpl::Number::operator+= : rounding state expected after subtract"); } else { @@ -1330,9 +1375,10 @@ operator rep() const g.setNegative(); drops = -drops; } - while (offset < 0) + if (offset < 0) { - g.doDropDigit(drops, offset); + g.doDropDigitWithTarget(drops, offset, 0); + XRPL_ASSERT(offset == 0, "xrpl::Number::operator rep() : exponents are equal"); } for (; offset > 0; --offset) { diff --git a/src/test/protocol/STNumber_test.cpp b/src/test/protocol/STNumber_test.cpp index 74792e0a70..1e5027df49 100644 --- a/src/test/protocol/STNumber_test.cpp +++ b/src/test/protocol/STNumber_test.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -176,61 +177,32 @@ struct STNumber_test : public beast::unit_test::Suite numberFromJson(sfNumber, std::to_string(kUMax)) == STNumber(sfNumber, Number(kUMax, 0))); + auto const expectJsonThrows = [this]( + json::Value const& num, std::string const& expected) { + try + { + numberFromJson(sfNumber, num); + fail(); + } + catch (std::exception const& e) + { + std::ostringstream out; + out << "Json: " << num.asString() << " got exception: " << e.what() + << ", expected: " << expected; + BEAST_EXPECTS(std::string(e.what()) == expected, out.str()); + } + }; + + // Obvious overflows tested here + expectJsonThrows("1e2000000", "Number::normalize 2"); + expectJsonThrows("1e2000000000", "Number::normalize 2"); + // Obvious non-numbers tested here - try - { - auto _ = numberFromJson(sfNumber, ""); - BEAST_EXPECT(false); - } - catch (std::runtime_error const& e) - { - std::string const expected = "'' is not a number"; - BEAST_EXPECT(e.what() == expected); - } - - try - { - auto _ = numberFromJson(sfNumber, "e"); - BEAST_EXPECT(false); - } - catch (std::runtime_error const& e) - { - std::string const expected = "'e' is not a number"; - BEAST_EXPECT(e.what() == expected); - } - - try - { - auto _ = numberFromJson(sfNumber, "1e"); - BEAST_EXPECT(false); - } - catch (std::runtime_error const& e) - { - std::string const expected = "'1e' is not a number"; - BEAST_EXPECT(e.what() == expected); - } - - try - { - auto _ = numberFromJson(sfNumber, "e2"); - BEAST_EXPECT(false); - } - catch (std::runtime_error const& e) - { - std::string const expected = "'e2' is not a number"; - BEAST_EXPECT(e.what() == expected); - } - - try - { - auto _ = numberFromJson(sfNumber, json::Value()); - BEAST_EXPECT(false); - } - catch (std::runtime_error const& e) - { - std::string const expected = "not a number"; - BEAST_EXPECT(e.what() == expected); - } + expectJsonThrows("", "'' is not a number"); + expectJsonThrows("e", "'e' is not a number"); + expectJsonThrows("1e", "'1e' is not a number"); + expectJsonThrows("e2", "'e2' is not a number"); + expectJsonThrows(json::Value(), "not a number"); try { diff --git a/src/tests/libxrpl/basics/Number.cpp b/src/tests/libxrpl/basics/Number.cpp index 32f93eb1f7..8e958b40d4 100644 --- a/src/tests/libxrpl/basics/Number.cpp +++ b/src/tests/libxrpl/basics/Number.cpp @@ -1,5 +1,6 @@ #include +#include #include #include #include @@ -16,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -183,6 +185,17 @@ TEST(NumberTest, limits) } EXPECT_TRUE(caught); + try + { + Number{1, 2000000, Number::Normalized{}}; + ADD_FAILURE(); + } + catch (std::overflow_error const& e) + { + std::string const expected = "Number::normalize 2"; + EXPECT_EQ(e.what(), expected) << e.what(); + } + if (scale == MantissaRange::MantissaScale::Large330) { // Normalization with the other scales, including the older large mantissa scales, will @@ -406,6 +419,158 @@ TEST(NumberTest, add) } } +TEST(NumberTest, add_sub_extreme_exponents) +{ + for (auto const mantissaScale : MantissaRange::getAllScales()) + { + NumberMantissaScaleGuard const sg(mantissaScale); + + auto const scale = Number::getMantissaScale(); + + EXPECT_EQ(Number::getround(), Number::RoundingMode::ToNearest) + << to_string(Number::getround()); + + // Special cases: Exponents at each end of the allowable range + for (auto const round : + {Number::RoundingMode::ToNearest, + Number::RoundingMode::TowardsZero, + Number::RoundingMode::Downward, + Number::RoundingMode::Upward}) + { + NumberRoundModeGuard const rg{round}; + + auto const bigMantissa = std::invoke([scale, round] { + auto m = Number::maxMantissa(); + if (scale != MantissaRange::MantissaScale::Small) + { + // At the large scales, the maxMantissa is not representable, so we need to + // shrink it down to a representable value. + m /= 10; + } + if (round == Number::RoundingMode::Upward) + { + // Rounding upward will overflow if the mantissa is at maxMantissa. Subtract an + // arbitrary small value to keep the mantissa near the limit, but with a + // little room to grow. 67 has no meaning, except that it's, you know, + // six seven. + m -= 67; + } + return m; + }); + auto const params = { + std::make_pair(Number::minMantissa(), 0), + // At the large scales, the maxMantissa is not representable, so we need to shrink + // it down to a representable value. Rounding upward will overflow if the mantissa + // is right at the all nines value. To keep things a little simpler, do those + // modifications unconditionally. + std::make_pair(bigMantissa, 1), + }; + for (auto const& [mantissa, exponentOffset] : params) + { + auto const x = Number{mantissa, Number::kMaxExponent, Number::Normalized{}}; + auto const y = + Number{mantissa, Number::kMinExponent + exponentOffset, Number::Normalized{}}; + + std::ostringstream detail; + detail << "Scale: " << to_string(scale) << ", round: " << to_string(round) + << ", x: " << x << ", y: " << y; + + EXPECT_EQ(x.mantissa(), mantissa); + EXPECT_EQ(x.exponent(), Number::kMaxExponent); + EXPECT_NE(x, beast::kZero); + EXPECT_EQ(y.mantissa(), mantissa); + EXPECT_EQ(y.exponent(), Number::kMinExponent + exponentOffset); + EXPECT_NE(y, beast::kZero); + + { + // x + y + auto const result = x + y; + + if (round == Number::RoundingMode::Upward) + { + // Rounding upward will take that little x-bit and round result up to the + // next representable value. + EXPECT_NE(result, x); + EXPECT_EQ(result, (Number{x.mantissa() + 1, x.exponent()})); + } + else + { + EXPECT_EQ(result, x); + } + } + { + // x - y + auto const result = x - y; + + switch (round) + { + case Number::RoundingMode::TowardsZero: + if (scale < MantissaRange::MantissaScale::Large330) + { + // Rounding TowardsZero was broken before Large330. + EXPECT_EQ(result, x) << detail.str(); + break; + } + [[fallthrough]]; + case Number::RoundingMode::Downward: + // Rounding downward (or toward zero in Large330) will take that little + // x-bit and round result down to the next representable value. + EXPECT_NE(result, x) << detail.str(); + EXPECT_EQ(result, (Number{x.mantissa() - 1, x.exponent()})) + << detail.str(); + break; + default: + // Rounding up and toNearest rounds back to the original value + EXPECT_EQ(result, x) << detail.str(); + } + } + { + // y + x + auto const result = y + x; + + if (round == Number::RoundingMode::Upward) + { + // Rounding upward will take that little x-bit and round result up to the + // next representable value. + EXPECT_NE(result, x); + EXPECT_EQ(result, (Number{x.mantissa() + 1, x.exponent()})); + } + else + { + EXPECT_EQ(result, x); + } + } + { + // y - x + auto const result = y - x; + + switch (round) + { + case Number::RoundingMode::TowardsZero: + if (scale < MantissaRange::MantissaScale::Large330) + { + // Rounding TowardsZero was broken before Large330. + EXPECT_EQ(result, -x) << detail.str(); + break; + } + [[fallthrough]]; + case Number::RoundingMode::Upward: + // Rounding upward (or toward zero in Large330) will take that little + // x-bit and round result up to the next representable negative value. + EXPECT_NE(result, -x) << detail.str(); + EXPECT_EQ(result, (Number{-x.mantissa() + 1, x.exponent()})) + << detail.str(); + break; + default: + // Rounding up and toNearest rounds back to the original value + EXPECT_EQ(result, -x) << detail.str(); + } + } + } + } + } +} + TEST(NumberTest, sub) { for (auto const mantissaScale : MantissaRange::getAllScales()) From 046d4dd4afca8c532962e8d5714e21b4762afe2c Mon Sep 17 00:00:00 2001 From: Timur Yalymov <36795566+tyalymov@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:30:17 +0000 Subject: [PATCH 09/32] fix: Reject vault deposits that move nothing from the depositor (#8014) Co-authored-by: Cursor --- .../tx/transactors/vault/VaultDeposit.cpp | 43 ++++ src/test/app/vault/VaultBugs_test.cpp | 183 +++++++++++++++++- 2 files changed, 225 insertions(+), 1 deletion(-) diff --git a/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp b/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp index a3c0a94eb5..5ee948bbba 100644 --- a/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp @@ -2,12 +2,15 @@ #include #include +#include #include #include +#include #include #include #include #include +#include #include #include #include @@ -47,6 +50,39 @@ roundToVaultScale(STAmount const& amount, SLE::const_ref vault) return roundToScale(amount, postScale, Number::RoundingMode::Downward); } +// True if debiting `assets` would leave the depositor's balance where it started, so the deposit +// would mint shares against a transfer that never happened. Asking the balance directly whether it +// notices the debit avoids having to infer the rounding step: it has to be the stored balance that +// answers, because that magnitude is what governs the rounding, and it is not the same as the +// spendable amount, which also counts what the counterparty's limit allows. +[[nodiscard]] +static bool +roundsToZeroForDepositor( + ReadView const& view, + AccountID const& account, + STAmount const& assets, + beast::Journal j) +{ + if (assets.integral()) + return false; + + auto const balance = accountHolds( + view, + account, + assets.asset(), + FreezeHandling::ZeroIfFrozen, + AuthHandling::ZeroIfUnauthorized, + j, + SpendableHandling::SimpleBalance); + + if (balance - assets != balance) + return false; + + JLOG(j.warn()) << "VaultDeposit: amount " << assets.getFullText() + << " leaves the depositor's balance " << balance.getFullText() << " unchanged"; + return true; +} + NotTEC VaultDeposit::preflight(PreflightContext const& ctx) { @@ -208,6 +244,7 @@ TER VaultDeposit::doApply() { bool const fix320Enabled = view().rules().enabled(fixCleanup3_2_0); + bool const fix340Enabled = view().rules().enabled(fixCleanup3_4_0); auto const vault = view().peek(keylet::vault(ctx_.tx[sfVaultID])); auto applyViewContext = ctx_.getApplyViewContext(); if (!vault) @@ -308,6 +345,12 @@ VaultDeposit::doApply() return tecINTERNAL; // LCOV_EXCL_STOP } + // What a deposit transfers is not the requested amount but that amount truncated to a + // whole number of shares and converted back, which can be smaller. Only here is that + // value known rather than recomputed, so this is where it can be checked against the + // depositor's balance before anything moves. + if (fix340Enabled && roundsToZeroForDepositor(view(), accountID_, *maybeAssets, j_)) + return tecPRECISION_LOSS; assetsDeposited = *maybeAssets; } catch (std::overflow_error const&) diff --git a/src/test/app/vault/VaultBugs_test.cpp b/src/test/app/vault/VaultBugs_test.cpp index 2dbd20f855..70a350a4f1 100644 --- a/src/test/app/vault/VaultBugs_test.cpp +++ b/src/test/app/vault/VaultBugs_test.cpp @@ -2,9 +2,12 @@ #include #include #include +#include #include +#include #include #include +#include #include #include #include @@ -15,13 +18,17 @@ #include #include #include +#include #include #include +#include #include #include +#include #include #include +#include #include #include #include @@ -408,10 +415,14 @@ private: }; { + // fixCleanup3_4_0 has to be off as well: its depositor-side check + // rejects alice's deposit for the same reason, so the invariant is + // only reachable with neither guard in place. testcase( "bug: VaultDeposit below Vault precision canonicalized to zero " "(pre-fixCleanup3_2_0)"); - runScenario(testableAmendments() - fixCleanup3_2_0, tecINVARIANT_FAILED); + runScenario( + testableAmendments() - fixCleanup3_2_0 - fixCleanup3_4_0, tecINVARIANT_FAILED); } { testcase( @@ -421,6 +432,175 @@ private: } } + // A deposit does not transfer the requested amount. It transfers the + // request truncated to a whole number of shares and converted back, which + // can be strictly smaller. When that smaller value is below half a ULP at + // the depositor's own trust-line scale, the debit rounds away to nothing: + // the depositor pays nothing, while the vault books the assets and mints + // shares. ValidVault catches the desync at finalize time. + // + // Only a non-power-of-ten assets-to-shares ratio is needed, and that + // happens through ordinary use: LoanPay books accrued interest into + // sfAssetsTotal without minting shares. + // + // The fixCleanup3_2_0 guard in preclaim does not help, because it tests the + // raw requested amount, which is large enough to survive the rounding. + // Post-fixCleanup3_4_0 the post-truncation value is checked as well and the + // deposit is rejected with tecPRECISION_LOSS before anything moves. + void + testBugDepositShareTruncationSubUlp() + { + using namespace test::jtx; + using namespace loan_broker; + using namespace loan; + + // How bob's trust line is set up before he deposits. Holding is the plain case: a large + // positive balance whose ULP swallows the debit. InDebt is the case where the stored + // balance and the spendable amount diverge: bob owes the issuer 1e16, and the issuer's + // limit on the same line lets him spend 1000 anyway. Reading the spendable amount there + // reports a small, finely scaled number, while the rounding of the debit is still governed + // by the 1e16 he actually holds. + enum class Line { Holding, InDebt }; + + auto runScenario = [this](FeatureBitset features, Line line, TER expected) { + std::string logs; + Env env(*this, features, std::make_unique(&logs)); + + Account const issuer{"issuer"}; + Account const alice{"alice"}; + Account const carol{"carol"}; + Account const bob{"bob"}; + + env.fund(XRP(100'000), issuer, alice, carol, bob); + env.close(); + env(fset(issuer, asfDefaultRipple)); + env.close(); + + PrettyAsset const usd{issuer["USD"]}; + PrettyAsset const bobUsd{bob["USD"]}; + STAmount const trustLimit{usd.raw(), Number{99'999'999'999'999'999LL}}; + // Bob's balance sits exactly on a multiple-of-10 boundary at the + // 1e16 IOU precision cusp, where one ULP is 10. + STAmount const bobEdge{usd.raw(), Number{10'000'000'000'000'010LL}}; + STAmount const bobDebt{bobUsd.raw(), Number{10'000'000'000'000'000LL}}; + STAmount const oppositeLimit{bobUsd.raw(), Number{10'000'000'000'001'000LL}}; + + env(trust(alice, trustLimit)); + env(trust(carol, trustLimit)); + env(trust(bob, trustLimit)); + env.close(); + + env(pay(issuer, alice, usd(1'000))); + env(pay(issuer, carol, usd(1'000))); + if (line == Line::Holding) + { + env(pay(issuer, bob, bobEdge)); + } + else + { + // The issuer trusts bob's own USD, so bob can issue 1e16 back and still have + // 1000 of spendable room left on the same line. + env(trust(issuer, oppositeLimit)); + env.close(); + env(pay(bob, issuer, bobDebt)); + } + env.close(); + + Vault const vault{env}; + auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = usd}); + vaultTx[sfScale] = 0; + env(vaultTx); + env.close(); + + // Alice deposits 1000 USD, minting 1000 shares 1:1. + env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = usd(1'000)})); + env.close(); + + // A loan broker on the vault, then a bullet loan at 24% interest: + // a single payment, one year out. + auto const brokerKeylet = + keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice))); + env(set(alice, vaultKeylet.key)); + env.close(); + + auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1)); + env(set(carol, brokerKeylet.key, usd(1'000).value()), + loan::kInterestRate(percentageToTenthBips(24)), + kGracePeriod(60), + kPaymentInterval(365 * 24 * 60 * 60), + kPaymentTotal(1), + Sig(sfCounterpartySignature, alice), + Fee(env.current()->fees().base * 2), + Ter(tesSUCCESS)); + env.close(); + + // Advance to just before the single payment falls due and let carol + // repay principal plus interest. LoanPay is what books the accrued + // interest into sfAssetsTotal; under cash-basis accounting LoanSet + // alone does not. Share supply stays at 1000, so + // assetsTotal/sharesTotal becomes 1240/1000. + env.close(std::chrono::seconds{(365 * 24 * 60 * 60) - 3600}); + env(pay(carol, loanKeylet.key, usd(2'000).value()), Ter(tesSUCCESS)); + env.close(); + + // Pin the ratio the rest of the scenario reasons about, so the test cannot quietly + // stop exercising the bug if the setup drifts. + auto const sleVault = env.le(vaultKeylet); + BEAST_EXPECT(sleVault && sleVault->at(sfAssetsTotal) == Number{1'240}); + auto const sleIssuance = env.le(keylet::mptokenIssuance(sleVault->at(sfShareMPTID))); + BEAST_EXPECT(sleIssuance && sleIssuance->at(sfOutstandingAmount) == 1'000); + + // Bob deposits 6 USD, which rounds to 10 at his own trust-line + // scale and so clears the fixCleanup3_2_0 guard. But + // floor(1000 * 6 / 1240) is 4 shares, worth 4 * 1240 / 1000 = 4.96, + // and that is below half a ULP of his balance, so it rounds away to + // nothing when subtracted. + env(vault.deposit({.depositor = bob, .id = vaultKeylet.key, .amount = usd(6)}), + Ter(expected)); + env.close(); + }; + + { + testcase( + "bug: VaultDeposit share truncation lets depositor debit " + "round away to zero (pre-fixCleanup3_4_0)"); + runScenario(testableAmendments() - fixCleanup3_4_0, Line::Holding, tecINVARIANT_FAILED); + } + { + testcase( + "bug: VaultDeposit share truncation lets depositor debit " + "round away to zero (pre-fixCleanup3_2_0 and pre-fixCleanup3_4_0)"); + runScenario( + testableAmendments() - fixCleanup3_2_0 - fixCleanup3_4_0, + Line::Holding, + tecINVARIANT_FAILED); + } + { + testcase( + "bug: VaultDeposit share truncation rejected with " + "tecPRECISION_LOSS (post-fixCleanup3_4_0)"); + runScenario(testableAmendments(), Line::Holding, tecPRECISION_LOSS); + } + { + testcase( + "bug: VaultDeposit share truncation rejected with " + "tecPRECISION_LOSS (post-fixCleanup3_4_0, pre-fixCleanup3_2_0)"); + runScenario(testableAmendments() - fixCleanup3_2_0, Line::Holding, tecPRECISION_LOSS); + } + { + testcase( + "bug: VaultDeposit share truncation against a debt balance " + "round away to zero (pre-fixCleanup3_4_0)"); + runScenario(testableAmendments() - fixCleanup3_4_0, Line::InDebt, tecINVARIANT_FAILED); + } + { + testcase( + "bug: VaultDeposit share truncation against a debt balance rejected with " + "tecPRECISION_LOSS (post-fixCleanup3_4_0)"); + runScenario(testableAmendments(), Line::InDebt, tecPRECISION_LOSS); + } + } + // Bug: ValidVault::visitEntry computes destinationDelta.scale as // max(before_exponent, after_exponent) for RippleState entries. When a // withdrawal credits a destination whose IOU balance sits just below a @@ -801,6 +981,7 @@ public: testBugMakeDeltaPosteriorScale(); testBugMakeDeltaAnteriorScale(); testVaultDepositCanonicalizeToZero(); + testBugDepositShareTruncationSubUlp(); testVaultWithdrawCanonicalizeToZero(); testBugVaultDustDebitCanonicalizesToNoOp(); testVaultDepositNegativeBalanceFromOppositeLimit(); From fe4ccdf7500dfccadda8560261853da44a530cac Mon Sep 17 00:00:00 2001 From: Mayukha Vadari Date: Fri, 21 Aug 2026 15:20:44 +0000 Subject: [PATCH 10/32] fix: Add assert for account_info flags (#7987) --- .../rpc/handlers/account/AccountInfo.cpp | 64 ++++++++++--------- 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/src/xrpld/rpc/handlers/account/AccountInfo.cpp b/src/xrpld/rpc/handlers/account/AccountInfo.cpp index 6b244af1a9..2276f98a0e 100644 --- a/src/xrpld/rpc/handlers/account/AccountInfo.cpp +++ b/src/xrpld/rpc/handlers/account/AccountInfo.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -30,6 +31,7 @@ #include #include #include +#include #include namespace xrpl { @@ -115,29 +117,37 @@ doAccountInfo(rpc::JsonContext& context) } auto const accountID{id.value()}; - static constexpr std::array, 9> kLsFlags{ - {{"defaultRipple", lsfDefaultRipple}, - {"depositAuth", lsfDepositAuth}, - {"disableMasterKey", lsfDisableMaster}, - {"disallowIncomingXRP", lsfDisallowXRP}, - {"globalFreeze", lsfGlobalFreeze}, - {"noFreeze", lsfNoFreeze}, - {"passwordSpent", lsfPasswordSpent}, - {"requireAuthorization", lsfRequireAuth}, - {"requireDestinationTag", lsfRequireDestTag}}}; - - static constexpr std::array, 4> - kDisallowIncomingFlags{ - {{"disallowIncomingNFTokenOffer", lsfDisallowIncomingNFTokenOffer}, + // Flags that are always reported. + static constexpr auto kAccountRootFlags = + std::to_array>( + {{"allowTrustLineClawback", lsfAllowTrustLineClawback}, + {"defaultRipple", lsfDefaultRipple}, + {"depositAuth", lsfDepositAuth}, + {"disableMasterKey", lsfDisableMaster}, {"disallowIncomingCheck", lsfDisallowIncomingCheck}, + {"disallowIncomingNFTokenOffer", lsfDisallowIncomingNFTokenOffer}, {"disallowIncomingPayChan", lsfDisallowIncomingPayChan}, - {"disallowIncomingTrustline", lsfDisallowIncomingTrustline}}}; + {"disallowIncomingTrustline", lsfDisallowIncomingTrustline}, + {"disallowIncomingXRP", lsfDisallowXRP}, + {"globalFreeze", lsfGlobalFreeze}, + {"noFreeze", lsfNoFreeze}, + {"passwordSpent", lsfPasswordSpent}, + {"requireAuthorization", lsfRequireAuth}, + {"requireDestinationTag", lsfRequireDestTag}}); - static constexpr std::pair kAllowTrustLineClawbackFlag{ - "allowTrustLineClawback", lsfAllowTrustLineClawback}; + // Flags that are only reported when their amendment is enabled. This can't be `constexpr`, + // since the amendment IDs are computed at runtime. + static auto const kAmendmentGatedFlags = + std::to_array>( + {{"allowTrustLineLocking", lsfAllowTrustLineLocking, featureTokenEscrow}}); - static constexpr std::pair kAllowTrustLineLockingFlag{ - "allowTrustLineLocking", lsfAllowTrustLineLocking}; + // Every `AccountRoot` flag must be reported by `account_info`, so if a new flag is added, it + // needs to be added to one of the arrays above. This can't be a `static_assert` because + // `getAccountRootFlags()` builds its map at runtime. + XRPL_ASSERT_PARTS( + kAccountRootFlags.size() + kAmendmentGatedFlags.size() == getAccountRootFlags().size(), + "xrpl::doAccountInfo", + "number of account flags"); auto const sleAccepted = ledger->read(keylet::account(accountID)); if (sleAccepted) @@ -157,19 +167,13 @@ doAccountInfo(rpc::JsonContext& context) result[jss::account_data] = jvAccepted; json::Value acctFlags{json::ValueType::Object}; - for (auto const& lsf : kLsFlags) - acctFlags[lsf.first.data()] = sleAccepted->isFlag(lsf.second); + for (auto const& [name, flag] : kAccountRootFlags) + acctFlags[name.data()] = sleAccepted->isFlag(flag); - for (auto const& lsf : kDisallowIncomingFlags) - acctFlags[lsf.first.data()] = sleAccepted->isFlag(lsf.second); - - acctFlags[kAllowTrustLineClawbackFlag.first.data()] = - sleAccepted->isFlag(kAllowTrustLineClawbackFlag.second); - - if (ledger->rules().enabled(featureTokenEscrow)) + for (auto const& [name, flag, amendment] : kAmendmentGatedFlags) { - acctFlags[kAllowTrustLineLockingFlag.first.data()] = - sleAccepted->isFlag(kAllowTrustLineLockingFlag.second); + if (ledger->rules().enabled(amendment)) + acctFlags[name.data()] = sleAccepted->isFlag(flag); } result[jss::account_flags] = std::move(acctFlags); From a097ccebae3cb55b55a62d3fcbcae637dd19ae7f Mon Sep 17 00:00:00 2001 From: Timur Yalymov <36795566+tyalymov@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:50:57 +0000 Subject: [PATCH 11/32] fix: Tighten destination checks on vault withdrawal (#7977) Co-authored-by: Cursor Co-authored-by: Vito Tumas <5780819+Tapanito@users.noreply.github.com> --- include/xrpl/ledger/helpers/VaultHelpers.h | 37 ++++ src/libxrpl/ledger/helpers/VaultHelpers.cpp | 24 +++ .../tx/transactors/vault/VaultDeposit.cpp | 24 +-- .../tx/transactors/vault/VaultWithdraw.cpp | 50 ++++- src/test/app/vault/VaultDomain_test.cpp | 191 ++++++++++++++++++ src/test/app/vault/VaultValidation_test.cpp | 112 ++++++++++ 6 files changed, 418 insertions(+), 20 deletions(-) diff --git a/include/xrpl/ledger/helpers/VaultHelpers.h b/include/xrpl/ledger/helpers/VaultHelpers.h index c898e9e148..e4ed6de0ef 100644 --- a/include/xrpl/ledger/helpers/VaultHelpers.h +++ b/include/xrpl/ledger/helpers/VaultHelpers.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -238,4 +239,40 @@ getVaultPhase( std::optional subscriptionDate, std::optional redemptionDate); +/** + * Controls whether checkVaultDomain reports an expired credential as an + * error. A caller that deletes expired credentials later, in doApply, passes + * Yes and treats the subject as authorized; a caller with no such cleanup + * step must keep the error. + */ +enum class SuppressExpired : bool { No = false, Yes = true }; + +/** + * Checks that subject belongs to the permissioned domain governing a vault's + * shares. + * + * The domain is read from the share issuance rather than from the vault. Vault + * shares are issued by the vault's pseudo-account, which cannot grant an + * authorization explicitly, so domain membership is the only route to being + * authorized: a vault with no domain set has no authorized participants at + * all, and every subject fails with tecNO_AUTH. + * + * Which accounts to check, and whether to check at all, is left to the caller. + * This says nothing about vault privacy or about the roles of the accounts. + * + * @param view The ledger view. + * @param issuance The MPTokenIssuance SLE for the vault's shares. + * @param subject The account whose domain membership is checked. + * @param suppressExpired Whether an expired credential counts as authorized. + * + * @return tesSUCCESS if the subject is a domain member, otherwise the reason + * it is not. + */ +[[nodiscard]] TER +checkVaultDomain( + ReadView const& view, + SLE::const_ref issuance, + AccountID const& subject, + SuppressExpired suppressExpired); + } // namespace xrpl diff --git a/src/libxrpl/ledger/helpers/VaultHelpers.cpp b/src/libxrpl/ledger/helpers/VaultHelpers.cpp index b0d835a423..7f4a7ac03c 100644 --- a/src/libxrpl/ledger/helpers/VaultHelpers.cpp +++ b/src/libxrpl/ledger/helpers/VaultHelpers.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include // IWYU pragma: keep @@ -13,6 +14,7 @@ #include #include // IWYU pragma: keep #include +#include #include #include @@ -242,4 +244,26 @@ getVaultPhase( return VaultPhase::Redemption; } +[[nodiscard]] TER +checkVaultDomain( + ReadView const& view, + SLE::const_ref issuance, + AccountID const& subject, + SuppressExpired suppressExpired) +{ + XRPL_ASSERT( + issuance && issuance->getType() == ltMPTOKEN_ISSUANCE, + "xrpl::checkVaultDomain : valid issuance SLE"); + + auto const maybeDomainID = issuance->at(~sfDomainID); + if (!maybeDomainID) + return tecNO_AUTH; + + auto const err = credentials::validDomain(view, *maybeDomainID, subject); + if (err == tecEXPIRED && suppressExpired == SuppressExpired::Yes) + return tesSUCCESS; + + return err; +} + } // namespace xrpl diff --git a/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp b/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp index 5ee948bbba..27e590338c 100644 --- a/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultDeposit.cpp @@ -6,7 +6,6 @@ #include #include #include -#include #include #include #include @@ -175,26 +174,13 @@ VaultDeposit::preclaim(PreclaimContext const& ctx) return tecLOCKED; } + // The vault owner is authorized to deposit unconditionally. An expired + // credential is tolerated here because doApply deletes it. if (vault->isFlag(lsfVaultPrivate) && account != vault->at(sfOwner)) { - auto const maybeDomainID = sleIssuance->at(~sfDomainID); - // Since this is a private vault and the account is not its owner, we - // perform authorization check based on DomainID read from sleIssuance. - // Had the vault shares been a regular MPToken, we would allow - // authorization granted by the Issuer explicitly, but Vault uses Issuer - // pseudo-account, which cannot grant an authorization. - if (maybeDomainID) - { - // As per validDomain documentation, we suppress tecEXPIRED error - // here, so we can delete any expired credentials inside doApply. - if (auto const err = credentials::validDomain(ctx.view, *maybeDomainID, account); - !isTesSuccess(err) && err != tecEXPIRED) - return err; - } - else - { - return tecNO_AUTH; - } + if (auto const err = checkVaultDomain(ctx.view, sleIssuance, account, SuppressExpired::Yes); + !isTesSuccess(err)) + return err; } // Source MPToken must exist (if asset is an MPT) diff --git a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp index 40689572a0..ffefa51d05 100644 --- a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -79,6 +80,7 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx) auto const fix313Enabled = ctx.view.rules().enabled(fixCleanup3_1_3); auto const fix320Enabled = ctx.view.rules().enabled(fixCleanup3_2_0); auto const fix330Enabled = ctx.view.rules().enabled(fixCleanup3_3_0); + auto const fix340Enabled = ctx.view.rules().enabled(fixCleanup3_4_0); auto const vault = ctx.view.read(keylet::vault(ctx.tx[sfVaultID])); if (!vault) @@ -130,6 +132,17 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx) if (auto const err = credentials::valid(ctx.tx, ctx.view, account, ctx.j); !isTesSuccess(err)) return err; + // A pseudo-account belongs to a ledger object rather than to a person and + // must never receive funds from a user-initiated transaction. Deposit + // authorization, which every pseudo-account carries, already refuses the + // payout, but it reports only that the destination declines deposits and + // leaves the real reason unsaid. + if (fix340Enabled && isPseudoAccount(ctx.view, dstAcct)) + { + JLOG(ctx.j.debug()) << "VaultWithdraw: cannot withdraw into a pseudo-account."; + return tecPSEUDO_ACCOUNT; + } + if (fix313Enabled && amount.asset() == vaultShare) { // Post-fixCleanup3_1_3: if the user specified shares, convert @@ -191,6 +204,39 @@ VaultWithdraw::preclaim(PreclaimContext const& ctx) if (auto const ter = requireAuth(ctx.view, vaultAsset, dstAcct, authType); !isTesSuccess(ter)) return ter; + // The checks above only establish that an account may hold the asset. A + // private vault additionally restricts who may take part in it, so paying + // its asset out to a third party requires both ends of that payout to be + // inside the vault's permissioned domain. VaultDeposit applies the same + // domain check on the way in. + // + // Two cases deliberately skip the check. Withdrawing to self is never + // restricted: losing vault access must not strand funds already deposited. + // The asset issuer is always allowed to receive, which keeps the return + // path for frozen assets open even for a submitter who lost access. + if (fix340Enabled && vault->isFlag(lsfVaultPrivate) && dstAcct != account && + dstAcct != vaultAsset.getIssuer()) + { + auto const sleIssuance = ctx.view.read(keylet::mptokenIssuance(vaultShare)); + if (!sleIssuance) + { + // LCOV_EXCL_START + JLOG(ctx.j.error()) << "VaultWithdraw: missing issuance of vault shares."; + return tefINTERNAL; + // LCOV_EXCL_STOP + } + + // Unlike VaultDeposit we do not suppress tecEXPIRED: there is no + // doApply step here that would clean up the expired credential. + if (auto const ter = checkVaultDomain(ctx.view, sleIssuance, account, SuppressExpired::No); + !isTesSuccess(ter)) + return ter; + + if (auto const ter = checkVaultDomain(ctx.view, sleIssuance, dstAcct, SuppressExpired::No); + !isTesSuccess(ter)) + return ter; + } + if (fix330Enabled) { // checkWithdrawFreeze checks the underlying asset on the source @@ -239,7 +285,9 @@ VaultWithdraw::doApply() // Note, we intentionally do not check lsfVaultPrivate flag on the Vault. If // you have a share in the vault, it means you were at some point authorized // to deposit into it, and this means you are also indefinitely authorized - // to withdraw from it. + // to withdraw it to yourself. Sending the proceeds to somebody else is a + // different matter, and preclaim checks such a withdrawal against the + // vault's permissioned domain. auto const amount = ctx_.tx[sfAmount]; Asset const vaultAsset = vault->at(sfAsset); diff --git a/src/test/app/vault/VaultDomain_test.cpp b/src/test/app/vault/VaultDomain_test.cpp index 5af0842962..5e058a13a8 100644 --- a/src/test/app/vault/VaultDomain_test.cpp +++ b/src/test/app/vault/VaultDomain_test.cpp @@ -572,6 +572,195 @@ private: } } + // Withdrawing out of a private vault to a third party requires both the + // submitter and the destination to be members of the vault's permissioned + // domain. Withdrawal to self is exempt: revoking vault access must not + // trap already deposited funds. The asset issuer is exempt as a + // destination, so that frozen assets can always be returned. + void + testVaultWithdrawPrivateDestinationDomain(FeatureBitset features) + { + using namespace test::jtx; + + bool const withFix = features[fixCleanup3_4_0]; + testcase( + std::string{"VaultWithdraw private vault destination domain check"} + + (withFix ? " (fixCleanup3_4_0)" : " (pre-fix)")); + + Account const issuer{"issuer"}; + Account const owner{"owner"}; + Account const depositor{"depositor"}; + Account const beneficiary{"beneficiary"}; + Account const outsider{"outsider"}; + Account const pdOwner{"pdOwner"}; + Account const credIssuer{"credIssuer"}; + std::string const credType = "credential"; + + Env env{*this, features}; + Vault const vault{env}; + + env.fund( + XRP(100'000), issuer, owner, depositor, beneficiary, outsider, pdOwner, credIssuer); + env.close(); + + PrettyAsset const asset = issuer["IOU"]; + // Everyone holds Layer 1 (asset) permission, so anything blocked below + // is blocked by the Layer 2 (vault) check alone. + for (auto const& account : {owner, depositor, beneficiary, outsider}) + { + env.trust(asset(1'000'000), account); + env(pay(issuer, account, asset(10'000))); + } + env.close(); + + auto const domainId = [&]() { + pdomain::Credentials const credentials{{.issuer = credIssuer, .credType = credType}}; + env(pdomain::setTx(pdOwner, credentials)); + env.close(); + return pdomain::getNewDomain(env.meta()); + }(); + + auto const joinDomain = [&](Account const& account) { + env(credentials::create(account, credIssuer, credType)); + env(credentials::accept(account, credIssuer, credType)); + env.close(); + }; + joinDomain(depositor); + joinDomain(beneficiary); + + auto [createTx, keylet] = + vault.create({.owner = owner, .asset = asset, .flags = tfVaultPrivate}); + env(createTx); + env.close(); + + { + auto tx = vault.set({.owner = owner, .id = keylet.key}); + tx[sfDomainID] = to_string(domainId); + env(tx); + env.close(); + } + + env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1'000)})); + env.close(); + + auto const withdrawTo = [&, keylet = keylet](Account const& destination) { + auto tx = + vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(1)}); + tx[sfDestination] = destination.human(); + return tx; + }; + + { + // Destination holds both layers of permission. + env(withdrawTo(beneficiary)); + env.close(); + } + + { + // Destination may hold the asset but was never let into the vault. + env(withdrawTo(outsider), Ter(withFix ? TER(tecNO_AUTH) : TER(tesSUCCESS))); + env.close(); + } + + { + // The asset issuer can always receive, to keep the recovery path + // for frozen assets open. + env(withdrawTo(issuer)); + env.close(); + } + + { + // The vault owner gets no special treatment as a destination: it + // is a third party like any other and needs domain membership. + env(withdrawTo(owner), Ter(withFix ? TER(tecNO_AUTH) : TER(tesSUCCESS))); + env.close(); + } + + { + // Withdrawal to self needs no Destination and stays unaffected. + env(vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(1)})); + env.close(); + } + + { + // Naming yourself as the Destination is still a withdrawal to self. + env(withdrawTo(depositor)); + env.close(); + } + + { + testcase( + std::string{"VaultWithdraw private vault submitter lost vault access"} + + (withFix ? " (fixCleanup3_4_0)" : " (pre-fix)")); + + env(credentials::deleteCred(credIssuer, depositor, credIssuer, credType)); + env.close(); + + // The exit of last resort: the submitter lost vault access but + // must still be able to redeem its own shares. + env(vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(1)})); + env.close(); + + // Moving funds to anyone else is not allowed any more, even to a + // destination that is itself a domain member. + env(withdrawTo(beneficiary), Ter(withFix ? TER(tecNO_AUTH) : TER(tesSUCCESS))); + env.close(); + + // Returning assets to the issuer stays open regardless. + env(withdrawTo(issuer)); + env.close(); + } + + { + testcase( + std::string{"VaultWithdraw private vault with no domain set"} + + (withFix ? " (fixCleanup3_4_0)" : " (pre-fix)")); + + // Give the submitter its vault access back first, so that the + // vault having no domain is the only reason left to refuse. + env(credentials::create(depositor, credIssuer, credType)); + env(credentials::accept(depositor, credIssuer, credType)); + env.close(); + + auto tx = vault.set({.owner = owner, .id = keylet.key}); + tx[sfDomainID] = "0"; + env(tx); + env.close(); + + // Clearing the domain leaves the vault with nobody it considers + // authorized, so a third-party destination cannot qualify even + // though both ends of the payout hold a credential. + env(withdrawTo(beneficiary), Ter(withFix ? TER(tecNO_AUTH) : TER(tesSUCCESS))); + env.close(); + + // The two exempt paths survive the domain going away. + env(vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(1)})); + env.close(); + + env(withdrawTo(issuer)); + env.close(); + } + + { + testcase( + std::string{"VaultWithdraw public vault destination unaffected"} + + (withFix ? " (fixCleanup3_4_0)" : " (pre-fix)")); + + auto [publicTx, publicKeylet] = vault.create({.owner = owner, .asset = asset}); + env(publicTx); + env.close(); + + env(vault.deposit({.depositor = owner, .id = publicKeylet.key, .amount = asset(100)})); + env.close(); + + auto tx = + vault.withdraw({.depositor = owner, .id = publicKeylet.key, .amount = asset(1)}); + tx[sfDestination] = outsider.human(); + env(tx); + env.close(); + } + } + void testWithdrawCredentialDepositPreauth(FeatureBitset features) { @@ -686,6 +875,8 @@ public: testDomainLossAfterAcquisition(); testDomainCheckBuyerSideOffer(); testWithDomainChecXRP(); + testVaultWithdrawPrivateDestinationDomain(all_ - fixCleanup3_4_0); + testVaultWithdrawPrivateDestinationDomain(all_); testWithdrawCredentialDepositPreauth(all_ - fixCleanup3_4_0); testWithdrawCredentialDepositPreauth(all_); } diff --git a/src/test/app/vault/VaultValidation_test.cpp b/src/test/app/vault/VaultValidation_test.cpp index 4219ce4661..45f6d1deaf 100644 --- a/src/test/app/vault/VaultValidation_test.cpp +++ b/src/test/app/vault/VaultValidation_test.cpp @@ -5,10 +5,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -1068,6 +1070,113 @@ private: } } + // A pseudo-account belongs to a ledger object, so it must never be the + // destination of a withdrawal. The payout is refused either way, by the + // deposit authorization every pseudo-account carries, so the only change + // is a misleading tecNO_PERMISSION becoming tecPSEUDO_ACCOUNT. The check + // runs ahead of the private-vault domain check, which would otherwise + // report a domain problem against an account that can never join one. + void + testVaultWithdrawPseudoAccountDestination(FeatureBitset features) + { + using namespace test::jtx; + + bool const withFix = features[fixCleanup3_4_0]; + testcase( + std::string{"VaultWithdraw pseudo-account destination"} + + (withFix ? " (fixCleanup3_4_0)" : " (pre-fix)")); + + Account const issuer{"issuer"}; + Account const owner{"owner"}; + Account const depositor{"depositor"}; + Account const pdOwner{"pdOwner"}; + Account const credIssuer{"credIssuer"}; + std::string const credType = "credential"; + + Env env{*this, features}; + Vault const vault{env}; + + env.fund(XRP(100'000), issuer, owner, depositor, pdOwner, credIssuer); + // Rippling plays no part in what is being tested here, and would + // otherwise stop the payout before it reaches the check under test. + env(fset(issuer, asfDefaultRipple)); + env.close(); + + PrettyAsset const asset = issuer["IOU"]; + for (auto const& account : {owner, depositor}) + { + env.trust(asset(1'000'000), account); + env(pay(issuer, account, asset(10'000))); + } + env.close(); + + // Another vault over the same asset supplies the destination. Its + // pseudo-account holds a trust line for the asset from creation, so + // the payout is refused for being a pseudo-account and nothing else. + auto const pseudoDestination = [&]() { + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + env(tx); + env.close(); + return Account("otherVault", env.le(keylet)->at(sfAccount)); + }(); + + TER const expected = withFix ? TER(tecPSEUDO_ACCOUNT) : TER(tecNO_PERMISSION); + + auto const withdrawToPseudo = [&](uint256 const& vaultId) { + auto tx = vault.withdraw({.depositor = depositor, .id = vaultId, .amount = asset(1)}); + tx[sfDestination] = pseudoDestination.human(); + return tx; + }; + + { + auto [createTx, keylet] = vault.create({.owner = owner, .asset = asset}); + env(createTx); + env.close(); + + env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1'000)})); + env.close(); + + env(withdrawToPseudo(keylet.key), Ter(expected)); + env.close(); + + // Withdrawing to self out of the same vault stays unaffected. + env(vault.withdraw({.depositor = depositor, .id = keylet.key, .amount = asset(1)})); + env.close(); + } + + { + auto const domainId = [&]() { + pdomain::Credentials const credentials{ + {.issuer = credIssuer, .credType = credType}}; + env(pdomain::setTx(pdOwner, credentials)); + env.close(); + return pdomain::getNewDomain(env.meta()); + }(); + + env(credentials::create(depositor, credIssuer, credType)); + env(credentials::accept(depositor, credIssuer, credType)); + env.close(); + + auto [createTx, keylet] = + vault.create({.owner = owner, .asset = asset, .flags = tfVaultPrivate}); + env(createTx); + env.close(); + + auto setTx = vault.set({.owner = owner, .id = keylet.key}); + setTx[sfDomainID] = to_string(domainId); + env(setTx); + env.close(); + + env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(1'000)})); + env.close(); + + // The domain check never gets a say: the destination is rejected + // for what it is, not for the domain it is missing. + env(withdrawToPseudo(keylet.key), Ter(expected)); + env.close(); + } + } + public: void run() override @@ -1078,6 +1187,9 @@ public: testCreateFailMPT(); testVaultDeleteMemoData(); testVaultCreateLEVersion(); + + testVaultWithdrawPseudoAccountDestination(all_ - fixCleanup3_4_0); + testVaultWithdrawPseudoAccountDestination(all_); } }; From 520650081bda1229b093b2fead94dc0d0066373c Mon Sep 17 00:00:00 2001 From: Timur Yalymov <36795566+tyalymov@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:06:44 +0000 Subject: [PATCH 12/32] fix: Remove credentials pinned to Vault, LoanBroker, and AMM pseudo-accounts (#7877) Co-authored-by: Vito Tumas <5780819+Tapanito@users.noreply.github.com> --- .cspell.config.yaml | 1 + .../xrpl/ledger/helpers/CredentialHelpers.h | 27 ++++ include/xrpl/protocol/Protocol.h | 10 ++ src/libxrpl/ledger/helpers/AMMHelpers.cpp | 14 ++ .../ledger/helpers/CredentialHelpers.cpp | 32 +++++ src/libxrpl/tx/Transactor.cpp | 14 +- src/libxrpl/tx/invariants/MPTInvariant.cpp | 8 ++ .../transactors/lending/LoanBrokerDelete.cpp | 15 +++ .../tx/transactors/vault/VaultDelete.cpp | 14 ++ src/test/app/AMM_test.cpp | 47 +++++++ src/test/app/lending/LoanBroker_test.cpp | 123 ++++++++++++++++++ src/test/app/vault/VaultBugs_test.cpp | 115 ++++++++++++++++ 12 files changed, 416 insertions(+), 4 deletions(-) diff --git a/.cspell.config.yaml b/.cspell.config.yaml index e8c5f3c30f..7b4a280c65 100644 --- a/.cspell.config.yaml +++ b/.cspell.config.yaml @@ -366,6 +366,7 @@ words: - venv - vfalco - vinnie + - vkeylet - wasmi - wextra - wptr diff --git a/include/xrpl/ledger/helpers/CredentialHelpers.h b/include/xrpl/ledger/helpers/CredentialHelpers.h index 8b1c819bf4..6d235b4316 100644 --- a/include/xrpl/ledger/helpers/CredentialHelpers.h +++ b/include/xrpl/ledger/helpers/CredentialHelpers.h @@ -14,6 +14,7 @@ #include #include +#include #include #include #include @@ -33,6 +34,32 @@ checkExpired(SLE const& sleCredential, NetClock::time_point const& closed); [[nodiscard]] TER deleteSLE(ApplyView& view, SLE::ref sleCredential, beast::Journal j); +/** + * @brief Remove credentials pinned to a pseudo-account's owner directory. + * + * Cleans up credentials that were linked to a pseudo-account (Vault, LoanBroker, + * AMM), which such an account can neither accept nor delete. Only credentials + * are removed; every other object is left in place. The walk visits at most + * @p maxNodesToDelete directory entries and charges the ones it leaves alone + * against that budget too, so a directory holding other objects yields fewer + * than @p maxNodesToDelete deletions. On reaching the bound the result is + * `tecINCOMPLETE` and the caller must propagate it so a later transaction + * resumes. + * + * @param view Mutable ledger view. + * @param pseudoAcct The pseudo-account whose directory is cleaned. + * @param maxNodesToDelete Upper bound on directory entries processed in one call. + * @param j Journal for diagnostics. + * @return tesSUCCESS once no credentials remain, tecINCOMPLETE if the bound was + * reached, or a deletion error. + */ +[[nodiscard]] TER +deletePseudoAccountCredentials( + ApplyView& view, + AccountID const& pseudoAcct, + std::uint16_t maxNodesToDelete, + beast::Journal j); + // Amendment and parameters checks for sfCredentialIDs field NotTEC checkFields(STTx const& tx, Rules const& rules, beast::Journal j); diff --git a/include/xrpl/protocol/Protocol.h b/include/xrpl/protocol/Protocol.h index 345baef853..e6768efd76 100644 --- a/include/xrpl/protocol/Protocol.h +++ b/include/xrpl/protocol/Protocol.h @@ -396,6 +396,16 @@ using TxID = uint256; */ constexpr std::uint16_t kMaxDeletableAmmTrustLines = 512; +/** + * The maximum number of owner-directory entries to walk when clearing + * credentials pinned to a pseudo-account, in a single transaction. + * + * The walk stops after this many entries whether or not each one turns out to + * be a credential, so a directory that also holds other objects yields fewer + * deletions per transaction. + */ +constexpr std::uint16_t kMaxDeletablePseudoAccountCredentials = 512; + /** * The maximum length of a URI inside an Oracle */ diff --git a/src/libxrpl/ledger/helpers/AMMHelpers.cpp b/src/libxrpl/ledger/helpers/AMMHelpers.cpp index fcad22d2d5..20a793e4cb 100644 --- a/src/libxrpl/ledger/helpers/AMMHelpers.cpp +++ b/src/libxrpl/ledger/helpers/AMMHelpers.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -690,6 +691,12 @@ deleteAMMTrustLines( return {deleteAMMTrustLine(sb, sleItem, ammAccountID, j), SkipEntry::No}; } + // A credential naming the pseudo-account as subject can't be + // accepted or deleted by it and would otherwise permanently pin the + // AMM. Clean it up here, inside the same bounded walk, so the + // pinned AMM can still be deleted. + if (sb.rules().enabled(fixCleanup3_4_0) && nodeType == ltCREDENTIAL) + return {credentials::deleteSLE(sb, sleItem, j), SkipEntry::No}; // LCOV_EXCL_START JLOG(j.error()) << "deleteAMMObjects: deleting non-trustline or non-MPT " << nodeType; return {tecINTERNAL, SkipEntry::No}; @@ -767,6 +774,8 @@ deleteAMMAccount(Sandbox& sb, Asset const& asset, Asset const& asset2, beast::Jo // LCOV_EXCL_STOP } + // deleteAMMTrustLines also removes any credentials pinned to the AMM + // pseudo-account, within its bounded walk. if (auto const ter = deleteAMMTrustLines(sb, ammAccountID, kMaxDeletableAmmTrustLines, j); !isTesSuccess(ter)) return ter; @@ -908,6 +917,11 @@ isOnlyLiquidityProvider(ReadView const& view, Issue const& ammIssue, AccountID c ++nMPT; continue; } + // A credential naming the pseudo-account as subject can be pinned + // to its owner directory. Ignore it here; deleteAMMTrustLines + // removes it when the AMM is deleted. + if (view.rules().enabled(fixCleanup3_4_0) && entryType == ltCREDENTIAL) + continue; if (entryType != ltRIPPLE_STATE) return std::unexpected(tecINTERNAL); // LCOV_EXCL_LINE auto const lowLimit = sle->getFieldAmount(sfLowLimit); diff --git a/src/libxrpl/ledger/helpers/CredentialHelpers.cpp b/src/libxrpl/ledger/helpers/CredentialHelpers.cpp index 5ba832957d..9c3ca4ec78 100644 --- a/src/libxrpl/ledger/helpers/CredentialHelpers.cpp +++ b/src/libxrpl/ledger/helpers/CredentialHelpers.cpp @@ -5,8 +5,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -127,6 +129,36 @@ deleteSLE(ApplyView& view, SLE::ref sleCredential, beast::Journal j) return tesSUCCESS; } +TER +deletePseudoAccountCredentials( + ApplyView& view, + AccountID const& pseudoAcct, + std::uint16_t maxNodesToDelete, + beast::Journal j) +{ + XRPL_ASSERT( + isPseudoAccount(view.read(keylet::account(pseudoAcct))), + "xrpl::credentials::deletePseudoAccountCredentials : is a pseudo-account"); + + // Delete the credentials linked into the pseudo-account's owner directory, + // visiting at most maxNodesToDelete entries. Any other object is left in + // place; the caller's own checks decide whether the remaining directory + // blocks deletion. If the bound is reached, cleanupOnAccountDelete returns + // tecINCOMPLETE and the caller propagates it so a later transaction resumes. + return cleanupOnAccountDelete( + view, + keylet::ownerDir(pseudoAcct), + [&view, &j](LedgerEntryType nodeType, uint256 const&, SLE::pointer& sleItem) + -> std::pair { + if (nodeType == ltCREDENTIAL) + return {deleteSLE(view, sleItem, j), SkipEntry::No}; + + return {tesSUCCESS, SkipEntry::Yes}; + }, + j, + maxNodesToDelete); +} + NotTEC checkFields(STTx const& tx, Rules const& rules, beast::Journal j) { diff --git a/src/libxrpl/tx/Transactor.cpp b/src/libxrpl/tx/Transactor.cpp index 6bf99e567d..63092cc128 100644 --- a/src/libxrpl/tx/Transactor.cpp +++ b/src/libxrpl/tx/Transactor.cpp @@ -1246,7 +1246,7 @@ removeExpiredNFTokenOffers( } static void -removeExpiredCredentials(ApplyView& view, std::vector const& creds, beast::Journal viewJ) +removeDeletedCredentials(ApplyView& view, std::vector const& creds, beast::Journal viewJ) { for (auto const& index : creds) { @@ -1255,7 +1255,7 @@ removeExpiredCredentials(ApplyView& view, std::vector const& creds, bea if (auto const ter = credentials::deleteSLE(view, sle, viewJ); !isTesSuccess(ter)) { JLOG(viewJ.error()) - << "removeExpiredCredentials: failed to delete expired credential. Err: " + << "removeDeletedCredentials: failed to delete credential. Err: " << transToken(ter); } } @@ -1437,7 +1437,8 @@ Transactor::processPersistentChanges(TER result, XRPAmount fee) // should be used, making it possible to do more useful work // when transactions fail with a `tec` code. - auto typesForResult = [](TER const ter) { + auto typesForResult = [credentialCleanup = + view().rules().enabled(fixCleanup3_4_0)](TER const ter) { std::unordered_set types; if ((ter == tecOVERSIZE) || (ter == tecKILLED)) { @@ -1446,6 +1447,11 @@ Transactor::processPersistentChanges(TER result, XRPAmount fee) else if (ter == tecINCOMPLETE) { types.insert(ltRIPPLE_STATE); + // A bounded pseudo-account credential cleanup (VaultDelete / + // LoanBrokerDelete) persists its partial credential deletions so a + // later transaction can resume. + if (credentialCleanup) + types.insert(ltCREDENTIAL); } else if (ter == tecEXPIRED) { @@ -1523,7 +1529,7 @@ Transactor::processPersistentChanges(TER result, XRPAmount fee) removeDeletedTrustLines(view(), ids, viewJ); break; case ltCREDENTIAL: - removeExpiredCredentials(view(), ids, viewJ); + removeDeletedCredentials(view(), ids, viewJ); break; // LCOV_EXCL_START default: diff --git a/src/libxrpl/tx/invariants/MPTInvariant.cpp b/src/libxrpl/tx/invariants/MPTInvariant.cpp index 89ade024e6..2cfd069420 100644 --- a/src/libxrpl/tx/invariants/MPTInvariant.cpp +++ b/src/libxrpl/tx/invariants/MPTInvariant.cpp @@ -234,6 +234,14 @@ ValidMPTIssuance::finalize( if (hasPrivilege(tx, Privilege::DestroyMptIssuance)) { + // A VaultDelete that is still cleaning up credentials pinned to its + // pseudo-account returns tecINCOMPLETE and has not yet reached the + // share issuance. Don't require the issuance to be removed until + // the deletion completes (a later transaction). + if (rules.enabled(fixCleanup3_4_0) && txnType == ttVAULT_DELETE && + result == tecINCOMPLETE) + return mptIssuancesDeleted_ == 0 && mptIssuancesCreated_ == 0; + if (mptIssuancesDeleted_ == 0) { JLOG(j.fatal()) << "Invariant failed: MPT issuance deletion " diff --git a/src/libxrpl/tx/transactors/lending/LoanBrokerDelete.cpp b/src/libxrpl/tx/transactors/lending/LoanBrokerDelete.cpp index 433d77806a..06907ce366 100644 --- a/src/libxrpl/tx/transactors/lending/LoanBrokerDelete.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanBrokerDelete.cpp @@ -4,11 +4,13 @@ #include #include #include +#include #include #include #include #include #include +#include #include #include #include @@ -140,6 +142,19 @@ LoanBrokerDelete::doApply() auto const brokerPseudoID = broker->at(sfAccount); + // Remove any credentials pinned to the broker pseudo-account before anything + // else. They would otherwise keep its owner directory alive and block + // deletion with tecHAS_OBLIGATIONS. Doing it first means a bounded, + // tecINCOMPLETE cleanup can be resumed by a later transaction without having + // already torn down the broker. + if (view().rules().enabled(fixCleanup3_4_0)) + { + if (auto const ter = credentials::deletePseudoAccountCredentials( + view(), brokerPseudoID, kMaxDeletablePseudoAccountCredentials, j_); + !isTesSuccess(ter)) + return ter; + } + if (!view().dirRemove( keylet::ownerDir(accountID_), broker->at(sfOwnerNode), broker->key(), false)) { diff --git a/src/libxrpl/tx/transactors/vault/VaultDelete.cpp b/src/libxrpl/tx/transactors/vault/VaultDelete.cpp index 497a2f2465..f3a587d5a4 100644 --- a/src/libxrpl/tx/transactors/vault/VaultDelete.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultDelete.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -100,6 +101,19 @@ VaultDelete::doApply() if (!vault) return tefINTERNAL; // LCOV_EXCL_LINE + // Remove any credentials pinned to the vault pseudo-account before anything + // else. They would otherwise keep its owner directory alive and block + // deletion with tecHAS_OBLIGATIONS. Doing it first means a bounded, + // tecINCOMPLETE cleanup can be resumed by a later transaction without having + // already torn down the vault. + if (view().rules().enabled(fixCleanup3_4_0)) + { + if (auto const ter = credentials::deletePseudoAccountCredentials( + view(), vault->at(sfAccount), kMaxDeletablePseudoAccountCredentials, j_); + !isTesSuccess(ter)) + return ter; + } + // Destroy the asset holding. auto asset = vault->at(sfAsset); diff --git a/src/test/app/AMM_test.cpp b/src/test/app/AMM_test.cpp index 0212035c6e..a1d5260606 100644 --- a/src/test/app/AMM_test.cpp +++ b/src/test/app/AMM_test.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -5192,6 +5193,51 @@ private: {features}); } + void + testCredentialPinsPseudoAccount() + { + testcase("Credential pins AMM pseudo-account"); + + using namespace jtx; + FeatureBitset const all{testableAmendments()}; + + // A credential issued to an AMM pseudo-account can't be accepted or + // deleted by it. A pin created before the cure activates stays pinned + // in the pseudo-account's owner directory and makes AMM deletion fail + // with tecINTERNAL (deleteAMMTrustLines rejects the unexpected + // directory entry). + Account const attacker{"attacker"}; + char const credType[] = "FN36"; + + Env env(*this, all - fixCleanup3_3_0 - fixCleanup3_4_0); + fund(env, gw_, {alice_}, XRP(20'000), {USD(10'000)}); + env.fund(XRP(1'000), attacker); + env.close(); + + AMM amm(env, alice_, XRP(10'000), USD(10'000)); + Account const ammAcct{"amm pseudo-account", amm.ammAccount()}; + env.memoize(ammAcct); + + env(credentials::create(ammAcct, attacker, credType)); + env.close(); + auto const credKey = credentials::keylet(ammAcct, attacker, credType); + BEAST_EXPECT(env.le(credKey)); + + // Emptying the AMM would auto-delete it, but the pinned credential makes + // deleteAMMAccount fail; the withdraw is rolled back and the AMM stays. + amm.withdrawAll(alice_, std::nullopt, Ter(tecINTERNAL)); + BEAST_EXPECT(amm.ammExists()); + + env.enableFeature(fixCleanup3_4_0); + env.close(); + + // The pre-existing pin is cleaned up and the AMM deletes. + amm.withdrawAll(alice_); + BEAST_EXPECT(!amm.ammExists()); + BEAST_EXPECT(!env.le(credKey)); + BEAST_EXPECT(!env.le(keylet::ownerDir(amm.ammAccount()))); + } + void testAutoDelete() { @@ -7459,6 +7505,7 @@ private: FeatureBitset const all{testableAmendments()}; testInvalidInstance(); testInstanceCreate(); + testCredentialPinsPseudoAccount(); for (auto const& f : amendmentCombinations({fixCleanup3_3_0, featureAMMClawback})) testInvalidDeposit(f); testDeposit(); diff --git a/src/test/app/lending/LoanBroker_test.cpp b/src/test/app/lending/LoanBroker_test.cpp index 321ed5168f..437a0cea99 100644 --- a/src/test/app/lending/LoanBroker_test.cpp +++ b/src/test/app/lending/LoanBroker_test.cpp @@ -60,6 +60,7 @@ #include #include #include +#include #include #include #include @@ -2871,6 +2872,126 @@ class LoanBroker_test : public beast::unit_test::Suite runTestCases(all_ - fixCleanup3_2_0); } + void + testCredentialPinsPseudoAccount() + { + using namespace test::jtx; + using namespace loan_broker; + + // A credential issued to a LoanBroker pseudo-account can't be accepted + // or deleted by it, so it stays pinned in the pseudo-account's owner + // directory and blocks LoanBrokerDelete with tecHAS_OBLIGATIONS. A pin + // created before the cure activates is removed by LoanBrokerDelete once + // it does. + Account const alice{"alice"}; // vault & broker owner + Account const attacker{"attacker"}; + char const credType[] = "FN36"; + + Env env{*this, all_ - fixCleanup3_3_0 - fixCleanup3_4_0}; + env.fund(XRP(1'000'000), alice, attacker); + env.close(); + + Vault const vault{env}; + auto [vtx, vkeylet] = vault.create({.owner = alice, .asset = xrpIssue()}); + env(vtx); + env.close(); + BEAST_EXPECT(env.le(vkeylet)); + + auto const brokerKeylet = + keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice))); + env(set(alice.id(), vkeylet.key)); + env.close(); + + auto const broker = env.le(brokerKeylet); + BEAST_EXPECT(broker); + Account const pseudo{"broker pseudo-account", broker->at(sfAccount)}; + env.memoize(pseudo); + + testcase("Credential pins broker pseudo-account"); + env(credentials::create(pseudo, attacker, credType)); + env.close(); + + auto const credKey = credentials::keylet(pseudo, attacker, credType); + BEAST_EXPECT(env.le(credKey)); + BEAST_EXPECT(ownerCount(env, attacker) == 1); + + env(del(alice.id(), brokerKeylet.key), Ter(tecHAS_OBLIGATIONS)); + env.close(); + + env.enableFeature(fixCleanup3_4_0); + env.close(); + + // The pre-existing pin no longer blocks deletion; the credential is + // cleaned up and the issuer's owner count is restored. + testcase("LoanBrokerDelete removes pinned credential"); + env(del(alice.id(), brokerKeylet.key)); + env.close(); + + BEAST_EXPECT(!env.le(credKey)); + BEAST_EXPECT(!env.le(brokerKeylet)); + BEAST_EXPECT(!env.le(keylet::account(pseudo.id()))); + BEAST_EXPECT(ownerCount(env, attacker) == 0); + } + + void + testCredentialPinOverflow() + { + using namespace test::jtx; + using namespace loan_broker; + testcase("Credential pin cleanup is bounded (tecINCOMPLETE)"); + + // A pseudo-account can be pinned with more credentials than one + // transaction is allowed to clean up. LoanBrokerDelete then removes + // them a bounded batch at a time, returning tecINCOMPLETE until the + // last batch. + Account const alice{"alice"}; + Account const attacker{"attacker"}; + + Env env{*this, all_ - fixCleanup3_3_0 - fixCleanup3_4_0}; + env.fund(XRP(10'000'000), alice, attacker); + env.close(); + + Vault const vault{env}; + auto [vtx, vkeylet] = vault.create({.owner = alice, .asset = xrpIssue()}); + env(vtx); + env.close(); + BEAST_EXPECT(env.le(vkeylet)); + + auto const brokerKeylet = + keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice))); + env(set(alice.id(), vkeylet.key)); + env.close(); + + auto const broker = env.le(brokerKeylet); + BEAST_EXPECT(broker); + Account const pseudo{"broker pseudo-account", broker->at(sfAccount)}; + env.memoize(pseudo); + + // Pin more than one cleanup batch's worth of credentials. + std::uint16_t const count = kMaxDeletablePseudoAccountCredentials + 3; + for (std::uint16_t i = 0; i < count; ++i) + env(credentials::create(pseudo, attacker, std::to_string(i))); + env.close(); + BEAST_EXPECT(ownerCount(env, attacker) == count); + + env.enableFeature(fixCleanup3_4_0); + env.close(); + + // First delete removes one bounded batch and reports it isn't finished. + env(del(alice.id(), brokerKeylet.key), Ter(tecINCOMPLETE)); + env.close(); + BEAST_EXPECT(env.le(brokerKeylet)); // broker still exists + auto const remaining = ownerCount(env, attacker); + BEAST_EXPECT(remaining > 0 && remaining < count); + + // Second delete finishes the cleanup and removes the broker. + env(del(alice.id(), brokerKeylet.key)); + env.close(); + BEAST_EXPECT(!env.le(brokerKeylet)); + BEAST_EXPECT(!env.le(keylet::account(pseudo.id()))); + BEAST_EXPECT(ownerCount(env, attacker) == 0); + } + public: void run() override @@ -2889,6 +3010,8 @@ public: testDisabled(); testLifecycle(); + testCredentialPinsPseudoAccount(); + testCredentialPinOverflow(); testInvalidLoanBrokerDelete(); testInvalidLoanBrokerSet(); testRequireAuth(); diff --git a/src/test/app/vault/VaultBugs_test.cpp b/src/test/app/vault/VaultBugs_test.cpp index 70a350a4f1..0771d4a450 100644 --- a/src/test/app/vault/VaultBugs_test.cpp +++ b/src/test/app/vault/VaultBugs_test.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -29,6 +30,7 @@ #include #include +#include #include #include #include @@ -972,6 +974,117 @@ private: } } + void + testCredentialPinsPseudoAccount() + { + using namespace test::jtx; + + // A credential issued to a vault pseudo-account can't be accepted or + // deleted by it (pseudo-accounts can't sign), so it stays pinned in the + // pseudo-account's owner directory and blocks VaultDelete with + // tecHAS_OBLIGATIONS. A pin created before the cure activates is removed + // by VaultDelete once it does. + Account const owner{"owner"}; + Account const attacker{"attacker"}; + char const credType[] = "FN36"; + + Env env{*this, all_ - fixCleanup3_3_0 - fixCleanup3_4_0}; + env.fund(XRP(1'000'000), owner, attacker); + env.close(); + + Vault const vault{env}; + PrettyAsset const asset = xrpIssue(); + auto [tx, keylet] = vault.create({.owner = owner, .asset = asset}); + env(tx); + env.close(); + + auto const vaultSle = env.le(keylet); + BEAST_EXPECT(vaultSle); + Account const pseudo{"vault pseudo-account", vaultSle->at(sfAccount)}; + env.memoize(pseudo); + + // The pseudo-account owns the share issuance; the pin must not change + // its owner count (an unaccepted credential is owned by the issuer). + auto const pseudoOwnerCount = ownerCount(env, pseudo); + + testcase("Credential pins vault pseudo-account"); + env(credentials::create(pseudo, attacker, credType)); + env.close(); + + auto const credKey = credentials::keylet(pseudo, attacker, credType); + BEAST_EXPECT(env.le(credKey)); + BEAST_EXPECT(ownerCount(env, attacker) == 1); + BEAST_EXPECT(ownerCount(env, pseudo) == pseudoOwnerCount); + + // The pin blocks deletion of an otherwise-empty vault. + env(vault.del({.owner = owner, .id = keylet.key}), Ter(tecHAS_OBLIGATIONS)); + env.close(); + + env.enableFeature(fixCleanup3_4_0); + env.close(); + + // The pre-existing pin no longer blocks deletion; the credential is + // cleaned up and the issuer's owner count is restored. + testcase("VaultDelete removes pinned credential"); + env(vault.del({.owner = owner, .id = keylet.key})); + env.close(); + + BEAST_EXPECT(!env.le(credKey)); + BEAST_EXPECT(!env.le(keylet)); + BEAST_EXPECT(!env.le(::xrpl::keylet::account(pseudo.id()))); + BEAST_EXPECT(ownerCount(env, attacker) == 0); + } + + void + testCredentialPinOverflow() + { + using namespace test::jtx; + testcase("Credential pin cleanup is bounded (tecINCOMPLETE)"); + + // A pseudo-account can be pinned with more credentials than one + // transaction is allowed to clean up. VaultDelete then removes them a + // bounded batch at a time, returning tecINCOMPLETE until the last batch. + Account const owner{"owner"}; + Account const attacker{"attacker"}; + + Env env{*this, all_ - fixCleanup3_3_0 - fixCleanup3_4_0}; + env.fund(XRP(10'000'000), owner, attacker); + env.close(); + + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = owner, .asset = xrpIssue()}); + env(tx); + env.close(); + auto const vaultSle = env.le(keylet); + BEAST_EXPECT(vaultSle); + Account const pseudo{"vault pseudo-account", vaultSle->at(sfAccount)}; + env.memoize(pseudo); + + // Pin more than one cleanup batch's worth of credentials. + std::uint16_t const count = kMaxDeletablePseudoAccountCredentials + 3; + for (std::uint16_t i = 0; i < count; ++i) + env(credentials::create(pseudo, attacker, std::to_string(i))); + env.close(); + BEAST_EXPECT(ownerCount(env, attacker) == count); + + env.enableFeature(fixCleanup3_4_0); + env.close(); + + // First delete removes one bounded batch and reports it isn't finished. + env(vault.del({.owner = owner, .id = keylet.key}), Ter(tecINCOMPLETE)); + env.close(); + BEAST_EXPECT(env.le(keylet)); // vault still exists + auto const remaining = ownerCount(env, attacker); + BEAST_EXPECT(remaining > 0 && remaining < count); + + // Second delete finishes the cleanup and removes the vault. + env(vault.del({.owner = owner, .id = keylet.key})); + env.close(); + BEAST_EXPECT(!env.le(keylet)); + BEAST_EXPECT(!env.le(::xrpl::keylet::account(pseudo.id()))); + BEAST_EXPECT(ownerCount(env, attacker) == 0); + } + public: void run() override @@ -985,6 +1098,8 @@ public: testVaultWithdrawCanonicalizeToZero(); testBugVaultDustDebitCanonicalizesToNoOp(); testVaultDepositNegativeBalanceFromOppositeLimit(); + testCredentialPinsPseudoAccount(); + testCredentialPinOverflow(); testBug6LimitBypassWithShares(); } }; From 764cbe7c295637e56c383ccf0c83313961328d43 Mon Sep 17 00:00:00 2001 From: Ed Hennis Date: Mon, 24 Aug 2026 14:16:03 +0000 Subject: [PATCH 13/32] perf: Pause online delete if there any gaps in recent ledger history (#5531) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- cfg/xrpld-example.cfg | 20 +- include/xrpl/config/Constants.h | 1 + src/test/app/LedgerMaster_test.cpp | 70 ++++ src/test/app/SHAMapStore_test.cpp | 341 +++++++++++++++++-- src/test/jtx/envconfig.h | 14 + src/test/jtx/impl/envconfig.cpp | 11 + src/xrpld/app/ledger/LedgerMaster.h | 9 +- src/xrpld/app/ledger/detail/LedgerMaster.cpp | 29 +- src/xrpld/app/misc/SHAMapStore.h | 5 +- src/xrpld/app/misc/SHAMapStoreImp.cpp | 303 +++++++++++++--- src/xrpld/app/misc/SHAMapStoreImp.h | 32 +- 11 files changed, 741 insertions(+), 94 deletions(-) diff --git a/cfg/xrpld-example.cfg b/cfg/xrpld-example.cfg index 747bafe077..8c4ae07fb1 100644 --- a/cfg/xrpld-example.cfg +++ b/cfg/xrpld-example.cfg @@ -1094,8 +1094,8 @@ # Default is 100. # # back_off_milliseconds -# Number of milliseconds to wait between -# online_delete batches to allow other functions +# Number of milliseconds to wait between online_delete +# SQL deletion batches to allow other functions # to catch up. # Default is 100. # @@ -1109,10 +1109,22 @@ # The online delete process checks periodically # that xrpld is still in sync with the network, # and that the validated ledger is less than -# 'age_threshold_seconds' old. If not, then continue +# 'age_threshold_seconds' old, and that all +# recent ledgers are available. If not, then continue # sleeping for this number of seconds and # checking until healthy. -# Default is 5. +# Default is 2. +# +# max_waiting_ledgers +# The maximum number of ledgers that may be validated +# while online deletion is waiting for the node to get +# fully synced with the rest of the network. If more than +# this number of ledgers are validated while waiting, then +# online deletion gives up on the current ledger and tries +# again later. Note this only affects situations that cause +# rotation to wait, such as going out of sync, or missing +# ledgers. Forward progress is not penalized. Minimum is 64. +# Default is the online_delete value. # # Notes: # The 'node_db' entry configures the primary, persistent storage. diff --git a/include/xrpl/config/Constants.h b/include/xrpl/config/Constants.h index 85d9e3f147..c78643d6c3 100644 --- a/include/xrpl/config/Constants.h +++ b/include/xrpl/config/Constants.h @@ -125,6 +125,7 @@ struct Keys static constexpr auto kMaximumTxnInLedger = "maximum_txn_in_ledger"; static constexpr auto kMaximumTxnPerAccount = "maximum_txn_per_account"; static constexpr auto kMemoryLevel = "memory_level"; + static constexpr auto kMaxWaitingLedgers = "max_waiting_ledgers"; static constexpr auto kMinLedgersToComputeSizeLimit = "min_ledgers_to_compute_size_limit"; static constexpr auto kMinimumEscalationMultiplier = "minimum_escalation_multiplier"; static constexpr auto kMinimumLastLedgerBuffer = "minimum_last_ledger_buffer"; diff --git a/src/test/app/LedgerMaster_test.cpp b/src/test/app/LedgerMaster_test.cpp index 3cf9b3a9d9..ece25356fd 100644 --- a/src/test/app/LedgerMaster_test.cpp +++ b/src/test/app/LedgerMaster_test.cpp @@ -5,17 +5,21 @@ #include #include +#include #include +#include #include #include #include +#include #include #include #include #include #include +#include #include namespace xrpl::test { @@ -111,6 +115,71 @@ class LedgerMaster_test : public beast::unit_test::Suite } } + void + testCompleteLedgerRange(FeatureBitset features) + { + // Note that this test is intentionally very similar to + // SHAMapStore_test::testLedgerGaps, but has a different + // focus. + + testcase("Complete Ledger operations"); + + using namespace test::jtx; + + auto const deleteInterval = 8; + + Env env{*this, envconfig(onlineDelete, deleteInterval)}; + + auto const alice = Account("alice"); + env.fund(XRP(1000), alice); + env.close(); + + auto& lm = env.app().getLedgerMaster(); + LedgerIndex minSeq = 2; + LedgerIndex maxSeq = env.closed()->header().seq; + auto& store = env.app().getSHAMapStore(); + BEAST_EXPECT(store.rendezvous()); + LedgerIndex lastRotated = store.getLastRotated(); + BEAST_EXPECTS(maxSeq == 3, to_string(maxSeq)); + BEAST_EXPECTS(lm.getCompleteLedgers() == "2-3", lm.getCompleteLedgers()); + BEAST_EXPECTS(lastRotated == 3, to_string(lastRotated)); + BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq, maxSeq) == 0); + BEAST_EXPECT(minSeq + 1 > maxSeq - 1); + BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq - 1, maxSeq + 1) == 2); + BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq - 2, maxSeq - 2) == 2); + BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq + 2, maxSeq + 2) == 2); + + // Close enough ledgers to rotate a few times + for (int i = 0; i < 24; ++i) + { + for (int t = 0; t < 3; ++t) + { + env(noop(alice)); + } + env.close(); + BEAST_EXPECT(store.rendezvous()); + + ++maxSeq; + + if (maxSeq == lastRotated + deleteInterval) + { + minSeq = lastRotated; + lastRotated = maxSeq; + } + BEAST_EXPECTS( + env.closed()->header().seq == maxSeq, to_string(env.closed()->header().seq)); + BEAST_EXPECTS(store.getLastRotated() == lastRotated, to_string(store.getLastRotated())); + std::stringstream expectedRange; + expectedRange << minSeq << "-" << maxSeq; + BEAST_EXPECTS(lm.getCompleteLedgers() == expectedRange.str(), lm.getCompleteLedgers()); + BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq, maxSeq) == 0); + BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq + 1, maxSeq - 1) == 0); + BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq - 1, maxSeq + 1) == 2); + BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq - 2, maxSeq - 2) == 2); + BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq + 2, maxSeq + 2) == 2); + } + } + public: void run() override @@ -124,6 +193,7 @@ public: testWithFeats(FeatureBitset features) { testTxnIdFromIndex(features); + testCompleteLedgerRange(features); } }; diff --git a/src/test/app/SHAMapStore_test.cpp b/src/test/app/SHAMapStore_test.cpp index 82019affba..0a8c51c56a 100644 --- a/src/test/app/SHAMapStore_test.cpp +++ b/src/test/app/SHAMapStore_test.cpp @@ -1,7 +1,9 @@ #include #include #include +#include +#include #include #include #include @@ -22,16 +24,21 @@ #include #include #include +#include #include +#include #include #include #include #include #include #include +#include #include +#include #include +#include namespace xrpl::test { @@ -42,9 +49,8 @@ class SHAMapStore_test : public beast::unit_test::Suite static auto onlineDelete(std::unique_ptr cfg) { - cfg->ledgerHistory = kDeleteInterval; - auto& section = cfg->section(Sections::kNodeDatabase); - section.set(Keys::kOnlineDelete, std::to_string(kDeleteInterval)); + cfg = jtx::onlineDelete(std::move(cfg), kDeleteInterval); + cfg->section(Sections::kNodeDatabase).set(Keys::kRecoveryWaitSeconds, "1"); return cfg; } @@ -143,11 +149,11 @@ class SHAMapStore_test : public beast::unit_test::Suite auto& store = env.app().getSHAMapStore(); int ledgerSeq = 3; - store.rendezvous(); + BEAST_EXPECT(store.rendezvous()); BEAST_EXPECT(!store.getLastRotated()); env.close(); - store.rendezvous(); + BEAST_EXPECT(store.rendezvous()); auto ledger = env.rpc("ledger", "validated"); BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq++))); @@ -227,7 +233,7 @@ public: BEAST_EXPECT(goodLedger(env, ledger, std::to_string(kDeleteInterval + 4))); } - store.rendezvous(); + BEAST_EXPECT(store.rendezvous()); BEAST_EXPECT(store.getLastRotated() == kDeleteInterval + 3); lastRotated = store.getLastRotated(); @@ -254,7 +260,7 @@ public: !getHash(ledgers[i]).empty()); } - store.rendezvous(); + BEAST_EXPECT(store.rendezvous()); BEAST_EXPECT(store.getLastRotated() == kDeleteInterval + lastRotated); @@ -292,7 +298,7 @@ public: BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq), true)); } - store.rendezvous(); + BEAST_EXPECT(store.rendezvous()); // The database will always have back to ledger 2, // regardless of lastRotated. @@ -307,7 +313,7 @@ public: BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq++), true)); } - store.rendezvous(); + BEAST_EXPECT(store.rendezvous()); ledgerCheck(env, ledgerSeq - lastRotated, lastRotated); BEAST_EXPECT(lastRotated != store.getLastRotated()); @@ -323,7 +329,7 @@ public: BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq), true)); } - store.rendezvous(); + BEAST_EXPECT(store.rendezvous()); ledgerCheck(env, kDeleteInterval + 1, lastRotated); BEAST_EXPECT(lastRotated != store.getLastRotated()); @@ -362,7 +368,7 @@ public: BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq), true)); } - store.rendezvous(); + BEAST_EXPECT(store.rendezvous()); ledgerCheck(env, ledgerSeq - 2, 2); BEAST_EXPECT(lastRotated == store.getLastRotated()); @@ -372,7 +378,7 @@ public: BEAST_EXPECT(!rpc::containsError(canDelete[jss::result])); BEAST_EXPECT(canDelete[jss::result][jss::can_delete] == ledgerSeq + (kDeleteInterval / 2)); - store.rendezvous(); + BEAST_EXPECT(store.rendezvous()); ledgerCheck(env, ledgerSeq - 2, 2); BEAST_EXPECT(store.getLastRotated() == lastRotated); @@ -385,7 +391,7 @@ public: BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq++), true)); } - store.rendezvous(); + BEAST_EXPECT(store.rendezvous()); ledgerCheck(env, ledgerSeq - lastRotated, lastRotated); @@ -401,7 +407,7 @@ public: BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq), true)); } - store.rendezvous(); + BEAST_EXPECT(store.rendezvous()); BEAST_EXPECT(store.getLastRotated() == lastRotated); @@ -413,7 +419,7 @@ public: BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq++), true)); } - store.rendezvous(); + BEAST_EXPECT(store.rendezvous()); ledgerCheck(env, ledgerSeq - firstBatch, firstBatch); @@ -435,7 +441,7 @@ public: BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq), true)); } - store.rendezvous(); + BEAST_EXPECT(store.rendezvous()); BEAST_EXPECT(store.getLastRotated() == lastRotated); @@ -447,7 +453,7 @@ public: BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq++), true)); } - store.rendezvous(); + BEAST_EXPECT(store.rendezvous()); ledgerCheck(env, ledgerSeq - lastRotated, lastRotated); @@ -468,7 +474,7 @@ public: BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq), true)); } - store.rendezvous(); + BEAST_EXPECT(store.rendezvous()); BEAST_EXPECT(store.getLastRotated() == lastRotated); @@ -480,7 +486,7 @@ public: BEAST_EXPECT(goodLedger(env, ledger, std::to_string(ledgerSeq++), true)); } - store.rendezvous(); + BEAST_EXPECT(store.rendezvous()); ledgerCheck(env, ledgerSeq - lastRotated, lastRotated); @@ -603,6 +609,302 @@ public: BEAST_EXPECT(dbr->getName() == "3"); } + void + testLedgerGaps() + { + // Note that this test is intentionally very similar to + // LedgerMaster_test::testCompleteLedgerRange, but has a different + // focus. + + testcase("Wait for ledger gaps to fill in"); + + using namespace test::jtx; + + Env env{*this, envconfig(onlineDelete)}; + + auto failureMessage = [&](char const* label, auto expected, auto actual) { + std::stringstream ss; + ss << label << ": Expected: " << expected << ", Got: " << actual; + return ss.str(); + }; + + auto const alice = Account("alice"); + env.fund(XRP(1000), alice); + env.close(); + + auto& lm = env.app().getLedgerMaster(); + LedgerIndex minSeq = 2; + LedgerIndex maxSeq = env.closed()->header().seq; + auto& store = env.app().getSHAMapStore(); + LedgerIndex lastRotated = store.getLastRotated(); + auto& netOPs = env.app().getOPs(); + while (lastRotated != 3) + { + BEAST_EXPECT(store.rendezvous()); + lastRotated = store.getLastRotated(); + } + BEAST_EXPECTS(maxSeq == 3, std::to_string(maxSeq)); + BEAST_EXPECTS(lm.getCompleteLedgers() == "2-3", lm.getCompleteLedgers()); + BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq, maxSeq) == 0); + BEAST_EXPECT(minSeq + 1 > maxSeq - 1); + BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq - 1, maxSeq + 1) == 2); + BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq - 2, maxSeq - 2) == 2); + BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq + 2, maxSeq + 2) == 2); + + auto expectedRange = + [](LedgerIndex minSeq, std::vector const& deleteSeqs, LedgerIndex maxSeq) { + std::stringstream expectedRange; + expectedRange << minSeq; + auto lastDelete = minSeq - 1; + for (auto deleteSeq : deleteSeqs) + { + if (deleteSeq <= lastDelete) + continue; + expectedRange << "-" << (deleteSeq - 1); + if (deleteSeq + 1 <= maxSeq) + expectedRange << "," << (deleteSeq + 1); + lastDelete = deleteSeq; + } + if (lastDelete + 1 < maxSeq) + { + expectedRange << "-" << maxSeq; + } + return expectedRange.str(); + }; + + auto deleteLedgerSeq = + [&lm, &store, &netOPs, &minSeq, &lastRotated, &expectedRange, &failureMessage, this]( + Env& env, + LedgerIndex& maxSeq, + std::vector& deleteSeqs) -> LedgerIndex { + using namespace std::chrono_literals; + + // The next ledger will trigger a rotation. Delete the + // current ledger from LedgerMaster. + + netOPs.setMode(OperatingMode::CONNECTED); + + LedgerIndex const deleteSeq = maxSeq; + std::size_t iterations = 30; + while (!lm.haveLedger(deleteSeq) && --iterations > 0) + { + std::this_thread::sleep_for(10ms); + } + // Even the slowest machines should be able to finalize deleteSeq within 10 + // loops (100ms). If this test ever actually fails feel free to lower this + // cutoff. The intent of this test is to flag if the loop takes a very long + // time, but still allow the rest of this function to finish. + BEAST_EXPECTS(iterations > 20, std::to_string(iterations)); + if (!BEAST_EXPECT(lm.haveLedger(deleteSeq))) + return 0; + + // This test may be timing sensitive, because it's messing with server internals in ways + // that they can't be messed with normally. Sleep a little bit to give the server time + // to finish any internal work before we delete the ledger. + std::this_thread::sleep_for(250ms); + + lm.clearLedger(deleteSeq); + deleteSeqs.push_back(deleteSeq); + if (!BEAST_EXPECT(!lm.haveLedger(deleteSeq))) + return 0; + + BEAST_EXPECTS( + lm.getCompleteLedgers() == expectedRange(minSeq, deleteSeqs, maxSeq), + failureMessage( + "Complete ledgers", + expectedRange(minSeq, deleteSeqs, maxSeq), + lm.getCompleteLedgers())); + BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq, maxSeq) == deleteSeqs.size()); + + if (!BEAST_EXPECT(!lm.haveLedger(deleteSeq))) + return 0; + // Close another ledger, which will trigger a rotation, but the + // rotation will be stuck until the missing ledger is filled in. + env.close(); + // Do not call rendezvous() here without a timeout; it will block until the missing + // ledger is backfilled. That will not happen automatically. It's a manual step that + // is done later in this test. + ++maxSeq; + + if (!BEAST_EXPECT(!lm.haveLedger(deleteSeq))) + return 0; + netOPs.setMode(OperatingMode::FULL); + + if (!BEAST_EXPECT(!lm.haveLedger(deleteSeq))) + return 0; + BEAST_EXPECT(!store.rendezvous(10ms)); + BEAST_EXPECT(netOPs.getOperatingMode() == OperatingMode::FULL); + + // Nothing has changed + BEAST_EXPECTS( + store.getLastRotated() == lastRotated, + failureMessage("lastRotated", lastRotated, store.getLastRotated())); + BEAST_EXPECTS( + lm.getCompleteLedgers() == expectedRange(minSeq, deleteSeqs, maxSeq), + failureMessage( + "Complete ledgers", + expectedRange(minSeq, deleteSeqs, maxSeq), + lm.getCompleteLedgers())); + + return deleteSeq; + }; + + std::vector deleteSeqs; + + // Close enough ledgers to rotate a few times + while (maxSeq < 40) + { + for (int t = 0; t < 3; ++t) + { + env(noop(alice)); + } + env.close(); + BEAST_EXPECT(store.rendezvous()); + + ++maxSeq; + + if (maxSeq + 1 == lastRotated + kDeleteInterval) + { + using namespace std::chrono_literals; + + { + // Trigger the circuit breaker in SHAMapStoreImp::healthWait() to ensure it + // doesn't block forever. + LedgerIndex const deleteSeq = deleteLedgerSeq(env, maxSeq, deleteSeqs); + if (!BEAST_EXPECT(deleteSeq > 0)) + return; + if (!BEAST_EXPECT(!lm.haveLedger(deleteSeq))) + return; + + // Close 7 more ledgers, waiting a little bit in between to + // simulate the ledger making progress while online delete waits + // for the missing ledger to be filled in. + // After the 7th ledger, the circuit breaker will trigger and abort the attempt. + while (maxSeq < lastRotated + (kDeleteInterval * 2) - 2) + { + env.close(); + ++maxSeq; + // Nothing has changed + BEAST_EXPECTS( + store.getLastRotated() == lastRotated, + failureMessage("lastRotated", lastRotated, store.getLastRotated())); + BEAST_EXPECTS( + lm.getCompleteLedgers() == expectedRange(minSeq, deleteSeqs, maxSeq), + failureMessage( + "Complete Ledgers", + expectedRange(minSeq, deleteSeqs, maxSeq), + lm.getCompleteLedgers())); + // The Store is "stuck" in healthWait() and won't finish the run() loop + // until it's backfilled + if (!BEAST_EXPECT(!lm.haveLedger(deleteSeq))) + return; + } + + // Close one more ledger, which will NOT trigger the circuit breaker. Wait for + // the full 1 second recovery wait timeout to ensure the circuit breaker is not + // triggered. + env.close(); + ++maxSeq; + // The Store is "stuck" in healthWait() and won't finish the run() loop + // until it's backfilled + BEAST_EXPECT(!store.rendezvous(1s)); + + // Close one more ledger, which will trigger the circuit breaker and abort the + // attempt to rotate. + env.close(); + ++maxSeq; + // Nothing has changed + BEAST_EXPECTS( + store.getLastRotated() == lastRotated, + failureMessage("lastRotated", lastRotated, store.getLastRotated())); + BEAST_EXPECTS( + lm.getCompleteLedgers() == expectedRange(minSeq, deleteSeqs, maxSeq), + failureMessage( + "Complete Ledgers", + expectedRange(minSeq, deleteSeqs, maxSeq), + lm.getCompleteLedgers())); + + // The circuit breaker has been triggered. + BEAST_EXPECT(store.rendezvous()); + } + { + // Recover before the circuit breaker triggers, so the test can continue. + LedgerIndex const deleteSeq = deleteLedgerSeq(env, maxSeq, deleteSeqs); + if (!BEAST_EXPECT(deleteSeq > 0)) + return; + if (!BEAST_EXPECT(!lm.haveLedger(deleteSeq))) + return; + + // Close 5 more ledgers, waiting a little bit in between to + // simulate the ledger making progress while online delete waits + // for the missing ledger to be filled in. + // This ensures the healthWait check has time to run and + // detect the gap. + for (int l = 0; l < 5; ++l) + { + env.close(); + ++maxSeq; + // Nothing has changed + BEAST_EXPECTS( + store.getLastRotated() == lastRotated, + failureMessage("lastRotated", lastRotated, store.getLastRotated())); + BEAST_EXPECTS( + lm.getCompleteLedgers() == expectedRange(minSeq, deleteSeqs, maxSeq), + failureMessage( + "Complete Ledgers", + expectedRange(minSeq, deleteSeqs, maxSeq), + lm.getCompleteLedgers())); + if (!BEAST_EXPECT(!lm.haveLedger(deleteSeq))) + return; + } + + // The Store is "stuck" in healthWait() and won't finish the run() loop + // until it's backfilled + // Wait for the full 1 second recovery wait timeout to ensure the circuit + // breaker is not triggered, and this isn't some other timing fluke. + BEAST_EXPECT(!store.rendezvous(1s)); + + // Put the missing ledger back in LedgerMaster + lm.setLedgerRangePresent(deleteSeq, deleteSeq); + BEAST_EXPECT(deleteSeqs.back() == deleteSeq); + deleteSeqs.pop_back(); + + // Wait for the rotation to finish + BEAST_EXPECT(store.rendezvous()); + + minSeq = lastRotated; + while (deleteSeqs.front() < minSeq) + { + deleteSeqs.erase(deleteSeqs.begin()); + } + lastRotated = deleteSeq + 1; + } + } + BEAST_EXPECT(maxSeq != lastRotated + kDeleteInterval); + BEAST_EXPECTS( + env.closed()->header().seq == maxSeq, + failureMessage("maxSeq", maxSeq, env.closed()->header().seq)); + BEAST_EXPECTS( + store.getLastRotated() == lastRotated, + failureMessage("lastRotated", lastRotated, store.getLastRotated())); + { + auto const expected = expectedRange(minSeq, deleteSeqs, maxSeq); + BEAST_EXPECTS( + lm.getCompleteLedgers() == expected, + failureMessage("CompleteLedgers", expected, lm.getCompleteLedgers())); + } + BEAST_EXPECT(lm.missingFromCompleteLedgerRange(minSeq, maxSeq) == deleteSeqs.size()); + BEAST_EXPECT( + lm.missingFromCompleteLedgerRange(minSeq + 1, maxSeq - 1) == deleteSeqs.size()); + BEAST_EXPECT( + lm.missingFromCompleteLedgerRange(minSeq - 1, maxSeq + 1) == deleteSeqs.size() + 2); + BEAST_EXPECT( + lm.missingFromCompleteLedgerRange(minSeq - 2, maxSeq - 2) == deleteSeqs.size() + 2); + BEAST_EXPECT( + lm.missingFromCompleteLedgerRange(minSeq + 2, maxSeq + 2) == deleteSeqs.size() + 2); + } + } + void run() override { @@ -610,6 +912,7 @@ public: testAutomatic(); testCanDelete(); testRotate(); + testLedgerGaps(); } }; diff --git a/src/test/jtx/envconfig.h b/src/test/jtx/envconfig.h index 1f920fca58..5ad24e25c4 100644 --- a/src/test/jtx/envconfig.h +++ b/src/test/jtx/envconfig.h @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -62,6 +63,19 @@ envconfig(F&& modfunc, Args&&... args) return modfunc(envconfig(), std::forward(args)...); } +/** + * @brief adjust config to enable online_delete + * + * @param cfg config instance to be modified + * + * @param deleteInterval how many new ledgers should be available before + * rotating. Defaults to 8, because the standalone minimum is 8. + * + * @return unique_ptr to Config instance + */ +std::unique_ptr +onlineDelete(std::unique_ptr cfg, std::uint32_t deleteInterval = 8); + /** * @brief adjust config so no admin ports are enabled * diff --git a/src/test/jtx/impl/envconfig.cpp b/src/test/jtx/impl/envconfig.cpp index bc65738b44..14690058ec 100644 --- a/src/test/jtx/impl/envconfig.cpp +++ b/src/test/jtx/impl/envconfig.cpp @@ -7,8 +7,10 @@ #include #include +#include #include #include +#include #include namespace xrpl::test { @@ -60,6 +62,15 @@ setupConfigForUnitTests(Config& cfg) namespace jtx { +std::unique_ptr +onlineDelete(std::unique_ptr cfg, std::uint32_t deleteInterval) +{ + cfg->ledgerHistory = deleteInterval; + auto& section = cfg->section(Sections::kNodeDatabase); + section.set(Keys::kOnlineDelete, std::to_string(deleteInterval)); + return cfg; +} + std::unique_ptr noAdmin(std::unique_ptr cfg) { diff --git a/src/xrpld/app/ledger/LedgerMaster.h b/src/xrpld/app/ledger/LedgerMaster.h index 32163fd57b..140b12fa59 100644 --- a/src/xrpld/app/ledger/LedgerMaster.h +++ b/src/xrpld/app/ledger/LedgerMaster.h @@ -123,7 +123,10 @@ public: failedSave(std::uint32_t seq, uint256 const& hash); std::string - getCompleteLedgers(); + getCompleteLedgers() const; + + std::size_t + missingFromCompleteLedgerRange(LedgerIndex first, LedgerIndex last) const; /** * Apply held transactions to the open ledger @@ -190,7 +193,7 @@ public: fixMismatch(ReadView const& ledger); bool - haveLedger(std::uint32_t seq); + haveLedger(std::uint32_t seq) const; void clearLedger(std::uint32_t seq); bool @@ -348,7 +351,7 @@ private: // A set of transactions to replay during the next close std::unique_ptr replayData_; - std::recursive_mutex completeLock_; + std::recursive_mutex mutable completeLock_; RangeSet completeLedgers_; // Publish thread is running. diff --git a/src/xrpld/app/ledger/detail/LedgerMaster.cpp b/src/xrpld/app/ledger/detail/LedgerMaster.cpp index 83d76bcd2a..878b257b69 100644 --- a/src/xrpld/app/ledger/detail/LedgerMaster.cpp +++ b/src/xrpld/app/ledger/detail/LedgerMaster.cpp @@ -57,6 +57,7 @@ #include #include +#include #include #include @@ -492,7 +493,7 @@ LedgerMaster::setBuildingLedger(LedgerIndex i) } bool -LedgerMaster::haveLedger(std::uint32_t seq) +LedgerMaster::haveLedger(std::uint32_t seq) const { std::scoped_lock const sl(completeLock_); return boost::icl::contains(completeLedgers_, seq); @@ -1576,12 +1577,36 @@ LedgerMaster::getPublishedLedger() } std::string -LedgerMaster::getCompleteLedgers() +LedgerMaster::getCompleteLedgers() const { std::scoped_lock const sl(completeLock_); return to_string(completeLedgers_); } +std::size_t +LedgerMaster::missingFromCompleteLedgerRange(LedgerIndex first, LedgerIndex last) const +{ + if (first > last) + { + // In expected usage, this will never happen because "first" is generally initialized to + // "last", "last" is guaranteed to grow monotonically, and "first" either doesn't change + // or grows more slowly. + // LCOV_EXCL_START + UNREACHABLE("xrpl::LedgerMaster::missingFromCompleteLedgerRange : invalid parameters"); + return 0; + // LCOV_EXCL_STOP + } + + RangeSet const target{range(first, last)}; + + auto const missing = [&target, this] { + std::scoped_lock const sl(completeLock_); + return target - completeLedgers_; + }(); + + return boost::icl::size(missing); +} + std::optional LedgerMaster::getCloseTimeBySeq(LedgerIndex ledgerIndex) { diff --git a/src/xrpld/app/misc/SHAMapStore.h b/src/xrpld/app/misc/SHAMapStore.h index eeb04df53d..9d50f988b5 100644 --- a/src/xrpld/app/misc/SHAMapStore.h +++ b/src/xrpld/app/misc/SHAMapStore.h @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -34,8 +35,8 @@ public: virtual void start() = 0; - virtual void - rendezvous() const = 0; + [[nodiscard]] virtual bool + rendezvous(std::optional const& timeout = {}) const = 0; virtual void stop() = 0; diff --git a/src/xrpld/app/misc/SHAMapStoreImp.cpp b/src/xrpld/app/misc/SHAMapStoreImp.cpp index 9e3f1ac52b..e19df597a2 100644 --- a/src/xrpld/app/misc/SHAMapStoreImp.cpp +++ b/src/xrpld/app/misc/SHAMapStoreImp.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -30,6 +31,8 @@ #include #include +#include +#include #include #include #include @@ -127,22 +130,6 @@ SHAMapStoreImp::SHAMapStoreImp( if (deleteInterval_ != 0u) { - // Configuration that affects the behavior of online delete - getIfExists(section, Keys::kDeleteBatch, deleteBatch_); - std::uint32_t temp = 0; - if (getIfExists(section, Keys::kBackOffMilliseconds, temp) || - // Included for backward compatibility with an undocumented setting - getIfExists(section, Keys::kBackOff, temp)) - { - backOff_ = std::chrono::milliseconds{temp}; - } - if (getIfExists(section, Keys::kAgeThresholdSeconds, temp)) - ageThreshold_ = std::chrono::seconds{temp}; - if (getIfExists(section, Keys::kRecoveryWaitSeconds, temp)) - recoveryWaitTime_ = std::chrono::seconds{temp}; - - getIfExists(section, Keys::kAdvisoryDelete, advisoryDelete_); - auto const minInterval = config.standalone() ? kMinimumDeletionIntervalSa : kMinimumDeletionInterval; if (deleteInterval_ < minInterval) @@ -159,6 +146,40 @@ SHAMapStoreImp::SHAMapStoreImp( std::to_string(config.ledgerHistory) + ")"); } + // Configuration that affects the behavior of online delete + getIfExists(section, Keys::kDeleteBatch, deleteBatch_); + std::uint32_t temp = 0; + if (getIfExists(section, Keys::kBackOffMilliseconds, temp) || + // Included for backward compatibility with an undocumented setting + getIfExists(section, Keys::kBackOff, temp)) + { + backOff_ = std::chrono::milliseconds{temp}; + } + if (getIfExists(section, Keys::kAgeThresholdSeconds, temp)) + ageThreshold_ = std::chrono::seconds{temp}; + if (getIfExists(section, Keys::kRecoveryWaitSeconds, temp)) + recoveryWaitTime_ = std::chrono::seconds{temp}; + if (recoveryWaitTime_ < std::chrono::seconds{1}) + Throw("recovery_wait_seconds must be at least 1 second"); + + getIfExists(section, Keys::kAdvisoryDelete, advisoryDelete_); + + if (getIfExists(section, Keys::kMaxWaitingLedgers, temp)) + { + maxWaitingLedgers_ = temp; + } + else + { + maxWaitingLedgers_ = deleteInterval_; + } + + auto const minWaiting = minInterval / 4; + if (maxWaitingLedgers_ < minWaiting) + { + Throw( + "max_waiting_ledgers must be at least " + std::to_string(minWaiting)); + } + stateDb_.init(config, dbName_); dbPaths(); } @@ -235,14 +256,22 @@ SHAMapStoreImp::onLedgerClosed(std::shared_ptr const& ledger) cond_.notify_one(); } -void -SHAMapStoreImp::rendezvous() const +[[nodiscard]] +bool +SHAMapStoreImp::rendezvous(std::optional const& timeout) const { if (!working_) - return; + return true; + + auto notWorking = [&] { return !working_; }; std::unique_lock lock(mutex_); - rendezvous_.wait(lock, [&] { return !working_; }); + if (timeout) + { + return rendezvous_.wait_for(lock, *timeout, notWorking); + } + rendezvous_.wait(lock, notWorking); + return true; } int @@ -275,7 +304,7 @@ SHAMapStoreImp::copyNode(std::uint64_t& nodeCount, SHAMapTreeNode const& node) } if ((++nodeCount % checkHealthInterval_) == 0u) { - if (healthWait() == HealthResult::Stopping) + if (healthWait() != HealthResult::KeepGoing) return false; } @@ -326,9 +355,35 @@ SHAMapStoreImp::run() stateDb_.setLastRotated(lastRotated); } + // We're starting a new cycle, so reset back to the default. + lastSuccessfulHealthCheck_ = 0; + bool const readyToRotate = validatedSeq >= lastRotated + deleteInterval_ && canDelete_ >= lastRotated - 1 && healthWait() == HealthResult::KeepGoing; + { + // Note that this is set after the healthWait() check, so that we + // don't start the rotation until the validated ledger is fully + // processed. It is not guaranteed to be done at this point. It also + // allows the testLedgerGaps unit test to work. + std::unique_lock lock(mutex_); + if (newLedger_) + { + // It is possible, though very unlikely outside of tests which manipulate internals, + // that healthWait() took so long that the validated ledger (newLedger_) has moved + // on from where we started. If that's the case, update lastGoodValidatedLedger_ + // to that ledger's sequence number. + lastGoodValidatedLedger_ = newLedger_->header().seq; + } + else + { + lastGoodValidatedLedger_ = validatedSeq; + } + auto const l = lastGoodValidatedLedger_; + lock.unlock(); + JLOG(journal_.trace()) << "run: Set lastGoodValidatedLedger_ to " << l; + } + // will delete up to (not including) lastRotated if (readyToRotate) { @@ -336,11 +391,19 @@ SHAMapStoreImp::run() << lastRotated << " deleteInterval " << deleteInterval_ << " canDelete_ " << canDelete_ << " state " << app_.getOPs().strOperatingMode(false) << " age " - << ledgerMaster_->getValidatedLedgerAge().count() << 's'; + << ledgerMaster_->getValidatedLedgerAge().count() + << "s. Complete ledgers: " << ledgerMaster_->getCompleteLedgers(); clearPrior(lastRotated); - if (healthWait() == HealthResult::Stopping) - return; + switch (healthWait()) + { + case HealthResult::Stopping: + return; + case HealthResult::Expired: + continue; + case HealthResult::KeepGoing: + break; + } JLOG(journal_.debug()) << "copying ledger " << validatedSeq; std::uint64_t nodeCount = 0; @@ -359,8 +422,15 @@ SHAMapStoreImp::run() continue; } - if (healthWait() == HealthResult::Stopping) - return; + switch (healthWait()) + { + case HealthResult::Stopping: + return; + case HealthResult::Expired: + continue; + case HealthResult::KeepGoing: + break; + } // Only log if we completed without a "health" abort JLOG(journal_.debug()) << "copied ledger " << validatedSeq << " nodecount " << nodeCount; @@ -384,8 +454,15 @@ SHAMapStoreImp::run() JLOG(journal_.debug()) << "freshening caches"; freshenCaches(); - if (healthWait() == HealthResult::Stopping) - return; + switch (healthWait()) + { + case HealthResult::Stopping: + return; + case HealthResult::Expired: + continue; + case HealthResult::KeepGoing: + break; + } // Only log if we completed without a "health" abort JLOG(journal_.debug()) << validatedSeq << " freshened caches"; @@ -394,8 +471,15 @@ SHAMapStoreImp::run() JLOG(journal_.debug()) << validatedSeq << " new backend " << newBackend->getName(); clearCaches(validatedSeq); - if (healthWait() == HealthResult::Stopping) - return; + switch (healthWait()) + { + case HealthResult::Stopping: + return; + case HealthResult::Expired: + continue; + case HealthResult::KeepGoing: + break; + } lastRotated = validatedSeq; @@ -411,7 +495,9 @@ SHAMapStoreImp::run() clearCaches(validatedSeq); }); - JLOG(journal_.warn()) << "finished rotation " << validatedSeq; + JLOG(journal_.warn()) << "finished rotation. validatedSeq: " << validatedSeq + << ", lastRotated: " << lastRotated + << ". Complete ledgers: " << ledgerMaster_->getCompleteLedgers(); } } } @@ -559,7 +645,7 @@ SHAMapStoreImp::clearSql( min = *m; } - if (min > lastRotated || healthWait() == HealthResult::Stopping) + if (min > lastRotated || healthWait() != HealthResult::KeepGoing) return; if (min == lastRotated) { @@ -572,18 +658,19 @@ SHAMapStoreImp::clearSql( << lastRotated; while (min < lastRotated) { + // The very first sleep is, arguably wasted, but clearSql is called multiple times for + // different tables, so the time is amortized among all the operations. This results in + // a backoff in between each set of tables, too. + std::this_thread::sleep_for(backOff_); + if (healthWait() != HealthResult::KeepGoing) + return; + min = std::min(lastRotated, min + deleteBatch_); JLOG(journal_.trace()) << "Begin: Delete up to " << deleteBatch_ << " rows with LedgerSeq < " << min << " from: " << tableName; deleteBeforeSeq(min); JLOG(journal_.trace()) << "End: Delete up to " << deleteBatch_ << " rows with LedgerSeq < " << min << " from: " << tableName; - if (healthWait() == HealthResult::Stopping) - return; - if (min < lastRotated) - std::this_thread::sleep_for(backOff_); - if (healthWait() == HealthResult::Stopping) - return; } JLOG(journal_.debug()) << "finished deleting from: " << tableName; } @@ -616,7 +703,7 @@ SHAMapStoreImp::clearPrior(LedgerIndex lastRotated) JLOG(journal_.trace()) << "Begin: Clear internal ledgers up to " << lastRotated; ledgerMaster_->clearPriorLedgers(lastRotated); JLOG(journal_.trace()) << "End: Clear internal ledgers up to " << lastRotated; - if (healthWait() == HealthResult::Stopping) + if (healthWait() != HealthResult::KeepGoing) return; auto& db = app_.getRelationalDatabase(); @@ -626,7 +713,7 @@ SHAMapStoreImp::clearPrior(LedgerIndex lastRotated) "Ledgers", [&db]() -> std::optional { return db.getMinLedgerSeq(); }, [&db](LedgerIndex min) -> void { db.deleteBeforeLedgerSeq(min); }); - if (healthWait() == HealthResult::Stopping) + if (healthWait() != HealthResult::KeepGoing) return; if (!app_.config().useTxTables()) @@ -637,7 +724,7 @@ SHAMapStoreImp::clearPrior(LedgerIndex lastRotated) "Transactions", [&db]() -> std::optional { return db.getTransactionsMinLedgerSeq(); }, [&db](LedgerIndex min) -> void { db.deleteTransactionsBeforeLedgerSeq(min); }); - if (healthWait() == HealthResult::Stopping) + if (healthWait() != HealthResult::KeepGoing) return; clearSql( @@ -645,30 +732,136 @@ SHAMapStoreImp::clearPrior(LedgerIndex lastRotated) "AccountTransactions", [&db]() -> std::optional { return db.getAccountTransactionsMinLedgerSeq(); }, [&db](LedgerIndex min) -> void { db.deleteAccountTransactionsBeforeLedgerSeq(min); }); - if (healthWait() == HealthResult::Stopping) + if (healthWait() != HealthResult::KeepGoing) return; } SHAMapStoreImp::HealthResult SHAMapStoreImp::healthWait() { - auto age = ledgerMaster_->getValidatedLedgerAge(); - OperatingMode mode = netOPs_->getOperatingMode(); - std::unique_lock lock(mutex_); - while (!stop_ && (mode != OperatingMode::FULL || age > ageThreshold_)) - { - lock.unlock(); - JLOG(journal_.warn()) << "Waiting " << recoveryWaitTime_.count() - << "s for node to stabilize. state: " - << app_.getOPs().strOperatingMode(mode, false) << ". age " - << age.count() << 's'; - std::this_thread::sleep_for(recoveryWaitTime_); + // Gets the current status of the server from ledgerMaster_ and netOPs_. Must be called + // while mutex_ is unlocked to avoid unlikely, but possible, deadlock with ledgerMaster_'s + // completeLock_. + // Releasing the lock may mean that status will be slightly out of date when the lock is + // reacquired, but it's close enough. In a normal rotation, healthWait() is called frequently, + // so a false positive will be detected on the next call, and a false negative will be detected + // in the next loop iteration. Database rotation is important, but not timely, so an extra + // delay is fine. + auto readServerStatus = [this]( + LedgerIndex& index, + bool& buildingIndex, + std::chrono::seconds& age, + OperatingMode& mode, + std::size_t& numMissing, + LedgerIndex const lowerBound, + ScopeUnlock const&) { + index = ledgerMaster_->getValidLedgerIndex(); + bool const haveIndex = ledgerMaster_->haveLedger(index); age = ledgerMaster_->getValidatedLedgerAge(); mode = netOPs_->getOperatingMode(); - lock.lock(); + + numMissing = + lowerBound == 0 ? 0 : ledgerMaster_->missingFromCompleteLedgerRange(lowerBound, index); + + buildingIndex = (numMissing == 1 && !haveIndex); + }; + + // Tracked server status properties + LedgerIndex index = 0; + bool buildingIndex = false; + std::chrono::seconds age; + OperatingMode mode = OperatingMode::DISCONNECTED; + std::size_t numMissing = 0; + + std::unique_lock lock(mutex_); + + auto const waitTime = recoveryWaitTime_; + auto const ageThreshold = ageThreshold_; + { + auto const lowerBound = lastGoodValidatedLedger_; + + ScopeUnlock const unlock(lock); + + readServerStatus(index, buildingIndex, age, mode, numMissing, lowerBound, unlock); + } + // If index gets past this point without the health check succeeding, return + // HealthWait::Expired. This depends on index being initialized, so it must be after + // readServerStatus(). + auto const lastSuccess = lastSuccessfulHealthCheck_ == 0 ? index : lastSuccessfulHealthCheck_; + auto const circuitBreaker = lastSuccess + maxWaitingLedgers_; + + auto healthy = [&] { + // Special case: If the server is disconnected, it's not doing any ledger I/O, because + // it's focused on trying to get peers. A disconnected state is should never be caused by + // the activity of the server. It's usually limited to hardware or connectivity issues. Take + // advantage of that to run as much rotation I/O as possible before it comes back online. + if (mode == OperatingMode::DISCONNECTED) + return true; + if (age > ageThreshold) + return false; + if (numMissing > 0) + return false; + if (mode != OperatingMode::FULL) + return false; + return true; + }; + + while (!stop_ && !healthy() && index < circuitBreaker) + { + // Future-proofing: this value shouldn't change while we are sleeping, but grab it while we + // have the lock in case it does. + auto const lowerBound = lastGoodValidatedLedger_; + + ScopeUnlock const unlock(lock); + + auto const [stream, waitMs] = std::invoke( + [mode, age, ageThreshold, buildingIndex, waitTime, index, lastSuccess, this] + -> std::pair { + if (mode != OperatingMode::FULL || age > ageThreshold || + (index - lastSuccess > maxWaitingLedgers_ / 4)) + return {journal_.warn(), waitTime}; + if (buildingIndex) + { + // We expect this ledger to be built soon, so log at a lower level, and don't + // wait as long. + return { + journal_.trace(), + std::chrono::duration_cast(waitTime) / 10}; + } + return {journal_.info(), waitTime}; + }); + JLOG(stream) << "Waiting " << waitMs.count() << "ms for node to stabilize. state: " + << app_.getOPs().strOperatingMode(mode, false) << ". age " << age.count() + << "s. Missing ledgers: " << numMissing << ". Expect: " << lowerBound << "-" + << index << ". Complete ledgers: " << ledgerMaster_->getCompleteLedgers(); + std::this_thread::sleep_for(waitMs); + + [[maybe_unused]] + LedgerIndex const lastLedger = index; + readServerStatus(index, buildingIndex, age, mode, numMissing, lowerBound, unlock); + SOMETIMES( + index > lastLedger, "SHAMapStoreImp::healthWait : validated ledger index changed"); } - return stop_ ? HealthResult::Stopping : HealthResult::KeepGoing; + auto const result = std::invoke([index, circuitBreaker, this]() -> HealthResult { + if (stop_) + return HealthResult::Stopping; + if (index < circuitBreaker) + return HealthResult::KeepGoing; + JLOG(journal_.error()) << "online_delete rotation has been unable to make progress for " + << maxWaitingLedgers_ << " ledgers. " + << "validated ledger index: " << index + << ", last successful health check index: " + << lastSuccessfulHealthCheck_ + << ", circuit breaker index: " << circuitBreaker; + return HealthResult::Expired; + }); + + XRPL_ASSERT(lock.owns_lock(), "SHAMapStoreImp::healthWait : lock held"); + if (result == HealthResult::KeepGoing) + lastSuccessfulHealthCheck_ = index; + + return result; } void diff --git a/src/xrpld/app/misc/SHAMapStoreImp.h b/src/xrpld/app/misc/SHAMapStoreImp.h index 8a1b7504b9..c1e9199665 100644 --- a/src/xrpld/app/misc/SHAMapStoreImp.h +++ b/src/xrpld/app/misc/SHAMapStoreImp.h @@ -88,6 +88,13 @@ private: std::thread thread_; bool stop_ = false; bool healthy_ = true; + // Used to prevent ledger gaps from forming during online deletion. Keeps + // track of the last validated ledger that was processed without gaps. There + // are no guarantees about gaps while online delete is not running. For + // that, use advisory_delete and check for gaps externally. + LedgerIndex lastGoodValidatedLedger_ = 0; + // Used to prevent the circuit breaker from tripping too quickly. + LedgerIndex lastSuccessfulHealthCheck_ = 0; mutable std::condition_variable cond_; mutable std::condition_variable rendezvous_; mutable std::mutex mutex_; @@ -102,12 +109,18 @@ private: std::chrono::milliseconds backOff_{100}; std::chrono::seconds ageThreshold_{60}; /** - * If the node is out of sync during an online_delete healthWait() - * call, sleep the thread for this time, and continue checking until - * recovery. + * If the node is out of sync, or any recent ledgers are not + * available during an online_delete healthWait() call, sleep + * the thread for this time, and continue checking until recovery. * See also: "recovery_wait_seconds" in xrpld-example.cfg */ - std::chrono::seconds recoveryWaitTime_{5}; + std::chrono::seconds recoveryWaitTime_{2}; + /** + * If the rotation stays "unhealthy" for a very long time, the process is aborted, and tried + * again later. This value represents the number of ledgers that must be validated without + * making rotation progress before the process is aborted. + */ + std::uint32_t maxWaitingLedgers_ = deleteBatch_; // these do not exist upon SHAMapStore creation, but do exist // as of run() or before @@ -163,8 +176,9 @@ public: void onLedgerClosed(std::shared_ptr const& ledger) override; - void - rendezvous() const override; + [[nodiscard]] + bool + rendezvous(std::optional const& timeout = {}) const override; int fdRequired() const override; @@ -192,7 +206,7 @@ private: for (auto const& key : cache.getKeys()) { dbRotating_->fetchNodeObject(key, 0, node_store::FetchType::Synchronous, true); - if (!(++check % checkHealthInterval_) && healthWait() == HealthResult::Stopping) + if (!(++check % checkHealthInterval_) && healthWait() != HealthResult::KeepGoing) return true; } @@ -220,11 +234,11 @@ private: /** * This is a health check for online deletion that waits until xrpld is * stable before returning. It returns an indication of whether the server - * is stopping. + * is stopping, or if this attempt should be abandoned. * * @return Whether the server is stopping. */ - enum class HealthResult { Stopping, KeepGoing }; + enum class HealthResult { Stopping, Expired, KeepGoing }; [[nodiscard]] HealthResult healthWait(); From 8bc6e81c5f0d2547a6944e8c91b54f87c3a20159 Mon Sep 17 00:00:00 2001 From: Bart Date: Mon, 24 Aug 2026 16:17:12 +0000 Subject: [PATCH 14/32] fix: Reject an inner node claimed at leaf depth in `verifyProofPath` (#7940) Co-authored-by: Bart <11445373+bthomee@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) --- src/libxrpl/shamap/SHAMapSync.cpp | 41 ++++++-- src/tests/libxrpl/shamap/SHAMap.cpp | 149 ++++++++++++++++++++++++++++ 2 files changed, 183 insertions(+), 7 deletions(-) diff --git a/src/libxrpl/shamap/SHAMapSync.cpp b/src/libxrpl/shamap/SHAMapSync.cpp index a12e524a5f..4319d0bcd4 100644 --- a/src/libxrpl/shamap/SHAMapSync.cpp +++ b/src/libxrpl/shamap/SHAMapSync.cpp @@ -143,6 +143,20 @@ SHAMap::visitDifferences( if (!function(*node)) return; + // Nibbles run out at kLeafDepth, so only a leaf belongs there. A well-formed map never + // holds an inner node at that depth: addKnownNode marks the map invalid rather than hooking + // one in, and fetch-pack data is hash-verified against a validated root, so reaching this + // means a defect or a corrupt store, not something a peer can provoke. Report the node + // anyway - the wire form carries no depth, and the recipient hooks blobs in by hash - but + // skip the children rather than letting getChildNodeID throw on them. + if (nodeID.getDepth() >= kLeafDepth) + { + // LCOV_EXCL_START + UNREACHABLE("xrpl::SHAMap::visitDifferences : inner node at leaf depth"); + continue; + // LCOV_EXCL_STOP + } + // 2) push non-matching child inner nodes for (auto i = 0u; i < kBranchFactor; ++i) { @@ -749,11 +763,9 @@ SHAMap::hasLeafNode(uint256 const& tag, SHAMapHash const& targetNodeHash) const do { - // An inner node is only reachable here at a depth below kLeafDepth in a well-formed map, - // where the loop always finds a leaf first. A malformed map could still have an inner - // node claiming kLeafDepth, and getChildNodeID below throws in that case: reject rather - // than let the throw escape uncaught. Not reachable through any public entry point, - // since addKnownNode already marks such a map invalid, so no test can cover this. + // Same kLeafDepth hazard as in visitDifferences above. That guard bounds the caller's own + // traversal, not the map queried here, and the loop below descends from this map's root + // independently, so this check is what keeps a malformed map from reaching getChildNodeID. if (nodeID.getDepth() >= kLeafDepth) { // LCOV_EXCL_START @@ -830,15 +842,30 @@ SHAMap::verifyProofPath(uint256 const& rootHash, uint256 const& key, std::vector if (node->getHash() != hash) return false; - auto const depth = std::distance(path.rbegin(), rit); + auto const depth = static_cast(std::distance(path.rbegin(), rit)); if (node->isInner()) { - auto nodeId = SHAMapNodeID::createID(static_cast(depth), key); + // Nibbles run out at kLeafDepth, so only the leaf terminating the path may sit + // there. These nodes come off the wire, so a peer can still claim an inner one; + // reject it rather than passing this depth to selectBranch. + SOMETIMES( + depth >= kLeafDepth, "xrpl::SHAMap::verifyProofPath : inner at leaf depth"); + if (depth >= kLeafDepth) + return false; + + auto nodeId = SHAMapNodeID::createID(depth, key); hash = safeDowncast(node.get()) ->getChildHash(selectBranch(nodeId, key)); } else { + // The hash chain up to rootHash only proves this leaf sits where the path claims, + // not that it is the leaf for `key`: a peer could substitute any other leaf whose + // subtree hashes to the same value at every level above it. Checking the terminal + // leaf's own key is what ties the proof to `key` specifically. + if (leafKey(*node) != key) + return false; + // should exhaust all the blobs now return depth + 1 == path.size(); } diff --git a/src/tests/libxrpl/shamap/SHAMap.cpp b/src/tests/libxrpl/shamap/SHAMap.cpp index c84cdf504f..7f7d6ffba2 100644 --- a/src/tests/libxrpl/shamap/SHAMap.cpp +++ b/src/tests/libxrpl/shamap/SHAMap.cpp @@ -3,19 +3,23 @@ #include #include #include +#include #include #include #include +#include #include #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -346,4 +350,149 @@ TEST_F(SHAMapPathProof, verify_proof_path) EXPECT_FALSE(map.verifyProofPath(rootHash, key, badPath)); } +// A legitimate proof path for two keys sharing all 63 leading nibbles is 65 elements: inner nodes +// at depths 0..63 plus the leaf at depth 64. This pins that the 65 bound is real, so the fix for +// the forged-path case below must not simply tighten the length limit. +TEST_F(SHAMapPathProof, legitimate_deep_path_is_sixty_five_elements) +{ + tests::TestNodeFamily f{j_}; + SHAMap map{SHAMapType::FREE, f}; + map.setUnbacked(); + + auto const kA = uint256{std::string_view{std::string(63, 'a') + "1"}}; + auto const kB = uint256{std::string_view{std::string(63, 'a') + "2"}}; + + for (auto const& k : {kA, kB}) + { + Buffer vuc{32}; + std::fill_n(vuc.data(), vuc.size(), std::uint8_t{1}); + ASSERT_TRUE(map.addItem(SHAMapNodeType::TnAccountState, makeShamapitem(k, std::move(vuc)))); + } + map.invariants(); + + auto const pathA = map.getProofPath(kA); + ASSERT_TRUE(pathA.has_value()); + // NOLINTBEGIN(bugprone-unchecked-optional-access) has_value() checked above + EXPECT_EQ(pathA->size(), 65u); + EXPECT_TRUE(SHAMap::verifyProofPath(map.getHash().asUInt256(), kA, *pathA)); + // NOLINTEND(bugprone-unchecked-optional-access) + + auto const pathB = map.getProofPath(kB); + ASSERT_TRUE(pathB.has_value()); + // NOLINTBEGIN(bugprone-unchecked-optional-access) has_value() checked above + EXPECT_EQ(pathB->size(), 65u); + EXPECT_TRUE(SHAMap::verifyProofPath(map.getHash().asUInt256(), kB, *pathB)); + // NOLINTEND(bugprone-unchecked-optional-access) +} + +// A forged path of 65 hash-chained inner nodes reaches depth kLeafDepth, where only the leaf +// terminating the path may sit. Such a path must be rejected. +TEST_F(SHAMapPathProof, all_inner_path_at_leaf_depth_is_rejected) +{ + // An arbitrary well-formed key; the test does not care about its specific value. + constexpr uint256 kTestKey("b92891fe4ef6cee585fdc6fda1e09eb4d386363158ec3321b8123e5a772c6ca8"); + + // Build upwards from the deepest node so each parent's selected branch carries its child's hash + // and the hash chain validates at every level. + std::vector path; + SHAMapHash childHash{uint256{1}}; + + for (auto depth = SHAMap::kLeafDepth + 1u; depth-- > 0;) + { + auto const id = SHAMapNodeID::createID(std::min(depth, SHAMap::kLeafDepth - 1u), kTestKey); + auto const branch = selectBranch(id, kTestKey); + + Serializer s; + for (auto i = 0u; i < SHAMap::kBranchFactor; ++i) + s.addBitString(i == branch ? childHash.asUInt256() : uint256{}); + s.add8(kWireTypeInner); + path.push_back(s.getData()); + + auto node = SHAMapTreeNode::makeFromWire(makeSlice(path.back())); + ASSERT_TRUE(node); + node->updateHash(); + childHash = node->getHash(); + } + + ASSERT_EQ(path.size(), 65u); + EXPECT_FALSE(SHAMap::verifyProofPath(childHash.asUInt256(), kTestKey, path)); +} + +/** + * Wrap a leaf blob in a forged root inner node whose branch for `key` carries that leaf's hash. + * + * The resulting two-element path hash-chains for `key` no matter which leaf sits at the bottom, + * which is exactly the substitution a peer could attempt. + * + * @param leafBlob the wire form of the leaf to place at the bottom of the path. + * @param key the key the forged path claims to prove. + * @return the path (deepest element first) and the forged root hash, or an empty path if the leaf + * blob does not parse. + */ +static std::pair, uint256> +forgeRootOverLeaf(Blob const& leafBlob, uint256 const& key) +{ + auto leaf = SHAMapTreeNode::makeFromWire(makeSlice(leafBlob)); + if (!leaf || !leaf->isLeaf()) + return {}; + leaf->updateHash(); + + auto const branch = selectBranch(SHAMapNodeID::createID(0, key), key); + Serializer s; + for (auto i = 0u; i < SHAMap::kBranchFactor; ++i) + s.addBitString(i == branch ? leaf->getHash().asUInt256() : uint256{}); + s.add8(kWireTypeInner); + + auto root = SHAMapTreeNode::makeFromWire(makeSlice(s.peekData())); + if (!root) + return {}; + root->updateHash(); + + return {std::vector{leafBlob, s.getData()}, root->getHash().asUInt256()}; +} + +// The hash chain above a leaf proves nothing about which key that leaf holds, so a peer can graft a +// genuine leaf from elsewhere in the map onto a path forged for another key. Comparing the terminal +// leaf's own key against the key being proved is what rejects it. +TEST_F(SHAMapPathProof, substituted_leaf_for_other_key_is_rejected) +{ + tests::TestNodeFamily f{j_}; + SHAMap map{SHAMapType::FREE, f}; + map.setUnbacked(); + + // Two arbitrary keys differing in their first nibble, so each leaf hangs off the root directly. + constexpr uint256 kKey("1c8cec8e5e9b0e5e0e0f5b3e2c9f7a1d6b4e8c2a0d7f3b9e5c1a8d4f2b6e0c93"); + constexpr uint256 kOtherKey("e3f1a7d5b9c2e8f406a1d3b5c7e9f2a4d6b8c0e2f4a6d8b0c2e4f6a8d0b2c4e6"); + + for (auto const& k : {kKey, kOtherKey}) + { + ASSERT_TRUE(map.addItem( + SHAMapNodeType::TnAccountState, makeShamapitem(k, Slice{k.data(), k.size()}))); + } + map.invariants(); + + auto const ownPath = map.getProofPath(kKey); + auto const otherPath = map.getProofPath(kOtherKey); + ASSERT_TRUE(ownPath.has_value()); + ASSERT_TRUE(otherPath.has_value()); + + // NOLINTBEGIN(bugprone-unchecked-optional-access) has_value() checked above + // The genuine leaf blobs, deepest element first. + auto const& ownLeaf = ownPath->front(); + auto const& otherLeaf = otherPath->front(); + // NOLINTEND(bugprone-unchecked-optional-access) + + // Control: the forged root is accepted when the leaf below it really is kKey's leaf, so the + // rejection below can only come from the leaf key comparison. + auto const [goodPath, goodRoot] = forgeRootOverLeaf(ownLeaf, kKey); + ASSERT_EQ(goodPath.size(), 2u); + EXPECT_TRUE(SHAMap::verifyProofPath(goodRoot, kKey, goodPath)); + + // Same forged root, but kOtherKey's leaf substituted at the bottom: the hash chain still + // validates, yet the path does not prove anything about kKey. + auto const [badPath, badRoot] = forgeRootOverLeaf(otherLeaf, kKey); + ASSERT_EQ(badPath.size(), 2u); + EXPECT_FALSE(SHAMap::verifyProofPath(badRoot, kKey, badPath)); +} + } // namespace xrpl::tests From f137d7151059b223b0f2c4593b3f58d5fc44fcb0 Mon Sep 17 00:00:00 2001 From: Jingchen Date: Mon, 24 Aug 2026 16:17:33 +0000 Subject: [PATCH 15/32] test: Split Invariants_test.cpp into per-topic files (#8077) --- include/xrpl/protocol/STLedgerEntry.h | 6 +- src/test/app/Invariants_test.cpp | 7096 ----------------- src/test/app/NFTokenBurn_test.cpp | 49 +- .../app/invariants/InvariantsAMM_test.cpp | 249 + src/test/app/invariants/InvariantsBase.cpp | 200 + src/test/app/invariants/InvariantsBase.h | 122 + .../invariants/InvariantsEscrowNFT_test.cpp | 352 + .../app/invariants/InvariantsMPT_test.cpp | 1577 ++++ .../app/invariants/InvariantsMisc_test.cpp | 1333 ++++ .../InvariantsPermissioned_test.cpp | 957 +++ .../InvariantsPseudoAccount_test.cpp | 461 ++ .../invariants/InvariantsTrustLine_test.cpp | 237 + .../app/invariants/InvariantsVault_test.cpp | 2091 +++++ 13 files changed, 7604 insertions(+), 7126 deletions(-) delete mode 100644 src/test/app/Invariants_test.cpp create mode 100644 src/test/app/invariants/InvariantsAMM_test.cpp create mode 100644 src/test/app/invariants/InvariantsBase.cpp create mode 100644 src/test/app/invariants/InvariantsBase.h create mode 100644 src/test/app/invariants/InvariantsEscrowNFT_test.cpp create mode 100644 src/test/app/invariants/InvariantsMPT_test.cpp create mode 100644 src/test/app/invariants/InvariantsMisc_test.cpp create mode 100644 src/test/app/invariants/InvariantsPermissioned_test.cpp create mode 100644 src/test/app/invariants/InvariantsPseudoAccount_test.cpp create mode 100644 src/test/app/invariants/InvariantsTrustLine_test.cpp create mode 100644 src/test/app/invariants/InvariantsVault_test.cpp diff --git a/include/xrpl/protocol/STLedgerEntry.h b/include/xrpl/protocol/STLedgerEntry.h index 8731488adb..7bc369ea37 100644 --- a/include/xrpl/protocol/STLedgerEntry.h +++ b/include/xrpl/protocol/STLedgerEntry.h @@ -19,7 +19,7 @@ namespace xrpl { class Rules; namespace test { -class Invariants_test; +class InvariantsMisc_test; } // namespace test class STLedgerEntry final : public STObject, public CountedObject @@ -83,8 +83,8 @@ private: void setSLEType(); - friend test::Invariants_test; // this test wants access to the private - // type_ + friend test::InvariantsMisc_test; // this test wants access to the + // private type_ STBase* copy(std::size_t n, void* buf) const override; diff --git a/src/test/app/Invariants_test.cpp b/src/test/app/Invariants_test.cpp deleted file mode 100644 index dcd22ffda6..0000000000 --- a/src/test/app/Invariants_test.cpp +++ /dev/null @@ -1,7096 +0,0 @@ -#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 -#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-only factory — not part of the public API. -// The returned Transactor holds a raw reference to ctx; the caller must ensure -// the ApplyContext outlives the Transactor. Implemented in applySteps.cpp -std::unique_ptr -makeTransactor(ApplyContext& ctx); - -} // namespace xrpl - -namespace xrpl::test { - -class Invariants_test : public beast::unit_test::Suite -{ - // The optional Preclose function is used to process additional transactions - // on the ledger after creating two accounts, but before closing it, and - // before the Precheck function. These should only be valid functions, and - // not direct manipulations. Preclose is not commonly used. - using Preclose = std::function< - bool(test::jtx::Account const& a, test::jtx::Account const& b, test::jtx::Env& env)>; - - // this is common setup/method for running a failing invariant check. The - // precheck function is used to manipulate the ApplyContext with view - // changes that will cause the check to fail. - using Precheck = std::function< - bool(test::jtx::Account const& a, test::jtx::Account const& b, ApplyContext& ac)>; - - static FeatureBitset - defaultAmendments() - { - return xrpl::test::jtx::testableAmendments() | fixCleanup3_1_3 | fixCleanup3_2_0; - } - - test::jtx::Env - makeEnv(FeatureBitset features) - { - return {*this, test::jtx::envconfig(), features, nullptr, beast::Severity::Disabled}; - } - - /** - * Run a specific test case to put the ledger into a state that will be - * detected by an invariant. Simulates the actions of a transaction that - * would violate an invariant. - * - * @param expect_logs One or more messages related to the failing invariant - * that should be in the log output - * @precheck See "Precheck" above - * @fee If provided, the fee amount paid by the simulated transaction. - * @tx A mock transaction that took the actions to trigger the invariant. In - * most cases, only the type matters. - * @ters The TER results expected on the two passes of the invariant - * checker. - * @preclose See "Preclose" above. Note that @preclose runs *before* - * @precheck, but is the last parameter for historical reasons - * @setTxAccount optionally set to add sfAccount to tx (either A1 or A2) - */ - enum class TxAccount : int { None = 0, A1, A2 }; - void - doInvariantCheck( - std::vector const& expectLogs, - Precheck const& precheck, - XRPAmount fee = XRPAmount{}, - STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}}, - std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - Preclose const& preclose = {}, - TxAccount setTxAccount = TxAccount::None, - std::source_location const& loc = std::source_location::current(), - // Result fed to the invariant checker on the first pass. Set it to a - // tec to exercise result-dependent invariants; the harness runs no - // transactor, so one never arises on its own. - TER initialResult = tesSUCCESS) - { - doInvariantCheck( - makeEnv(defaultAmendments()), - expectLogs, - precheck, - fee, - tx, - ters, - preclose, - setTxAccount, - loc, - initialResult); - } - - void - doInvariantCheck( - test::jtx::Env&& env, - std::vector const& expectLogs, - Precheck const& precheck, - XRPAmount fee = XRPAmount{}, - STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}}, - std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - Preclose const& preclose = {}, - TxAccount setTxAccount = TxAccount::None, - std::source_location const& loc = std::source_location::current(), - TER initialResult = tesSUCCESS) - { - using namespace test::jtx; - - Account const a1{"A1"}; - Account const a2{"A2"}; - env.fund(XRP(1000), a1, a2); - if (preclose) - BEAST_EXPECT(preclose(a1, a2, env)); - env.close(); - - if (setTxAccount != TxAccount::None) - tx.setAccountID(sfAccount, setTxAccount == TxAccount::A1 ? a1.id() : a2.id()); - - doInvariantCheck( - std::move(env), a1, a2, expectLogs, precheck, fee, tx, ters, loc, initialResult); - } - - void - doInvariantCheck( - // NOLINTNEXTLINE(cppcoreguidelines-rvalue-reference-param-not-moved) - test::jtx::Env&& env, - test::jtx::Account const& a1, - test::jtx::Account const& a2, - std::vector const& expectLogs, - Precheck const& precheck, - XRPAmount fee = XRPAmount{}, - STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}}, - std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - std::source_location const& loc = std::source_location::current(), - TER initialResult = tesSUCCESS) - { - using namespace test::jtx; - - OpenView ov{*env.current()}; - test::StreamSink sink{beast::Severity::Warning}; - beast::Journal const jlog{sink}; - ApplyContext ac{env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog}; - - // Invariants normally run in the Transaction's "apply" (operator()) context, and can always - // access global Rules. - CurrentTransactionRulesGuard const rulesGuard(ov.rules()); - - BEAST_EXPECT(precheck(a1, a2, ac)); - - auto transactor = makeTransactor(ac); - if (!BEAST_EXPECT(transactor)) - return; - - // Invoke the check twice to cover the tec and tef cases. Both passes run - // against the same view -- production would discard it in between -- so - // the second sees the same violation and escalates tec -> tef. A - // {tec, tef} pair therefore means "enforced whatever the incoming - // result", not that the transaction ends in tef on ledger. - if (!BEAST_EXPECT(ters.size() == 2)) - return; - - TER terActual = initialResult; - for (TER const& terExpect : ters) - { - TER const terInput = terActual; - terActual = - transactor->checkInvariants(terActual, fee, Transactor::InvariantScope::Full); - expect( - terExpect == terActual, - "expected: " + transToken(terExpect) + " got: " + transToken(terActual), - loc.file_name(), - loc.line()); - auto const messages = sink.messages().str(); - - // checkInvariants returns its input unchanged unless something - // fires, so a changed result means an invariant fired, and a firing - // invariant must log. - if (terActual != terInput) - { - expect( - messages.starts_with("Invariant failed:") || - messages.starts_with("Transaction caused an exception"), - messages, - loc.file_name(), - loc.line()); - } - - // std::cerr << messages << '\n'; - for (auto const& m : expectLogs) - { - expect(messages.contains(m), m, loc.file_name(), loc.line()); - } - } - } - - void - testXRPNotCreated() - { - using namespace test::jtx; - testcase << "XRP created"; - doInvariantCheck( - {{"XRP net change was positive: 500"}}, - [](Account const& a1, Account const&, ApplyContext& ac) { - // put a single account in the view and "manufacture" some XRP - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - auto amt = sle->getFieldAmount(sfBalance); - sle->setFieldAmount(sfBalance, amt + STAmount{500}); - ac.view().update(sle); - return true; - }); - } - - void - testAccountRootsNotRemoved() - { - using namespace test::jtx; - testcase << "account root removed"; - - // An account was deleted, but not by an AccountDelete transaction. - doInvariantCheck( - {{"an account root was deleted"}}, - [](Account const& a1, Account const&, ApplyContext& ac) { - // remove an account from the view - auto sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - // Clear the balance so the "account deletion left behind a - // non-zero balance" check doesn't trip earlier than the desired - // check. - sle->at(sfBalance) = beast::kZero; - ac.view().erase(sle); - return true; - }); - - // Successful AccountDelete transaction that didn't delete an account. - // - // Note that this is a case where a second invocation of the invariant - // checker returns a tecINVARIANT_FAILED, not a tefINVARIANT_FAILED. - // After a discussion with the team, we believe that's okay. - doInvariantCheck( - {{"account deletion succeeded without deleting an account"}}, - [](Account const&, Account const&, ApplyContext& ac) { return true; }, - XRPAmount{}, - STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); - - // Successful AccountDelete that deleted more than one account. - doInvariantCheck( - {{"account deletion succeeded but deleted multiple accounts"}}, - [](Account const& a1, Account const& a2, ApplyContext& ac) { - // remove two accounts from the view - auto sleA1 = ac.view().peek(keylet::account(a1.id())); - auto sleA2 = ac.view().peek(keylet::account(a2.id())); - if (!sleA1 || !sleA2) - return false; - // Clear the balance so the "account deletion left behind a - // non-zero balance" check doesn't trip earlier than the desired - // check. - sleA1->at(sfBalance) = beast::kZero; - sleA2->at(sfBalance) = beast::kZero; - ac.view().erase(sleA1); - ac.view().erase(sleA2); - return true; - }, - XRPAmount{}, - STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}); - } - - void - testAccountRootsDeletedClean() - { - using namespace test::jtx; - testcase << "account root deletion left artifact"; - - doInvariantCheck( - {{"account deletion left behind a non-zero balance"}}, - // NOLINTNEXTLINE(readability-identifier-naming) - [&](Account const& A1, Account const& A2, ApplyContext& ac) { - // A1 has a balance. Delete A1 - auto const a1 = A1.id(); - auto const sleA1 = ac.view().peek(keylet::account(a1)); - if (!sleA1) - return false; - if (!BEAST_EXPECT(*sleA1->at(sfBalance) != beast::kZero)) - return false; - - ac.view().erase(sleA1); - - return true; - }, - XRPAmount{}, - STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}); - - doInvariantCheck( - {{"account deletion left behind a non-zero owner count"}}, - // NOLINTNEXTLINE(readability-identifier-naming) - [&](Account const& A1, Account const& A2, ApplyContext& ac) { - // Increment A1's owner count, then delete A1 - auto const a1 = A1.id(); - auto const sleA1 = ac.view().peek(keylet::account(a1)); - if (!sleA1) - return false; - // Clear the balance so the "account deletion left behind a - // non-zero balance" check doesn't trip earlier than the desired - // check. - sleA1->at(sfBalance) = beast::kZero; - BEAST_EXPECT(sleA1->at(sfOwnerCount) == 0); - increaseOwnerCount(ac.view(), sleA1, {}, 1, ac.journal); - - ac.view().erase(sleA1); - - return true; - }, - XRPAmount{}, - STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}); - - doInvariantCheck( - {{"account deletion left behind a sponsorship field"}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const sleA1 = ac.view().peek(keylet::account(a1.id())); - if (!sleA1) - return false; - sleA1->at(sfBalance) = beast::kZero; - sleA1->setFieldU32(sfSponsoredOwnerCount, 1); - - ac.view().erase(sleA1); - - return true; - }, - XRPAmount{}, - STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}); - - doInvariantCheck( - {{"account deletion left behind a sponsorship field"}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const sleA1 = ac.view().peek(keylet::account(a1.id())); - if (!sleA1) - return false; - sleA1->at(sfBalance) = beast::kZero; - sleA1->setFieldU32(sfSponsoringOwnerCount, 1); - - ac.view().erase(sleA1); - - return true; - }, - XRPAmount{}, - STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}); - - doInvariantCheck( - {{"account deletion left behind a sponsorship field"}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const a1Id = a1.id(); - auto const sleA1 = ac.view().peek(keylet::account(a1Id)); - if (!sleA1) - return false; - sleA1->at(sfBalance) = beast::kZero; - sleA1->setFieldU32(sfSponsoringAccountCount, 1); - - ac.view().erase(sleA1); - - return true; - }, - XRPAmount{}, - STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}); - - doInvariantCheck( - {{"account deletion left behind a sponsorship field"}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const sleA1 = ac.view().peek(keylet::account(a1.id())); - if (!sleA1) - return false; - sleA1->at(sfBalance) = beast::kZero; - sleA1->setAccountID(sfSponsor, a2.id()); - - ac.view().erase(sleA1); - - return true; - }, - XRPAmount{}, - STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}); - - doInvariantCheck( - Env{*this, FeatureBitset{featureSponsor}}, - {{"account deletion left behind a sponsorship field"}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const sleA1 = ac.view().peek(keylet::account(a1.id())); - if (!sleA1) - return false; - sleA1->at(sfBalance) = beast::kZero; - sleA1->setAccountID(sfSponsor, a2.id()); - - ac.view().erase(sleA1); - - return true; - }, - XRPAmount{}, - STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}); - - for (auto const& [keyletfunc, type, includeInTests] : kDirectAccountKeylets) - { - if (!includeInTests) - continue; - - using namespace std::string_literals; - - doInvariantCheck( - {{"account deletion left behind a "s + type.cStr() + " object"}}, - // NOLINTNEXTLINE(readability-identifier-naming) - [&](Account const& A1, Account const& A2, ApplyContext& ac) { - // Add an object to the ledger for account A1, then delete - // A1 - auto const a1 = A1.id(); - auto sleA1 = ac.view().peek(keylet::account(a1)); - if (!sleA1) - return false; - - auto const key = std::invoke(keyletfunc, a1); - auto const newSLE = std::make_shared(key); - ac.view().insert(newSLE); - // Clear the balance so the "account deletion left behind a - // non-zero balance" check doesn't trip earlier than the - // desired check. - sleA1->at(sfBalance) = beast::kZero; - ac.view().erase(sleA1); - - return true; - }, - XRPAmount{}, - STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}); - } - - // NFT special case - doInvariantCheck( - {{"account deletion left behind a NFTokenPage object"}}, - [&](Account const& a1, Account const&, ApplyContext& ac) { - // remove an account from the view - auto sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - // Clear the balance so the "account deletion left behind a - // non-zero balance" check doesn't trip earlier than the desired - // check. - sle->at(sfBalance) = beast::kZero; - sle->at(sfOwnerCount) = 0; - ac.view().erase(sle); - return true; - }, - XRPAmount{}, - STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - [&](Account const& a1, Account const&, Env& env) { - // Preclose callback to mint the NFT which will be deleted in - // the Precheck callback above. - env(token::mint(a1)); - - return true; - }); - - // AMM special cases - AccountID ammAcctID; - uint256 ammKey; - Issue ammIssue; - doInvariantCheck( - {{"account deletion left behind a DirectoryNode object"}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - // Delete the AMM account without cleaning up the directory or - // deleting the AMM object - auto sle = ac.view().peek(keylet::account(ammAcctID)); - if (!sle) - return false; - - BEAST_EXPECT(sle->at(~sfAMMID)); - BEAST_EXPECT(sle->at(~sfAMMID) == ammKey); - - // Clear the balance so the "account deletion left behind a - // non-zero balance" check doesn't trip earlier than the desired - // check. - sle->at(sfBalance) = beast::kZero; - sle->at(sfOwnerCount) = 0; - ac.view().erase(sle); - - return true; - }, - XRPAmount{}, - STTx{ttAMM_WITHDRAW, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - // Preclose callback to create the AMM which will be partially - // deleted in the Precheck callback above. - AMM const amm(env, a1, XRP(100), a1["USD"](50)); - ammAcctID = amm.ammAccount(); - ammKey = amm.ammID(); - ammIssue = amm.lptIssue(); - return true; - }); - doInvariantCheck( - {{"account deletion left behind a AMM object"}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - // Delete all the AMM's trust lines, remove the AMM from the AMM - // account's directory (this deletes the directory), and delete - // the AMM account. Do not delete the AMM object. - auto sle = ac.view().peek(keylet::account(ammAcctID)); - if (!sle) - return false; - - BEAST_EXPECT(sle->at(~sfAMMID)); - BEAST_EXPECT(sle->at(~sfAMMID) == ammKey); - - for (auto const& trustKeylet : - {keylet::trustLine(ammAcctID, a1["USD"]), keylet::trustLine(a1, ammIssue)}) - { - auto const line = ac.view().peek(trustKeylet); - if (!line) - { - return false; - } - - STAmount const lowLimit = line->at(sfLowLimit); - STAmount const highLimit = line->at(sfHighLimit); - BEAST_EXPECT( - trustDelete( - ac.view(), - line, - lowLimit.getIssuer(), - highLimit.getIssuer(), - ac.journal) == tesSUCCESS); - } - - auto const ammSle = ac.view().peek(keylet::amm(ammKey)); - if (!BEAST_EXPECT(ammSle)) - return false; - auto const ownerDirKeylet = keylet::ownerDir(ammAcctID); - - BEAST_EXPECT( - ac.view().dirRemove(ownerDirKeylet, ammSle->at(sfOwnerNode), ammKey, false)); - BEAST_EXPECT( - !ac.view().exists(ownerDirKeylet) || ac.view().emptyDirDelete(ownerDirKeylet)); - - // Clear the balance so the "account deletion left behind a - // non-zero balance" check doesn't trip earlier than the desired - // check. - sle->at(sfBalance) = beast::kZero; - sle->at(sfOwnerCount) = 0; - ac.view().erase(sle); - - return true; - }, - XRPAmount{}, - STTx{ttAMM_WITHDRAW, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - // Preclose callback to create the AMM which will be partially - // deleted in the Precheck callback above. - AMM const amm(env, a1, XRP(100), a1["USD"](50)); - ammAcctID = amm.ammAccount(); - ammKey = amm.ammID(); - ammIssue = amm.lptIssue(); - return true; - }); - } - - void - testTypesMatch() - { - using namespace test::jtx; - testcase << "ledger entry types don't match"; - doInvariantCheck( - {{"ledger entry type mismatch"}, {"XRP net change of -1000000000 doesn't match fee 0"}}, - [](Account const& a1, Account const&, ApplyContext& ac) { - // replace an entry in the table with an SLE of a different type - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - auto const sleNew = std::make_shared(ltTICKET, sle->key()); - ac.rawView().rawReplace(sleNew); - return true; - }); - - doInvariantCheck( - {{"invalid ledger entry type added"}}, - [](Account const& a1, Account const&, ApplyContext& ac) { - // add an entry in the table with an SLE of an invalid type - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - - // make a dummy escrow ledger entry, then change the type to an - // unsupported value so that the valid type invariant check - // will fail. - auto const sleNew = std::make_shared( - keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2))); - - // We don't use ltNICKNAME directly since it's marked deprecated - // to prevent accidental use elsewhere. - sleNew->type_ = static_cast('n'); - ac.view().insert(sleNew); - return true; - }); - } - - void - testNoXRPTrustLine() - { - using namespace test::jtx; - testcase << "trust lines with XRP not allowed"; - doInvariantCheck( - {{"an XRP trust line was created"}}, - [](Account const& a1, Account const& a2, ApplyContext& ac) { - // create simple trust SLE with xrp currency - auto const sleNew = - std::make_shared(keylet::trustLine(a1, a2, xrpIssue().currency)); - ac.view().insert(sleNew); - return true; - }); - } - - void - testNoDeepFreezeTrustLinesWithoutFreeze() - { - using namespace test::jtx; - testcase << "trust lines with deep freeze flag without freeze " - "not allowed"; - doInvariantCheck( - {{"a trust line with deep freeze flag without normal freeze was " - "created"}}, - [](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const sleNew = - std::make_shared(keylet::trustLine(a1, a2, a1["USD"].currency)); - sleNew->setFieldAmount(sfLowLimit, a1["USD"](0)); - sleNew->setFieldAmount(sfHighLimit, a1["USD"](0)); - - std::uint32_t uFlags = 0u; - uFlags |= lsfLowDeepFreeze; - sleNew->setFieldU32(sfFlags, uFlags); - ac.view().insert(sleNew); - return true; - }); - - doInvariantCheck( - {{"a trust line with deep freeze flag without normal freeze was " - "created"}}, - [](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const sleNew = - std::make_shared(keylet::trustLine(a1, a2, a1["USD"].currency)); - sleNew->setFieldAmount(sfLowLimit, a1["USD"](0)); - sleNew->setFieldAmount(sfHighLimit, a1["USD"](0)); - std::uint32_t uFlags = 0u; - uFlags |= lsfHighDeepFreeze; - sleNew->setFieldU32(sfFlags, uFlags); - ac.view().insert(sleNew); - return true; - }); - - doInvariantCheck( - {{"a trust line with deep freeze flag without normal freeze was " - "created"}}, - [](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const sleNew = - std::make_shared(keylet::trustLine(a1, a2, a1["USD"].currency)); - sleNew->setFieldAmount(sfLowLimit, a1["USD"](0)); - sleNew->setFieldAmount(sfHighLimit, a1["USD"](0)); - std::uint32_t uFlags = 0u; - uFlags |= lsfLowDeepFreeze | lsfHighDeepFreeze; - sleNew->setFieldU32(sfFlags, uFlags); - ac.view().insert(sleNew); - return true; - }); - - doInvariantCheck( - {{"a trust line with deep freeze flag without normal freeze was " - "created"}}, - [](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const sleNew = - std::make_shared(keylet::trustLine(a1, a2, a1["USD"].currency)); - sleNew->setFieldAmount(sfLowLimit, a1["USD"](0)); - sleNew->setFieldAmount(sfHighLimit, a1["USD"](0)); - std::uint32_t uFlags = 0u; - uFlags |= lsfLowDeepFreeze | lsfHighFreeze; - sleNew->setFieldU32(sfFlags, uFlags); - ac.view().insert(sleNew); - return true; - }); - - doInvariantCheck( - {{"a trust line with deep freeze flag without normal freeze was " - "created"}}, - [](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const sleNew = - std::make_shared(keylet::trustLine(a1, a2, a1["USD"].currency)); - sleNew->setFieldAmount(sfLowLimit, a1["USD"](0)); - sleNew->setFieldAmount(sfHighLimit, a1["USD"](0)); - std::uint32_t uFlags = 0u; - uFlags |= lsfLowFreeze | lsfHighDeepFreeze; - sleNew->setFieldU32(sfFlags, uFlags); - ac.view().insert(sleNew); - return true; - }); - } - - void - testTransfersNotFrozen() - { - using namespace test::jtx; - testcase << "transfers when frozen"; - - Account const g1{"G1"}; - // Helper function to establish the trustlines - auto const createTrustlines = [&](Account const& a1, Account const& a2, Env& env) { - // Preclose callback to establish trust lines with gateway - env.fund(XRP(1000), g1); - - env.trust(g1["USD"](10000), a1); - env.trust(g1["USD"](10000), a2); - env.close(); - - env(pay(g1, a1, g1["USD"](1000))); - env(pay(g1, a2, g1["USD"](1000))); - env.close(); - - return true; - }; - - auto const a1FrozenByIssuer = [&](Account const& a1, Account const& a2, Env& env) { - createTrustlines(a1, a2, env); - env(trust(g1, a1["USD"](10000), tfSetFreeze)); - env.close(); - - return true; - }; - - auto const a1DeepFrozenByIssuer = [&](Account const& a1, Account const& a2, Env& env) { - a1FrozenByIssuer(a1, a2, env); - env(trust(g1, a1["USD"](10000), tfSetDeepFreeze)); - env.close(); - - return true; - }; - - auto const changeBalances = [&](Account const& a1, - Account const& a2, - ApplyContext& ac, - int a1Balance, - int a2Balance) { - auto const sleA1 = ac.view().peek(keylet::trustLine(a1, g1["USD"])); - auto const sleA2 = ac.view().peek(keylet::trustLine(a2, g1["USD"])); - - sleA1->setFieldAmount(sfBalance, g1["USD"](a1Balance)); - sleA2->setFieldAmount(sfBalance, g1["USD"](a2Balance)); - - ac.view().update(sleA1); - ac.view().update(sleA2); - }; - - // test: imitating frozen A1 making a payment to A2. - doInvariantCheck( - {{"Attempting to move frozen funds"}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - changeBalances(a1, a2, ac, -900, -1100); - return true; - }, - XRPAmount{}, - STTx{ttPAYMENT, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - a1FrozenByIssuer); - - // test: imitating deep frozen A1 making a payment to A2. - doInvariantCheck( - {{"Attempting to move frozen funds"}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - changeBalances(a1, a2, ac, -900, -1100); - return true; - }, - XRPAmount{}, - STTx{ttPAYMENT, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - a1DeepFrozenByIssuer); - - // test: imitating A2 making a payment to deep frozen A1. - doInvariantCheck( - {{"Attempting to move frozen funds"}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - changeBalances(a1, a2, ac, -1100, -900); - return true; - }, - XRPAmount{}, - STTx{ttPAYMENT, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - a1DeepFrozenByIssuer); - } - - void - testXRPBalanceCheck() - { - using namespace test::jtx; - testcase << "XRP balance checks"; - - doInvariantCheck( - {{"Cannot return non-native STAmount as XRPAmount"}}, - [](Account const& a1, Account const& a2, ApplyContext& ac) { - // non-native balance - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - STAmount const nonNative(a2["USD"](51)); - sle->setFieldAmount(sfBalance, nonNative); - ac.view().update(sle); - return true; - }); - - doInvariantCheck( - {{"incorrect account XRP balance"}, {"XRP net change was positive: 99999999000000001"}}, - [this](Account const& a1, Account const&, ApplyContext& ac) { - // balance exceeds genesis amount - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - // Use `drops(1)` to bypass a call to STAmount::canonicalize - // with an invalid value - sle->setFieldAmount(sfBalance, kInitialXrp + drops(1)); - BEAST_EXPECT(!sle->getFieldAmount(sfBalance).negative()); - ac.view().update(sle); - return true; - }); - - doInvariantCheck( - {{"incorrect account XRP balance"}, - {"XRP net change of -1000000001 doesn't match fee 0"}}, - [this](Account const& a1, Account const&, ApplyContext& ac) { - // balance is negative - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - sle->setFieldAmount(sfBalance, STAmount{1, true}); - BEAST_EXPECT(sle->getFieldAmount(sfBalance).negative()); - ac.view().update(sle); - return true; - }); - } - - void - testTransactionFeeCheck() - { - using namespace test::jtx; - using namespace std::string_literals; - testcase << "Transaction fee checks"; - - doInvariantCheck( - {{"fee paid was negative: -1"}, {"XRP net change of 0 doesn't match fee -1"}}, - [](Account const&, Account const&, ApplyContext&) { return true; }, - XRPAmount{-1}); - - doInvariantCheck( - {{"fee paid exceeds system limit: "s + to_string(kInitialXrp)}, - {"XRP net change of 0 doesn't match fee "s + to_string(kInitialXrp)}}, - [](Account const&, Account const&, ApplyContext&) { return true; }, - XRPAmount{kInitialXrp}); - - doInvariantCheck( - {{"fee paid is 20 exceeds fee specified in transaction."}, - {"XRP net change of 0 doesn't match fee 20"}}, - [](Account const&, Account const&, ApplyContext&) { return true; }, - XRPAmount{20}, - STTx{ttACCOUNT_SET, [](STObject& tx) { tx.setFieldAmount(sfFee, XRPAmount{10}); }}); - } - - void - testNoBadOffers() - { - using namespace test::jtx; - testcase << "no bad offers"; - - doInvariantCheck( - {{"offer with a bad amount"}}, [](Account const& a1, Account const&, ApplyContext& ac) { - // offer with negative takerpays - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - auto sleNew = std::make_shared( - keylet::offer(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence]))); - sleNew->setAccountID(sfAccount, a1.id()); - sleNew->setFieldU32(sfSequence, (*sle)[sfSequence]); - sleNew->setFieldAmount(sfTakerPays, XRP(-1)); - ac.view().insert(sleNew); - return true; - }); - - doInvariantCheck( - {{"offer with a bad amount"}}, [](Account const& a1, Account const&, ApplyContext& ac) { - // offer with negative takergets - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - auto sleNew = std::make_shared( - keylet::offer(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence]))); - sleNew->setAccountID(sfAccount, a1.id()); - sleNew->setFieldU32(sfSequence, (*sle)[sfSequence]); - sleNew->setFieldAmount(sfTakerPays, a1["USD"](10)); - sleNew->setFieldAmount(sfTakerGets, XRP(-1)); - ac.view().insert(sleNew); - return true; - }); - - doInvariantCheck( - {{"offer with a bad amount"}}, [](Account const& a1, Account const&, ApplyContext& ac) { - // offer XRP to XRP - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - auto sleNew = std::make_shared( - keylet::offer(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence]))); - sleNew->setAccountID(sfAccount, a1.id()); - sleNew->setFieldU32(sfSequence, (*sle)[sfSequence]); - sleNew->setFieldAmount(sfTakerPays, XRP(10)); - sleNew->setFieldAmount(sfTakerGets, XRP(11)); - ac.view().insert(sleNew); - return true; - }); - } - - void - testNoZeroEscrow() - { - using namespace test::jtx; - testcase << "no zero escrow"; - - doInvariantCheck( - {{"XRP net change of -1000000 doesn't match fee 0"}, - {"escrow specifies invalid amount"}}, - [](Account const& a1, Account const&, ApplyContext& ac) { - // escrow with negative amount - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - auto sleNew = std::make_shared( - keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2))); - sleNew->setFieldAmount(sfAmount, XRP(-1)); - ac.view().insert(sleNew); - return true; - }); - - doInvariantCheck( - {{"XRP net change was positive: 100000000000000001"}, - {"escrow specifies invalid amount"}}, - [](Account const& a1, Account const&, ApplyContext& ac) { - // escrow with too-large amount - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - auto sleNew = std::make_shared( - keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2))); - // Use `drops(1)` to bypass a call to STAmount::canonicalize - // with an invalid value - sleNew->setFieldAmount(sfAmount, kInitialXrp + drops(1)); - ac.view().insert(sleNew); - return true; - }); - - // IOU < 0 - doInvariantCheck( - {{"escrow specifies invalid amount"}}, - [](Account const& a1, Account const&, ApplyContext& ac) { - // escrow with too-little iou - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - auto sleNew = std::make_shared( - keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2))); - - Issue const usd{Currency(0x5553440000000000), AccountID(0x4985601)}; - STAmount const amt(usd, -1); - sleNew->setFieldAmount(sfAmount, amt); - ac.view().insert(sleNew); - return true; - }); - - // IOU bad currency - doInvariantCheck( - {{"escrow specifies invalid amount"}}, - [](Account const& a1, Account const&, ApplyContext& ac) { - // escrow with bad iou currency - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - auto sleNew = std::make_shared( - keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2))); - - Issue const bad{badCurrency(), AccountID(0x4985601)}; - STAmount const amt(bad, 1); - sleNew->setFieldAmount(sfAmount, amt); - ac.view().insert(sleNew); - return true; - }); - - // MPT < 0 - doInvariantCheck( - {{"escrow specifies invalid amount"}}, - [](Account const& a1, Account const&, ApplyContext& ac) { - // escrow with too-little mpt - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - auto sleNew = std::make_shared( - keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2))); - - MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))}; - STAmount const amt(mpt, -1); - sleNew->setFieldAmount(sfAmount, amt); - ac.view().insert(sleNew); - return true; - }); - - // MPT OutstandingAmount < 0 - doInvariantCheck( - {{"escrow specifies invalid amount"}}, - [](Account const& a1, Account const&, ApplyContext& ac) { - // mptissuance outstanding is negative - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - - MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))}; - auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID())); - sleNew->setFieldU64(sfOutstandingAmount, -1); - ac.view().insert(sleNew); - return true; - }); - - // MPT LockedAmount < 0 - doInvariantCheck( - {{"escrow specifies invalid amount"}}, - [](Account const& a1, Account const&, ApplyContext& ac) { - // mptissuance locked is less than locked - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - - MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))}; - auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID())); - sleNew->setFieldU64(sfLockedAmount, -1); - ac.view().insert(sleNew); - return true; - }); - - // MPT OutstandingAmount < LockedAmount - doInvariantCheck( - {{"escrow specifies invalid amount"}}, - [](Account const& a1, Account const&, ApplyContext& ac) { - // mptissuance outstanding is less than locked - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - - MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))}; - auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID())); - sleNew->setFieldU64(sfOutstandingAmount, 1); - sleNew->setFieldU64(sfLockedAmount, 10); - ac.view().insert(sleNew); - return true; - }); - - // MPT MPTAmount < 0 - doInvariantCheck( - {{"escrow specifies invalid amount"}}, - [](Account const& a1, Account const&, ApplyContext& ac) { - // mptoken amount is negative - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - - MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))}; - auto sleNew = std::make_shared(keylet::mptoken(mpt.getMptID(), a1)); - sleNew->setFieldU64(sfMPTAmount, -1); - ac.view().insert(sleNew); - return true; - }); - - // MPT LockedAmount < 0 - doInvariantCheck( - {{"escrow specifies invalid amount"}}, - [](Account const& a1, Account const&, ApplyContext& ac) { - // mptoken locked amount is negative - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - - MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))}; - auto sleNew = std::make_shared(keylet::mptoken(mpt.getMptID(), a1)); - sleNew->setFieldU64(sfLockedAmount, -1); - ac.view().insert(sleNew); - return true; - }); - } - - void - testValidNewAccountRoot() - { - using namespace test::jtx; - testcase << "valid new account root"; - - doInvariantCheck( - {{"account root created illegally"}}, - [](Account const&, Account const&, ApplyContext& ac) { - // Insert a new account root created by a non-payment into - // the view. - Account const a3{"A3"}; - Keylet const acctKeylet = keylet::account(a3); - auto const sleNew = std::make_shared(acctKeylet); - ac.view().insert(sleNew); - return true; - }); - - doInvariantCheck( - {{"multiple accounts created in a single transaction"}}, - [](Account const&, Account const&, ApplyContext& ac) { - // Insert two new account roots into the view. - { - Account const a3{"A3"}; - Keylet const acctKeylet = keylet::account(a3); - auto const sleA3 = std::make_shared(acctKeylet); - ac.view().insert(sleA3); - } - { - Account const a4{"A4"}; - Keylet const acctKeylet = keylet::account(a4); - auto const sleA4 = std::make_shared(acctKeylet); - ac.view().insert(sleA4); - } - return true; - }); - - doInvariantCheck( - {{"account created with wrong starting sequence number"}}, - [](Account const&, Account const&, ApplyContext& ac) { - // Insert a new account root with the wrong starting sequence. - Account const a3{"A3"}; - Keylet const acctKeylet = keylet::account(a3); - auto const sleNew = std::make_shared(acctKeylet); - sleNew->setFieldU32(sfSequence, ac.view().seq() + 1); - ac.view().insert(sleNew); - return true; - }, - XRPAmount{}, - STTx{ttPAYMENT, [](STObject& tx) {}}); - - doInvariantCheck( - {{"pseudo-account created by a wrong transaction type"}}, - [](Account const&, Account const&, ApplyContext& ac) { - Account const a3{"A3"}; - Keylet const acctKeylet = keylet::account(a3); - auto const sleNew = std::make_shared(acctKeylet); - sleNew->setFieldU32(sfSequence, 0); - sleNew->setFieldH256(sfAMMID, uint256(1)); - sleNew->setFieldU32(sfFlags, lsfDisableMaster | lsfDefaultRipple); - ac.view().insert(sleNew); - return true; - }, - XRPAmount{}, - STTx{ttPAYMENT, [](STObject& tx) {}}); - - doInvariantCheck( - {{"account created with wrong starting sequence number"}}, - [](Account const&, Account const&, ApplyContext& ac) { - Account const a3{"A3"}; - Keylet const acctKeylet = keylet::account(a3); - auto const sleNew = std::make_shared(acctKeylet); - sleNew->setFieldU32(sfSequence, ac.view().seq()); - sleNew->setFieldH256(sfAMMID, uint256(1)); - sleNew->setFieldU32(sfFlags, lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth); - ac.view().insert(sleNew); - return true; - }, - XRPAmount{}, - STTx{ttAMM_CREATE, [](STObject& tx) {}}); - - doInvariantCheck( - {{"pseudo-account created with wrong flags"}}, - [](Account const&, Account const&, ApplyContext& ac) { - Account const a3{"A3"}; - Keylet const acctKeylet = keylet::account(a3); - auto const sleNew = std::make_shared(acctKeylet); - sleNew->setFieldU32(sfSequence, 0); - sleNew->setFieldH256(sfAMMID, uint256(1)); - sleNew->setFieldU32(sfFlags, lsfDisableMaster | lsfDefaultRipple); - ac.view().insert(sleNew); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_CREATE, [](STObject& tx) {}}); - - doInvariantCheck( - {{"pseudo-account created with wrong flags"}}, - [](Account const&, Account const&, ApplyContext& ac) { - Account const a3{"A3"}; - Keylet const acctKeylet = keylet::account(a3); - auto const sleNew = std::make_shared(acctKeylet); - sleNew->setFieldU32(sfSequence, 0); - sleNew->setFieldH256(sfAMMID, uint256(1)); - sleNew->setFieldU32( - sfFlags, - lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth | lsfRequireDestTag); - ac.view().insert(sleNew); - return true; - }, - XRPAmount{}, - STTx{ttAMM_CREATE, [](STObject& tx) {}}); - } - - void - testNFTokenPageInvariants() - { - using namespace test::jtx; - testcase << "NFTokenPage"; - - // lambda that returns an STArray of NFTokenIDs. - uint256 const firstNFTID( - "0000000000000000000000000000000000000001FFFFFFFFFFFFFFFF00000000"); - auto makeNFTokenIDs = [&firstNFTID](unsigned int nftCount) { - SOTemplate const* nfTokenTemplate = - InnerObjectFormats::getInstance().findSOTemplateBySField(sfNFToken); - - uint256 nftID(firstNFTID); - STArray ret; - for (int i = 0; i < nftCount; ++i) - { - STObject newNFToken(*nfTokenTemplate, sfNFToken, [&nftID](STObject& object) { - object.setFieldH256(sfNFTokenID, nftID); - }); - ret.pushBack(std::move(newNFToken)); - ++nftID; - } - return ret; - }; - - doInvariantCheck( - {{"NFT page has invalid size"}}, - [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) { - auto nftPage = std::make_shared(keylet::nftokenPageMax(a1)); - nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(0)); - - ac.view().insert(nftPage); - return true; - }); - - doInvariantCheck( - {{"NFT page has invalid size"}}, - [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) { - auto nftPage = std::make_shared(keylet::nftokenPageMax(a1)); - nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(33)); - - ac.view().insert(nftPage); - return true; - }); - - doInvariantCheck( - {{"NFTs on page are not sorted"}}, - [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) { - STArray nfTokens = makeNFTokenIDs(2); - std::iter_swap(nfTokens.begin(), nfTokens.begin() + 1); - - auto nftPage = std::make_shared(keylet::nftokenPageMax(a1)); - nftPage->setFieldArray(sfNFTokens, nfTokens); - - ac.view().insert(nftPage); - return true; - }); - - doInvariantCheck( - {{"NFT contains empty URI"}}, - [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) { - STArray nfTokens = makeNFTokenIDs(1); - nfTokens[0].setFieldVL(sfURI, Blob{}); - - auto nftPage = std::make_shared(keylet::nftokenPageMax(a1)); - nftPage->setFieldArray(sfNFTokens, nfTokens); - - ac.view().insert(nftPage); - return true; - }); - - doInvariantCheck( - {{"NFT page is improperly linked"}}, - [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) { - auto nftPage = std::make_shared(keylet::nftokenPageMax(a1)); - nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(1)); - nftPage->setFieldH256(sfPreviousPageMin, keylet::nftokenPageMax(a1).key); - - ac.view().insert(nftPage); - return true; - }); - - doInvariantCheck( - {{"NFT page is improperly linked"}}, - [&makeNFTokenIDs](Account const& a1, Account const& a2, ApplyContext& ac) { - auto nftPage = std::make_shared(keylet::nftokenPageMax(a1)); - nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(1)); - nftPage->setFieldH256(sfPreviousPageMin, keylet::nftokenPageMin(a2).key); - - ac.view().insert(nftPage); - return true; - }); - - doInvariantCheck( - {{"NFT page is improperly linked"}}, - [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) { - auto nftPage = std::make_shared(keylet::nftokenPageMax(a1)); - nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(1)); - nftPage->setFieldH256(sfNextPageMin, nftPage->key()); - - ac.view().insert(nftPage); - return true; - }); - - doInvariantCheck( - {{"NFT page is improperly linked"}}, - [&makeNFTokenIDs](Account const& a1, Account const& a2, ApplyContext& ac) { - STArray nfTokens = makeNFTokenIDs(1); - auto nftPage = std::make_shared(keylet::nftokenPage( - keylet::nftokenPageMax(a1), ++(nfTokens[0].getFieldH256(sfNFTokenID)))); - nftPage->setFieldArray(sfNFTokens, nfTokens); - nftPage->setFieldH256(sfNextPageMin, keylet::nftokenPageMax(a2).key); - - ac.view().insert(nftPage); - return true; - }); - - doInvariantCheck( - {{"NFT found in incorrect page"}}, - [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) { - STArray nfTokens = makeNFTokenIDs(2); - auto nftPage = std::make_shared(keylet::nftokenPage( - keylet::nftokenPageMax(a1), (nfTokens[1].getFieldH256(sfNFTokenID)))); - nftPage->setFieldArray(sfNFTokens, nfTokens); - - ac.view().insert(nftPage); - return true; - }); - } - - void - testAMMDeleteInvariants(FeatureBitset features) - { - using namespace test::jtx; - - bool const enforceAMMDelete = features[fixCleanup3_3_0]; - testcase << "AMM delete invariants" + std::string(enforceAMMDelete ? " fix" : ""); - - Env env(*this, features); - Account const issuer{"issuer"}; - Issue const lptIssue{Currency(0x4c50540000000000), issuer.id()}; - STAmount const zeroLP{lptIssue, 0}; - STAmount const nonZeroLP{lptIssue, 1}; - - auto const makeAMM = [](STAmount const& lptBalance) { - auto sleAMM = std::make_shared(keylet::amm(uint256(1))); - sleAMM->setFieldAmount(sfLPTokenBalance, lptBalance); - return sleAMM; - }; - - auto const checkInvariant = [&](TxType txType, - TER result, - std::optional const& deletedLPBalance, - bool expected, - std::string const& expectedLog) { - test::StreamSink sink{beast::Severity::Warning}; - beast::Journal const jlog{sink}; - ValidAMM invariant; - - if (deletedLPBalance) - invariant.visitEntry(true, makeAMM(*deletedLPBalance), nullptr); - - bool const actual = invariant.finalize( - STTx{txType, [](STObject&) {}}, result, XRPAmount{}, *env.current(), jlog); - - BEAST_EXPECTS(actual == expected, "unexpected AMM delete invariant result"); - auto const messages = sink.messages().str(); - auto const expectedLogWhenEnforced = enforceAMMDelete ? expectedLog : ""; - if (!expectedLogWhenEnforced.empty()) - { - BEAST_EXPECTS(messages.contains(expectedLogWhenEnforced), expectedLogWhenEnforced); - } - else - { - BEAST_EXPECTS(messages.empty(), messages); - } - }; - - checkInvariant( - ttPAYMENT, - tesSUCCESS, - nonZeroLP, - !enforceAMMDelete, - "Invariant failed: AMM failed, unexpected AMM deletion by"); - checkInvariant( - ttAMM_DELETE, - tesSUCCESS, - std::nullopt, - !enforceAMMDelete, - "Invariant failed: AMMDelete failed, AMM object remained on tesSUCCESS"); - checkInvariant( - ttAMM_DELETE, - tesSUCCESS, - nonZeroLP, - !enforceAMMDelete, - "Invariant failed: AMMDelete failed, AMM object deleted with non-zero LP balance"); - checkInvariant( - ttAMM_DELETE, - tecINCOMPLETE, - zeroLP, - !enforceAMMDelete, - "Invariant failed: AMMDelete failed, AMM object deleted when result is not tesSUCCESS"); - - checkInvariant(ttAMM_WITHDRAW, tesSUCCESS, nonZeroLP, true, ""); - checkInvariant(ttAMM_CLAWBACK, tesSUCCESS, nonZeroLP, true, ""); - - checkInvariant(ttAMM_DELETE, tesSUCCESS, zeroLP, true, ""); - checkInvariant(ttAMM_WITHDRAW, tesSUCCESS, zeroLP, true, ""); - checkInvariant(ttAMM_CLAWBACK, tesSUCCESS, zeroLP, true, ""); - } - - static SLE::pointer - createPermissionedDomain( - ApplyContext& ac, - test::jtx::Account const& a1, - test::jtx::Account const& a2, - std::uint32_t numCreds = 2, - std::uint32_t seq = 10) - { - Keylet const pdKeylet = keylet::permissionedDomain(a1.id(), SeqProxy::rawSequence(seq)); - auto sle = std::make_shared(pdKeylet); - - sle->setAccountID(sfOwner, a1); - sle->setFieldU32(sfSequence, seq); - - if (numCreds != 0u) - { - // This array is sorted naturally, but if you are going to change - // this behavior, don't forget to use credentials::makeSorted - STArray credentials(sfAcceptedCredentials, numCreds); - for (std::size_t n = 0; n < numCreds; ++n) - { - auto cred = STObject::makeInnerObject(sfCredential); - cred.setAccountID(sfIssuer, a2); - auto credType = "cred_type" + std::to_string(n); - cred.setFieldVL(sfCredentialType, Slice(credType.c_str(), credType.size())); - credentials.pushBack(std::move(cred)); - } - sle->setFieldArray(sfAcceptedCredentials, credentials); - } - - ac.view().insert(sle); - return sle; - }; - - void - testPermissionedDomainInvariants(FeatureBitset features) - { - using namespace test::jtx; - - bool const fixEnabled = features[fixCleanup3_1_3]; - std::initializer_list const badTers = {tecINVARIANT_FAILED, tecINVARIANT_FAILED}; - std::initializer_list const failTers = {tecINVARIANT_FAILED, tefINVARIANT_FAILED}; - - testcase << "PermissionedDomain" + std::string(fixEnabled ? " fix" : ""); - - doInvariantCheck( - makeEnv(features), - {{"permissioned domain with no rules."}}, - [](Account const& a1, Account const& a2, ApplyContext& ac) { - return createPermissionedDomain(ac, a1, a2, 0).get(); - }, - XRPAmount{}, - STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}}, - fixEnabled ? failTers : badTers); - - testcase << "PermissionedDomain 2"; - - static constexpr auto kTooBig = kMaxPermissionedDomainCredentialsArraySize + 1; - doInvariantCheck( - makeEnv(features), - {{"permissioned domain bad credentials size " + std::to_string(kTooBig)}}, - [](Account const& a1, Account const& a2, ApplyContext& ac) { - return !!createPermissionedDomain(ac, a1, a2, kTooBig); - }, - XRPAmount{}, - STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}}, - fixEnabled ? failTers : badTers); - - testcase << "PermissionedDomain 3"; - doInvariantCheck( - makeEnv(features), - {{"permissioned domain credentials aren't sorted"}}, - [](Account const& a1, Account const& a2, ApplyContext& ac) { - auto slePd = createPermissionedDomain(ac, a1, a2, 0); - - STArray credentials(sfAcceptedCredentials, 2); - for (std::size_t n = 0; n < 2; ++n) - { - auto cred = STObject::makeInnerObject(sfCredential); - cred.setAccountID(sfIssuer, a2); - auto credType = std::string("cred_type") + std::to_string(9 - n); - cred.setFieldVL(sfCredentialType, Slice(credType.c_str(), credType.size())); - credentials.pushBack(std::move(cred)); - } - slePd->setFieldArray(sfAcceptedCredentials, credentials); - ac.view().update(slePd); - return true; - }, - XRPAmount{}, - STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}}, - fixEnabled ? failTers : badTers); - - testcase << "PermissionedDomain 4"; - doInvariantCheck( - makeEnv(features), - {{"permissioned domain credentials aren't unique"}}, - [](Account const& a1, Account const& a2, ApplyContext& ac) { - auto slePd = createPermissionedDomain(ac, a1, a2, 0); - - STArray credentials(sfAcceptedCredentials, 2); - for (std::size_t n = 0; n < 2; ++n) - { - auto cred = STObject::makeInnerObject(sfCredential); - cred.setAccountID(sfIssuer, a2); - cred.setFieldVL(sfCredentialType, Slice("cred_type", 9)); - credentials.pushBack(std::move(cred)); - } - slePd->setFieldArray(sfAcceptedCredentials, credentials); - ac.view().update(slePd); - return true; - }, - XRPAmount{}, - STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}}, - fixEnabled ? failTers : badTers); - - testcase << "PermissionedDomain Set 1"; - doInvariantCheck( - makeEnv(features), - {{"permissioned domain with no rules."}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - // create PD - auto slePd = createPermissionedDomain(ac, a1, a2); - - // update PD with empty rules - { - STArray const credentials(sfAcceptedCredentials, 2); - slePd->setFieldArray(sfAcceptedCredentials, credentials); - ac.view().update(slePd); - } - - return true; - }, - XRPAmount{}, - STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}}, - fixEnabled ? failTers : badTers); - - testcase << "PermissionedDomain Set 2"; - doInvariantCheck( - makeEnv(features), - {{"permissioned domain bad credentials size " + std::to_string(kTooBig)}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - // create PD - auto slePd = createPermissionedDomain(ac, a1, a2); - - // update PD - { - STArray credentials(sfAcceptedCredentials, kTooBig); - - for (std::size_t n = 0; n < kTooBig; ++n) - { - auto cred = STObject::makeInnerObject(sfCredential); - cred.setAccountID(sfIssuer, a2); - auto credType = "cred_type2" + std::to_string(n); - cred.setFieldVL(sfCredentialType, Slice(credType.c_str(), credType.size())); - credentials.pushBack(std::move(cred)); - } - - slePd->setFieldArray(sfAcceptedCredentials, credentials); - ac.view().update(slePd); - } - - return true; - }, - XRPAmount{}, - STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}}, - fixEnabled ? failTers : badTers); - - testcase << "PermissionedDomain Set 3"; - doInvariantCheck( - makeEnv(features), - {{"permissioned domain credentials aren't sorted"}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - // create PD - auto slePd = createPermissionedDomain(ac, a1, a2); - - // update PD - { - STArray credentials(sfAcceptedCredentials, 2); - for (std::size_t n = 0; n < 2; ++n) - { - auto cred = STObject::makeInnerObject(sfCredential); - cred.setAccountID(sfIssuer, a2); - auto credType = std::string("cred_type2") + std::to_string(9 - n); - cred.setFieldVL(sfCredentialType, Slice(credType.c_str(), credType.size())); - credentials.pushBack(std::move(cred)); - } - - slePd->setFieldArray(sfAcceptedCredentials, credentials); - ac.view().update(slePd); - } - - return true; - }, - XRPAmount{}, - STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}}, - fixEnabled ? failTers : badTers); - - testcase << "PermissionedDomain Set 4"; - doInvariantCheck( - makeEnv(features), - {{"permissioned domain credentials aren't unique"}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - // create PD - auto slePd = createPermissionedDomain(ac, a1, a2); - - // update PD - { - STArray credentials(sfAcceptedCredentials, 2); - for (std::size_t n = 0; n < 2; ++n) - { - auto cred = STObject::makeInnerObject(sfCredential); - cred.setAccountID(sfIssuer, a2); - cred.setFieldVL(sfCredentialType, Slice("cred_type", 9)); - credentials.pushBack(std::move(cred)); - } - slePd->setFieldArray(sfAcceptedCredentials, credentials); - ac.view().update(slePd); - } - - return true; - }, - XRPAmount{}, - STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}}, - fixEnabled ? failTers : badTers); - - std::initializer_list const goodTers = {tesSUCCESS, tesSUCCESS}; - - std::vector const badMoreThan1{ - {"transaction affected more than 1 permissioned domain entry."}}; - std::vector const emptyV; - std::vector const badNoDomains{{"no domain objects affected by"}}; - std::vector const badNotDeleted{ - {"domain object modified, but not deleted by "}}; - std::vector const badDeleted{{"domain object deleted by"}}; - std::vector const badTx{ - {"domain object(s) affected by an unauthorized transaction."}}; - - { - testcase << "PermissionedDomain set 2 domains "; - doInvariantCheck( - makeEnv(features), - fixEnabled ? badMoreThan1 : emptyV, - [](Account const& a1, Account const& a2, ApplyContext& ac) { - createPermissionedDomain(ac, a1, a2); - createPermissionedDomain(ac, a1, a2, 2, 11); - return true; - }, - XRPAmount{}, - STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}}, - fixEnabled ? failTers : goodTers); - } - - { - testcase << "PermissionedDomain del 2 domains"; - - Env env1(*this, features); - - Account const a1{"A1"}; - Account const a2{"A2"}; - env1.fund(XRP(1000), a1, a2); - env1.close(); - - [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2); - [[maybe_unused]] auto [seq2, pd2] = createPermissionedDomainEnv(env1, a1, a2); - env1.close(); - - doInvariantCheck( - std::move(env1), - a1, - a2, - fixEnabled ? badMoreThan1 : emptyV, - [&pd1, &pd2](Account const&, Account const&, ApplyContext& ac) { - auto sle1 = ac.view().peek({ltPERMISSIONED_DOMAIN, pd1}); - auto sle2 = ac.view().peek({ltPERMISSIONED_DOMAIN, pd2}); - ac.view().erase(sle1); - ac.view().erase(sle2); - return true; - }, - XRPAmount{}, - STTx{ttPERMISSIONED_DOMAIN_DELETE, [](STObject&) {}}, - fixEnabled ? failTers : goodTers); - } - - { - testcase << "PermissionedDomain set 0 domains "; - doInvariantCheck( - makeEnv(features), - fixEnabled ? badNoDomains : emptyV, - [](Account const&, Account const&, ApplyContext&) { return true; }, - XRPAmount{}, - STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}}, - fixEnabled ? badTers : goodTers); - } - - { - testcase << "PermissionedDomain del 0 domains"; - - Env env1(*this, features); - - Account const a1{"A1"}; - Account const a2{"A2"}; - env1.fund(XRP(1000), a1, a2); - env1.close(); - - [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2); - [[maybe_unused]] auto [seq2, pd2] = createPermissionedDomainEnv(env1, a1, a2); - env1.close(); - - doInvariantCheck( - makeEnv(features), - a1, - a2, - fixEnabled ? badNoDomains : emptyV, - [](Account const&, Account const&, ApplyContext&) { return true; }, - XRPAmount{}, - STTx{ttPERMISSIONED_DOMAIN_DELETE, [](STObject&) {}}, - fixEnabled ? badTers : goodTers); - } - - { - testcase << "PermissionedDomain set, delete domain"; - - Env env1(*this, features); - - Account const a1{"A1"}; - Account const a2{"A2"}; - env1.fund(XRP(1000), a1, a2); - env1.close(); - - [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2); - env1.close(); - - doInvariantCheck( - std::move(env1), - a1, - a2, - fixEnabled ? badDeleted : emptyV, - [&pd1](Account const&, Account const&, ApplyContext& ac) { - auto sle1 = ac.view().peek({ltPERMISSIONED_DOMAIN, pd1}); - ac.view().erase(sle1); - return true; - }, - XRPAmount{}, - STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}}, - fixEnabled ? failTers : goodTers); - } - - { - testcase << "PermissionedDomain del, create domain "; - doInvariantCheck( - makeEnv(features), - fixEnabled ? badNotDeleted : emptyV, - [](Account const& a1, Account const& a2, ApplyContext& ac) { - createPermissionedDomain(ac, a1, a2); - return true; - }, - XRPAmount{}, - STTx{ttPERMISSIONED_DOMAIN_DELETE, [](STObject&) {}}, - fixEnabled ? failTers : goodTers); - } - - { - testcase << "PermissionedDomain invalid tx"; - - doInvariantCheck( - fixEnabled ? badTx : emptyV, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - createPermissionedDomain(ac, a1, a2); - return true; - }, - XRPAmount{}, - STTx{ttPAYMENT, [](STObject&) {}}, - failTers); - } - } - - void - testValidPseudoAccounts() - { - testcase << "valid pseudo accounts"; - - using namespace jtx; - - AccountID pseudoAccountID; - Preclose const createPseudo = [&, this](Account const& a, Account const& b, Env& env) { - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - - // Create vault - Vault const vault{env}; - auto [tx, vKeylet] = vault.create({.owner = a, .asset = xrpAsset}); - env(tx); - env.close(); - if (auto const vSle = env.le(vKeylet); BEAST_EXPECT(vSle)) - { - pseudoAccountID = vSle->at(sfAccount); - } - - return BEAST_EXPECT(env.le(keylet::account(pseudoAccountID))); - }; - - /* Cases to check - "pseudo-account has 0 pseudo-account fields set" - "pseudo-account has 2 pseudo-account fields set" - "pseudo-account sequence changed" - "pseudo-account flags are not set" - "pseudo-account has a regular key" - "pseudo-account has a sponsorship field" - */ - struct Mod - { - std::string expectedFailure; - std::function func; - }; - auto const mods = std::to_array({ - { - .expectedFailure = "pseudo-account has 0 pseudo-account fields set", - .func = - [this](SLE::pointer& sle) { - BEAST_EXPECT(sle->at(~sfVaultID)); - sle->at(~sfVaultID) = std::nullopt; - }, - }, - { - .expectedFailure = "pseudo-account sequence changed", - .func = [](SLE::pointer& sle) { sle->at(sfSequence) = 12345; }, - }, - { - .expectedFailure = "pseudo-account flags are not set", - .func = [](SLE::pointer& sle) { sle->at(sfFlags) = lsfNoFreeze; }, - }, - { - .expectedFailure = "pseudo-account has a regular key", - .func = [](SLE::pointer& sle) { sle->at(sfRegularKey) = Account("regular").id(); }, - }, - { - .expectedFailure = "pseudo-account has a sponsorship field", - .func = [](SLE::pointer& sle) { sle->at(sfSponsoredOwnerCount) = 1; }, - }, - { - .expectedFailure = "pseudo-account has a sponsorship field", - .func = [](SLE::pointer& sle) { sle->at(sfSponsoringOwnerCount) = 1; }, - }, - { - .expectedFailure = "pseudo-account has a sponsorship field", - .func = [](SLE::pointer& sle) { sle->at(sfSponsoringAccountCount) = 1; }, - }, - { - .expectedFailure = "pseudo-account has a sponsorship field", - .func = [](SLE::pointer& sle) { sle->at(sfSponsor) = Account("sponsor").id(); }, - }, - }); - - for (auto const& mod : mods) - { - doInvariantCheck( - {{mod.expectedFailure}}, - [&](Account const& a1, Account const&, ApplyContext& ac) { - auto sle = ac.view().peek(keylet::account(pseudoAccountID)); - if (!sle) - return false; - mod.func(sle); - ac.view().update(sle); - return true; - }, - XRPAmount{}, - STTx{ttACCOUNT_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - createPseudo); - } - for (auto const pField : getPseudoAccountFields()) - { - // createPseudo creates a vault, so sfVaultID will be set, and - // setting it again will not cause an error - if (pField == &sfVaultID) - continue; - doInvariantCheck( - {{"pseudo-account has 2 pseudo-account fields set"}}, - [&](Account const& a1, Account const&, ApplyContext& ac) { - auto sle = ac.view().peek(keylet::account(pseudoAccountID)); - if (!sle) - return false; - - auto const vaultID = ~sle->at(~sfVaultID); - BEAST_EXPECT(vaultID && !sle->isFieldPresent(*pField)); - sle->setFieldH256(*pField, *vaultID); - - ac.view().update(sle); - return true; - }, - XRPAmount{}, - STTx{ttACCOUNT_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - createPseudo); - } - - // Take one of the regular accounts and set the sequence to 0, which - // will make it look like a pseudo-account - doInvariantCheck( - {{"pseudo-account has 0 pseudo-account fields set"}, - {"pseudo-account sequence changed"}, - {"pseudo-account flags are not set"}}, - [&](Account const& a1, Account const&, ApplyContext& ac) { - auto sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - sle->at(sfSequence) = 0; - ac.view().update(sle); - return true; - }); - } - - static std::pair - createPermissionedDomainEnv( - test::jtx::Env& env, - test::jtx::Account const& a1, - test::jtx::Account const& a2, - std::uint32_t numCreds = 2) - { - using namespace test::jtx; - - pdomain::Credentials credentials; - - for (std::size_t n = 0; n < numCreds; ++n) - { - auto credType = "cred_type" + std::to_string(n); - credentials.push_back({.issuer = a2, .credType = credType}); - } - - std::uint32_t const seq = env.seq(a1); - env(pdomain::setTx(a1, credentials)); - uint256 const key = pdomain::getNewDomain(env.meta()); - - // std::cout << "PD, acc: " << A1.id() << ", seq: " << seq << ", k: " << - // key << std::endl; - return {seq, key}; - } - - void - testPermissionedDEX(FeatureBitset features) - { - using namespace test::jtx; - - bool const fixEnabled = features[fixCleanup3_1_3]; - - testcase << "PermissionedDEX" + std::string(fixEnabled ? " fix" : ""); - - doInvariantCheck( - makeEnv(features), - {{"domain doesn't exist"}}, - [](Account const& a1, Account const&, ApplyContext& ac) { - Keylet const offerKey = keylet::offer(a1.id(), SeqProxy::rawSequence(10)); - auto sleOffer = std::make_shared(offerKey); - sleOffer->setAccountID(sfAccount, a1); - sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10)); - sleOffer->setFieldAmount(sfTakerGets, XRP(1)); - ac.view().insert(sleOffer); - return true; - }, - XRPAmount{}, - STTx{ - ttOFFER_CREATE, - [](STObject& tx) { - tx.setFieldH256( - sfDomainID, - uint256{"F10D0CC9A0F9A3CBF585B80BE09A186483668FDBDD39AA7E33" - "70F3649CE134E5"}); - Account const a1{"A1"}; - tx.setFieldAmount(sfTakerPays, a1["USD"](10)); - tx.setFieldAmount(sfTakerGets, XRP(1)); - }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); - - // missing domain ID in offer object - doInvariantCheck( - makeEnv(features), - {{"hybrid offer is malformed"}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10)); - auto sleOffer = std::make_shared(offerKey); - sleOffer->setAccountID(sfAccount, a2); - sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10)); - sleOffer->setFieldAmount(sfTakerGets, XRP(1)); - sleOffer->setFlag(lsfHybrid); - - STArray bookArr; - bookArr.pushBack(STObject::makeInnerObject(sfBook)); - sleOffer->setFieldArray(sfAdditionalBooks, bookArr); - ac.view().insert(sleOffer); - return true; - }, - XRPAmount{}, - STTx{ttOFFER_CREATE, [&](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); - - // more than one entry in sfAdditionalBooks - { - Env env1(*this, features); - - Account const a1{"A1"}; - Account const a2{"A2"}; - env1.fund(XRP(1000), a1, a2); - env1.close(); - - [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2); - env1.close(); - - doInvariantCheck( - std::move(env1), - a1, - a2, - {{"hybrid offer is malformed"}}, - [&pd1](Account const& a1, Account const& a2, ApplyContext& ac) { - Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10)); - auto sleOffer = std::make_shared(offerKey); - sleOffer->setAccountID(sfAccount, a2); - sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10)); - sleOffer->setFieldAmount(sfTakerGets, XRP(1)); - sleOffer->setFlag(lsfHybrid); - sleOffer->setFieldH256(sfDomainID, pd1); - - STArray bookArr; - bookArr.pushBack(STObject::makeInnerObject(sfBook)); - bookArr.pushBack(STObject::makeInnerObject(sfBook)); - sleOffer->setFieldArray(sfAdditionalBooks, bookArr); - ac.view().insert(sleOffer); - return true; - }, - XRPAmount{}, - STTx{ttOFFER_CREATE, [&](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); - } - - // empty sfAdditionalBooks (size 0) - { - Env env1(*this, features); - - Account const a1{"A1"}; - Account const a2{"A2"}; - env1.fund(XRP(1000), a1, a2); - env1.close(); - - [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2); - env1.close(); - - doInvariantCheck( - std::move(env1), - a1, - a2, - fixEnabled ? std::vector{{"hybrid offer is malformed"}} - : std::vector{}, - [&pd1](Account const& a1, Account const& a2, ApplyContext& ac) { - Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10)); - auto sleOffer = std::make_shared(offerKey); - sleOffer->setAccountID(sfAccount, a2); - sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10)); - sleOffer->setFieldAmount(sfTakerGets, XRP(1)); - sleOffer->setFlag(lsfHybrid); - sleOffer->setFieldH256(sfDomainID, pd1); - - STArray const bookArr; // empty array, size 0 - sleOffer->setFieldArray(sfAdditionalBooks, bookArr); - ac.view().insert(sleOffer); - return true; - }, - XRPAmount{}, - STTx{ttOFFER_CREATE, [&](STObject&) {}}, - fixEnabled ? std::initializer_list{tecINVARIANT_FAILED, tecINVARIANT_FAILED} - : std::initializer_list{tesSUCCESS, tesSUCCESS}); - } - - // hybrid offer missing sfAdditionalBooks - { - Env env1(*this, features); - - Account const a1{"A1"}; - Account const a2{"A2"}; - env1.fund(XRP(1000), a1, a2); - env1.close(); - - [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2); - env1.close(); - - doInvariantCheck( - std::move(env1), - a1, - a2, - {{"hybrid offer is malformed"}}, - [&pd1](Account const& a1, Account const& a2, ApplyContext& ac) { - Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10)); - auto sleOffer = std::make_shared(offerKey); - sleOffer->setAccountID(sfAccount, a2); - sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10)); - sleOffer->setFieldAmount(sfTakerGets, XRP(1)); - sleOffer->setFlag(lsfHybrid); - sleOffer->setFieldH256(sfDomainID, pd1); - ac.view().insert(sleOffer); - return true; - }, - XRPAmount{}, - STTx{ttOFFER_CREATE, [&](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); - } - - { - Env env1(*this, features); - - Account const a1{"A1"}; - Account const a2{"A2"}; - env1.fund(XRP(1000), a1, a2); - env1.close(); - - [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2); - [[maybe_unused]] auto [seq2, pd2] = createPermissionedDomainEnv(env1, a1, a2); - env1.close(); - - doInvariantCheck( - std::move(env1), - a1, - a2, - {{"transaction consumed wrong domains"}}, - [&pd1](Account const& a1, Account const& a2, ApplyContext& ac) { - Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10)); - auto sleOffer = std::make_shared(offerKey); - sleOffer->setAccountID(sfAccount, a2); - sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10)); - sleOffer->setFieldAmount(sfTakerGets, XRP(1)); - sleOffer->setFieldH256(sfDomainID, pd1); - ac.view().insert(sleOffer); - return true; - }, - XRPAmount{}, - STTx{ - ttOFFER_CREATE, - [&pd2, &a1](STObject& tx) { - tx.setFieldH256(sfDomainID, pd2); - tx.setFieldAmount(sfTakerPays, a1["USD"](10)); - tx.setFieldAmount(sfTakerGets, XRP(1)); - }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); - } - - { - Env env1(*this, features); - - Account const a1{"A1"}; - Account const a2{"A2"}; - env1.fund(XRP(1000), a1, a2); - env1.close(); - - [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2); - env1.close(); - - doInvariantCheck( - std::move(env1), - a1, - a2, - {{"domain transaction affected regular offers"}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10)); - auto sleOffer = std::make_shared(offerKey); - sleOffer->setAccountID(sfAccount, a2); - sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10)); - sleOffer->setFieldAmount(sfTakerGets, XRP(1)); - ac.view().insert(sleOffer); - return true; - }, - XRPAmount{}, - STTx{ - ttOFFER_CREATE, - [&](STObject& tx) { - Account const a1{"A1"}; - tx.setFieldH256(sfDomainID, pd1); - tx.setFieldAmount(sfTakerPays, a1["USD"](10)); - tx.setFieldAmount(sfTakerGets, XRP(1)); - }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); - } - } - - void - testPermissionedDEXDeletedOfferFallback() - { - using namespace test::jtx; - - testcase << "PermissionedDEX null after"; - - // Tx is OfferCreate on pd2. Tracking pd1 fails the invariant iff that - // domain lands in the set finalize consults. after == null is never - // tracked (pre-340: after-only; post-340: early return) — same result, - // both sides are coverage/regression that we do not fall back to before. - auto const check = [this]( - FeatureBitset features, - bool const afterIsNull, - bool const isDelete, - bool const expectInvariantFailure) { - Env env(*this, features); - - Account const a1{"A1"}; - Account const a2{"A2"}; - env.fund(XRP(1000), a1, a2); - env.close(); - - [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env, a1, a2); - [[maybe_unused]] auto [seq2, pd2] = createPermissionedDomainEnv(env, a1, a2); - env.close(); - - auto sleOffer = - std::make_shared(keylet::offer(a2.id(), SeqProxy::rawSequence(10))); - sleOffer->setAccountID(sfAccount, a2); - sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10)); - sleOffer->setFieldAmount(sfTakerGets, XRP(1)); - sleOffer->setFieldH256(sfDomainID, pd1); - - CurrentTransactionRulesGuard const rulesGuard(env.current()->rules()); - - ValidPermissionedDEX invariant; - if (afterIsNull) - { - // Defensive path: after is null. Must not fall back to before. - invariant.visitEntry(isDelete, sleOffer, nullptr); - } - else - { - // Normal / real-erase path: after is the offer on pd1. - invariant.visitEntry(isDelete, nullptr, sleOffer); - } - - STTx const tx{ttOFFER_CREATE, [&pd2, &a1](STObject& tx) { - tx.setFieldH256(sfDomainID, pd2); - tx.setFieldAmount(sfTakerPays, a1["USD"](10)); - tx.setFieldAmount(sfTakerGets, XRP(1)); - }}; - - test::StreamSink sink{beast::Severity::Warning}; - beast::Journal const jlog{sink}; - bool const passed = - invariant.finalize(tx, tesSUCCESS, XRPAmount{}, *env.current(), jlog); - BEAST_EXPECT(passed != expectInvariantFailure); - if (expectInvariantFailure) - { - BEAST_EXPECT(sink.messages().str().contains("transaction consumed wrong domains")); - } - else - { - BEAST_EXPECT(sink.messages().str().empty()); - } - }; - - auto const pre = defaultAmendments() - fixCleanup3_4_0; - auto const post = defaultAmendments() | fixCleanup3_4_0; - - // after == null: not tracked - check(pre, true, true, false); - check(post, true, true, false); - - // after == offer on pd1 - // pre-340: domainsOld_ (delete still inserted) → fail - check(pre, false, true, true); - // post-340: isDelete → only domainsOld_ → pass; !isDelete → domains_ → fail - check(post, false, true, false); - check(post, false, false, true); - } - - void - testBookDirectoryExchangeRate() - { - using namespace test::jtx; - testcase << "book directory exchange rate"; - - auto const getBookRootKey = [](Account const& account, std::uint64_t quality) { - Book const book{xrpIssue(), account["USD"], std::nullopt}; - return keylet::quality(keylet::book(book), quality); - }; - - // Root book-directory pages carry exchange-rate metadata that must - // match the quality encoded in the directory key. - auto const makeRootPage = [](Keylet const& dir, std::uint64_t exchangeRate) { - auto sleDir = std::make_shared(dir); - sleDir->setFieldH256(sfRootIndex, dir.key); - STVector256 indexes; - indexes.pushBack(uint256{1}); - sleDir->setFieldV256(sfIndexes, indexes); - sleDir->setFieldU64(sfExchangeRate, exchangeRate); - return sleDir; - }; - - // Child pages do not carry quality metadata; they only point back to - // the root directory. - auto const makeChildPage = [](Keylet const& rootDir) { - auto sleDir = std::make_shared(keylet::page(rootDir, 1)); - sleDir->setFieldH256(sfRootIndex, rootDir.key); - STVector256 indexes; - indexes.pushBack(uint256{2}); - sleDir->setFieldV256(sfIndexes, indexes); - return sleDir; - }; - - auto const makeOfferCreateTx = [] { - return STTx{ttOFFER_CREATE, [](STObject& tx) { - Account const account{"A1"}; - tx.setFieldAmount(sfTakerPays, XRP(1)); - tx.setFieldAmount(sfTakerGets, account["USD"](1)); - }}; - }; - std::initializer_list const failTers = {tecINVARIANT_FAILED, tefINVARIANT_FAILED}; - - // Creating a root book directory with mismatched exchange-rate - // metadata violates the invariant. - doInvariantCheck( - {{"book directory exchange rate does not match directory quality"}}, - [&](Account const& a1, Account const&, ApplyContext& ac) { - auto const directoryQuality = STAmount::kURateOne; - auto const dir = getBookRootKey(a1, directoryQuality); - ac.view().insert(makeRootPage(dir, directoryQuality + 1)); - return true; - }, - XRPAmount{}, - makeOfferCreateTx(), - failTers); - - // A new child page must point to an existing root page. - doInvariantCheck( - {{"book directory root missing"}}, - [&](Account const& a1, Account const&, ApplyContext& ac) { - auto const directoryQuality = STAmount::kURateOne; - auto const rootDir = getBookRootKey(a1, directoryQuality); - // Insert only the child page. It points at rootDir, but the - // corresponding root page is intentionally missing. - ac.view().insert(makeChildPage(rootDir)); - return true; - }, - XRPAmount{}, - makeOfferCreateTx(), - failTers); - - // Legacy bad-root tolerance: - // - The view contains a pre-existing root page with bad sfExchangeRate - // metadata. - // - The simulated transaction only creates a child page pointing to - // that root. - // - The invariant must pass because this transaction did not create - // the bad root, only adding a child page. - { - Env env{*this, defaultAmendments()}; - Account const a1{"A1"}; - env.fund(XRP(1000), a1); - env.close(); - - OpenView view{*env.current()}; - auto const directoryQuality = STAmount::kURateOne; - auto const rootDir = getBookRootKey(a1, directoryQuality); - view.rawInsert(makeRootPage(rootDir, directoryQuality + 1)); - - ValidBookDirectory invariant; - invariant.visitEntry(false, nullptr, makeChildPage(rootDir)); - - test::StreamSink sink{beast::Severity::Warning}; - beast::Journal const jlog{sink}; - BEAST_EXPECT( - invariant.finalize(makeOfferCreateTx(), tesSUCCESS, XRPAmount{}, view, jlog)); - } - - // A bad root is rejected when added, ignored when a legacy bad root is - // modified without changing sfRootIndex or deleted, and checked when a - // modified directory changes sfRootIndex. - { - Env env{*this, defaultAmendments()}; - Account const a1{"A1"}; - env.fund(XRP(1000), a1); - env.close(); - - OpenView view{*env.current()}; - auto const directoryQuality = STAmount::kURateOne; - auto const rootDir = getBookRootKey(a1, directoryQuality); - auto const missingRootDir = getBookRootKey(a1, directoryQuality + 1); - auto const badRoot = makeRootPage(rootDir, directoryQuality + 1); - view.rawInsert(badRoot); - - test::StreamSink sink{beast::Severity::Warning}; - beast::Journal const jlog{sink}; - - { - // add - ValidBookDirectory invariant; - invariant.visitEntry(false, nullptr, badRoot); - - BEAST_EXPECT( - !invariant.finalize(makeOfferCreateTx(), tesSUCCESS, XRPAmount{}, view, jlog)); - } - { - // modify (without changing the sfRootIndex) - ValidBookDirectory invariant; - invariant.visitEntry(false, badRoot, badRoot); - - BEAST_EXPECT( - invariant.finalize(makeOfferCreateTx(), tesSUCCESS, XRPAmount{}, view, jlog)); - } - { - // modify (changing sfRootIndex to a missing root) - auto const childBefore = makeChildPage(rootDir); - auto const childAfter = std::make_shared(*childBefore, childBefore->key()); - childAfter->setFieldH256(sfRootIndex, missingRootDir.key); - - ValidBookDirectory invariant; - invariant.visitEntry(false, childBefore, childAfter); - - test::StreamSink missingRootSink{beast::Severity::Warning}; - beast::Journal const missingRootJlog{missingRootSink}; - BEAST_EXPECT(!invariant.finalize( - makeOfferCreateTx(), tesSUCCESS, XRPAmount{}, view, missingRootJlog)); - BEAST_EXPECT( - missingRootSink.messages().str().contains("book directory root missing")); - } - { - // delete - view.rawErase(badRoot); - BEAST_EXPECT(!view.exists(rootDir)); - - ValidBookDirectory invariant; - invariant.visitEntry(true, badRoot, badRoot); - BEAST_EXPECT( - invariant.finalize(makeOfferCreateTx(), tesSUCCESS, XRPAmount{}, view, jlog)); - } - } - } - - Keylet - createLoanBroker(jtx::Account const& a, jtx::Env& env, jtx::PrettyAsset const& asset) - { - using namespace jtx; - - // Create vault - uint256 vaultID; - Vault const vault{env}; - auto [tx, vKeylet] = vault.create({.owner = a, .asset = asset}); - env(tx); - BEAST_EXPECT(env.le(vKeylet)); - - vaultID = vKeylet.key; - - // Create Loan Broker - using namespace loan_broker; - - auto const loanBrokerKeylet = keylet::loanBroker(a.id(), SeqProxy::rawSequence(env.seq(a))); - // Create a Loan Broker with all default values. - env(set(a, vaultID), Fee(kIncrement)); - - return loanBrokerKeylet; - }; - - void - testNoModifiedUnmodifiableFields() - { - testcase("no modified unmodifiable fields"); - using namespace jtx; - - // Initialize with a placeholder value because there's no default ctor - Keylet loanBrokerKeylet = keylet::amendments(); - Preclose const createLoanBroker = [&, this](Account const& a, Account const& b, Env& env) { - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - - loanBrokerKeylet = this->createLoanBroker(a, env, xrpAsset); - return BEAST_EXPECT(env.le(loanBrokerKeylet)); - }; - - { - auto const mods = std::to_array>({ - [](SLE::pointer& sle) { sle->at(sfSequence) += 1; }, - [](SLE::pointer& sle) { sle->at(sfOwnerNode) += 1; }, - [](SLE::pointer& sle) { sle->at(sfVaultNode) += 1; }, - [](SLE::pointer& sle) { sle->at(sfVaultID) = uint256(1u); }, - [](SLE::pointer& sle) { sle->at(sfAccount) = sle->at(sfOwner); }, - [](SLE::pointer& sle) { sle->at(sfOwner) = sle->at(sfAccount); }, - [](SLE::pointer& sle) { sle->at(sfManagementFeeRate) += 1; }, - [](SLE::pointer& sle) { sle->at(sfCoverRateMinimum) += 1; }, - [](SLE::pointer& sle) { sle->at(sfCoverRateLiquidation) += 1; }, - [](SLE::pointer& sle) { sle->at(sfLedgerEntryType) += 1; }, - [](SLE::pointer& sle) { sle->at(sfLedgerIndex) = sle->at(sfVaultID).value(); }, - }); - - for (auto const& mod : mods) - { - doInvariantCheck( - {{"changed an unchangeable field"}}, - [&](Account const& a1, Account const&, ApplyContext& ac) { - auto sle = ac.view().peek(loanBrokerKeylet); - if (!sle) - return false; - mod(sle); - ac.view().update(sle); - return true; - }, - XRPAmount{}, - STTx{ttACCOUNT_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - createLoanBroker); - } - } - - // TODO: Loan Object - - // VaultKind, SubscriptionDate and RedemptionDate are immutable once set at creation. - // Enforced by NoModifiedUnmodifiableFields on ltVAULT via kFieldChanged. - Keylet closedEndedVaultKeylet = keylet::amendments(); - Preclose const createClosedEndedVault = [&, this]( - Account const& a, Account const&, Env& env) { - auto const sub = env.now().time_since_epoch().count() + 60; - auto const red = sub + kMinInvestmentPeriod + 1'000'000; - Vault const vault{env}; - auto [tx, keylet] = vault.create( - {.owner = a, - .asset = xrpIssue(), - .vaultKind = std::to_underlying(VaultKind::ClosedEnded), - .subscriptionDate = sub, - .redemptionDate = red}); - env(tx); - closedEndedVaultKeylet = keylet; - return BEAST_EXPECT(env.le(closedEndedVaultKeylet)); - }; - - { - // Each mutation must keep the vault otherwise valid so that only the immutability check - // fires. Shifting both dates by the same offset preserves the gap; bumping sfVaultKind - // stays within the recognised range. - auto const mods = std::to_array>({ - [](SLE::pointer& sle) { sle->at(sfVaultKind) += 1; }, - [](SLE::pointer& sle) { sle->at(sfSubscriptionDate) += 1; }, - [](SLE::pointer& sle) { sle->at(sfRedemptionDate) += 1; }, - }); - - for (auto const& mod : mods) - { - doInvariantCheck( - {{"changed an unchangeable field"}}, - [&](Account const&, Account const&, ApplyContext& ac) { - auto sle = ac.view().peek(closedEndedVaultKeylet); - if (!sle) - return false; - mod(sle); - ac.view().update(sle); - return true; - }, - XRPAmount{}, - STTx{ttACCOUNT_SET, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - createClosedEndedVault); - } - } - - { - auto const mods = std::to_array>({ - [](SLE::pointer& sle) { sle->at(sfLedgerEntryType) += 1; }, - [](SLE::pointer& sle) { sle->at(sfLedgerIndex) = uint256(1u); }, - }); - - for (auto const& mod : mods) - { - doInvariantCheck( - {{"changed an unchangeable field"}}, - [&](Account const& a1, Account const&, ApplyContext& ac) { - auto sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - mod(sle); - ac.view().update(sle); - return true; - }); - } - } - } - - void - testValidLoanBroker() - { - testcase << "valid loan broker"; - - using namespace jtx; - - enum class Asset { XRP, IOU, MPT }; - auto const assetTypes = std::to_array({Asset::XRP, Asset::IOU, Asset::MPT}); - - for (auto const assetType : assetTypes) - { - // Initialize with a placeholder value because there's no default - // ctor - auto const setupAsset = - [&](Account const& alice, Account const& issuer, Env& env) -> PrettyAsset { - switch (assetType) - { - case Asset::IOU: { - PrettyAsset const iouAsset = issuer["IOU"]; - env(trust(alice, iouAsset(1000))); - env(pay(issuer, alice, iouAsset(1000))); - env.close(); - return iouAsset; - } - case Asset::MPT: { - MPTTester mptt{env, issuer, kMptInitNoFund}; - mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); - PrettyAsset const mptAsset = mptt.issuanceID(); - mptt.authorize({.account = alice}); - env(pay(issuer, alice, mptAsset(1000))); - env.close(); - return mptAsset; - } - case Asset::XRP: - default: - return PrettyAsset{xrpIssue(), 1'000'000}; - } - }; - - Keylet loanBrokerKeylet = keylet::amendments(); - Preclose const createLoanBroker = - [&, this](Account const& alice, Account const& issuer, Env& env) { - auto const asset = setupAsset(alice, issuer, env); - loanBrokerKeylet = this->createLoanBroker(alice, env, asset); - return BEAST_EXPECT(env.le(loanBrokerKeylet)); - }; - - // Ensure the test scenarios are set up completely. The test cases - // will need to recompute any of these values it needs for itself - // rather than trying to return a bunch of items - auto setupTest = [&, this](Account const& a1, Account const&, ApplyContext& ac) - -> std::optional> { - if (loanBrokerKeylet.type != ltLOAN_BROKER) - return {}; - auto sleBroker = ac.view().peek(loanBrokerKeylet); - if (!sleBroker) - return {}; - if (!BEAST_EXPECT(sleBroker->at(sfOwnerCount) == 0)) - return {}; - // Need to touch sleBroker so that it is included in the - // modified entries for the invariant to find - ac.view().update(sleBroker); - - // The pseudo-account holds the directory, so get it - auto const pseudoAccountID = sleBroker->at(sfAccount); - auto const pseudoAccountKeylet = keylet::account(pseudoAccountID); - // Strictly speaking, we don't need to load the - // ACCOUNT_ROOT, but check anyway - auto slePseudo = ac.view().peek(pseudoAccountKeylet); - if (!BEAST_EXPECT(slePseudo)) - return {}; - // Make sure the directory doesn't already exist - auto const dirKeylet = keylet::ownerDir(pseudoAccountID); - auto sleDir = ac.view().peek(dirKeylet); - auto const describe = describeOwnerDir(pseudoAccountID); - if (!sleDir) - { - // Create the directory - BEAST_EXPECT( - ::xrpl::directory::createRoot( - ac.view(), dirKeylet, loanBrokerKeylet.key, describe) == 0); - - sleDir = ac.view().peek(dirKeylet); - } - - return std::make_pair(slePseudo, sleDir); - }; - - doInvariantCheck( - {{"Loan Broker with zero OwnerCount has multiple directory " - "pages"}}, - [&setupTest, this](Account const& a1, Account const& a2, ApplyContext& ac) { - auto test = setupTest(a1, a2, ac); - if (!test || !test->first || !test->second) - return false; - - auto slePseudo = test->first; - auto sleDir = test->second; - auto const describe = describeOwnerDir(slePseudo->at(sfAccount)); - - BEAST_EXPECT( - ::xrpl::directory::insertPage( - ac.view(), - 0, - sleDir, - 0, - sleDir, - slePseudo->key(), - keylet::page(sleDir->key(), 0), - describe) == 1); - - return true; - }, - XRPAmount{}, - STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - createLoanBroker); - - doInvariantCheck( - {{"Loan Broker with zero OwnerCount has multiple indexes in " - "the Directory root"}}, - [&setupTest](Account const& a1, Account const& a2, ApplyContext& ac) { - auto test = setupTest(a1, a2, ac); - if (!test || !test->first || !test->second) - return false; - - auto slePseudo = test->first; - auto sleDir = test->second; - auto indexes = sleDir->getFieldV256(sfIndexes); - - // Put some extra garbage into the directory - for (auto const& key : {slePseudo->key(), sleDir->key()}) - { - ::xrpl::directory::insertKey(ac.view(), sleDir, 0, false, indexes, key); - } - - return true; - }, - XRPAmount{}, - STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - createLoanBroker); - - doInvariantCheck( - {{"Loan Broker directory corrupt"}}, - [&setupTest](Account const& a1, Account const& a2, ApplyContext& ac) { - auto test = setupTest(a1, a2, ac); - if (!test || !test->first || !test->second) - return false; - - auto slePseudo = test->first; - auto sleDir = test->second; - auto const describe = describeOwnerDir(slePseudo->at(sfAccount)); - // Empty vector will overwrite the existing entry for the - // holding, if any, avoiding the "has multiple indexes" - // failure. - STVector256 indexes; - - // Put one meaningless key into the directory - auto const key = keylet::account(Account("random").id()).key; - ::xrpl::directory::insertKey(ac.view(), sleDir, 0, false, indexes, key); - - return true; - }, - XRPAmount{}, - STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - createLoanBroker); - - doInvariantCheck( - {{"Loan Broker with zero OwnerCount has an unexpected entry in " - "the directory"}}, - [&setupTest](Account const& a1, Account const& a2, ApplyContext& ac) { - auto test = setupTest(a1, a2, ac); - if (!test || !test->first || !test->second) - return false; - - auto slePseudo = test->first; - auto sleDir = test->second; - // Empty vector will overwrite the existing entry for the - // holding, if any, avoiding the "has multiple indexes" - // failure. - STVector256 indexes; - - ::xrpl::directory::insertKey( - ac.view(), sleDir, 0, false, indexes, slePseudo->key()); - - return true; - }, - XRPAmount{}, - STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - createLoanBroker); - - doInvariantCheck( - {{"Loan Broker sequence number decreased"}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - if (loanBrokerKeylet.type != ltLOAN_BROKER) - return false; - auto sleBroker = ac.view().peek(loanBrokerKeylet); - if (!sleBroker) - return false; - if (!BEAST_EXPECT(sleBroker->at(sfLoanSequence) > 0)) - return false; - // Need to touch sleBroker so that it is included in the - // modified entries for the invariant to find - ac.view().update(sleBroker); - - sleBroker->at(sfLoanSequence) -= 1; - - return true; - }, - XRPAmount{}, - STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - createLoanBroker); - - // Test: cover available less than pseudo-account asset balance - { - Keylet brokerKeylet = keylet::amendments(); - Preclose const createBrokerWithCover = - [&, this](Account const& alice, Account const& issuer, Env& env) { - auto const asset = setupAsset(alice, issuer, env); - brokerKeylet = this->createLoanBroker(alice, env, asset); - if (!BEAST_EXPECT(env.le(brokerKeylet))) - return false; - env(loan_broker::coverDeposit(alice, brokerKeylet.key, asset(10))); - env.close(); - return BEAST_EXPECT(env.le(brokerKeylet)); - }; - - doInvariantCheck( - {{"Loan Broker cover available is less than pseudo-account asset balance"}}, - [&](Account const&, Account const&, ApplyContext& ac) { - auto sle = ac.view().peek(brokerKeylet); - if (!BEAST_EXPECT(sle)) - return false; - // Pseudo-account holds 10 units, set cover to 5 - sle->at(sfCoverAvailable) = Number(5); - ac.view().update(sle); - return true; - }, - XRPAmount{}, - STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - createBrokerWithCover); - } - - // Test: cover available greater than pseudo-account asset balance - // (requires fixCleanup3_1_3) - doInvariantCheck( - {{"Loan Broker cover available is greater than pseudo-account asset balance"}}, - [&](Account const&, Account const&, ApplyContext& ac) { - auto sle = ac.view().peek(loanBrokerKeylet); - if (!BEAST_EXPECT(sle)) - return false; - // Pseudo-account has no cover deposited; set cover - // higher than any incidental balance - sle->at(sfCoverAvailable) = Number(1'000'000); - ac.view().update(sle); - return true; - }, - XRPAmount{}, - STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - createLoanBroker); - } - } - - void - testVault() // NOLINT(readability-function-size) - { - using namespace test::jtx; - - struct AccountAmount - { - AccountID account; - int amount; - }; - struct Adjustments - { - // NOLINTBEGIN(readability-redundant-member-init) - std::optional assetsTotal = std::nullopt; - std::optional assetsAvailable = std::nullopt; - std::optional lossUnrealized = std::nullopt; - std::optional assetsMaximum = std::nullopt; - std::optional sharesTotal = std::nullopt; - std::optional vaultAssets = std::nullopt; - std::optional accountAssets = std::nullopt; - std::optional accountShares = std::nullopt; - // NOLINTEND(readability-redundant-member-init) - }; - constexpr auto kAdjust = [&](ApplyView& ac, xrpl::Keylet keylet, Adjustments args) { - auto sleVault = ac.peek(keylet); - if (!sleVault) - return false; - - auto const mptIssuanceID = (*sleVault)[sfShareMPTID]; - auto sleShares = ac.peek(keylet::mptokenIssuance(mptIssuanceID)); - if (!sleShares) - return false; - - // These two fields are adjusted in absolute terms - if (args.lossUnrealized) - (*sleVault)[sfLossUnrealized] = *args.lossUnrealized; - if (args.assetsMaximum) - (*sleVault)[sfAssetsMaximum] = *args.assetsMaximum; - - // Remaining fields are adjusted in terms of difference - if (args.assetsTotal) - (*sleVault)[sfAssetsTotal] = *(*sleVault)[sfAssetsTotal] + *args.assetsTotal; - if (args.assetsAvailable) - { - (*sleVault)[sfAssetsAvailable] = - *(*sleVault)[sfAssetsAvailable] + *args.assetsAvailable; - } - ac.update(sleVault); - - if (args.sharesTotal) - { - (*sleShares)[sfOutstandingAmount] = - *(*sleShares)[sfOutstandingAmount] + *args.sharesTotal; - ac.update(sleShares); - } - - auto const assets = *(*sleVault)[sfAsset]; - auto const pseudoId = *(*sleVault)[sfAccount]; - if (args.vaultAssets) - { - if (assets.native()) - { - auto slePseudoAccount = ac.peek(keylet::account(pseudoId)); - if (!slePseudoAccount) - return false; - (*slePseudoAccount)[sfBalance] = - *(*slePseudoAccount)[sfBalance] + *args.vaultAssets; - ac.update(slePseudoAccount); - } - else if (assets.holds()) - { - auto const mptId = assets.get().getMptID(); - auto sleMPToken = ac.peek(keylet::mptoken(mptId, pseudoId)); - if (!sleMPToken) - return false; - (*sleMPToken)[sfMPTAmount] = *(*sleMPToken)[sfMPTAmount] + *args.vaultAssets; - ac.update(sleMPToken); - } - else - { - return false; // Not supporting testing with IOU - } - } - - if (args.accountAssets) - { - auto const& pair = *args.accountAssets; - if (assets.native()) - { - auto sleAccount = ac.peek(keylet::account(pair.account)); - if (!sleAccount) - return false; - (*sleAccount)[sfBalance] = *(*sleAccount)[sfBalance] + pair.amount; - ac.update(sleAccount); - } - else if (assets.holds()) - { - auto const mptID = assets.get().getMptID(); - auto sleMPToken = ac.peek(keylet::mptoken(mptID, pair.account)); - if (!sleMPToken) - return false; - (*sleMPToken)[sfMPTAmount] = *(*sleMPToken)[sfMPTAmount] + pair.amount; - ac.update(sleMPToken); - } - else - { - return false; // Not supporting testing with IOU - } - } - - if (args.accountShares) - { - auto const& pair = *args.accountShares; - auto sleMPToken = ac.peek(keylet::mptoken(mptIssuanceID, pair.account)); - if (!sleMPToken) - return false; - (*sleMPToken)[sfMPTAmount] = *(*sleMPToken)[sfMPTAmount] + pair.amount; - ac.update(sleMPToken); - } - return true; - }; - - static constexpr auto kArgs = [](AccountID id, int adjustment, auto fn) -> Adjustments { - Adjustments sample = { - .assetsTotal = adjustment, - .assetsAvailable = adjustment, - .lossUnrealized = 0, - .sharesTotal = adjustment, - .vaultAssets = adjustment, - .accountAssets = // - AccountAmount{.account = id, .amount = -adjustment}, - .accountShares = // - AccountAmount{.account = id, .amount = adjustment}}; - fn(sample); - return sample; - }; - - Account const a3{"A3"}; - Account const a4{"A4"}; - auto const precloseXrp = [&](Account const& a1, Account const& a2, Env& env) -> bool { - env.fund(XRP(1000), a3, a4); - Vault const vault{env}; - auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()}); - env(tx); - env(vault.deposit({.depositor = a1, .id = keylet.key, .amount = XRP(10)})); - env(vault.deposit({.depositor = a2, .id = keylet.key, .amount = XRP(10)})); - env(vault.deposit({.depositor = a3, .id = keylet.key, .amount = XRP(10)})); - return true; - }; - - testcase << "Vault general checks"; - doInvariantCheck( - {"vault deletion succeeded without deleting a vault"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - auto sleVault = ac.view().peek(keylet); - if (!sleVault) - return false; - ac.view().update(sleVault); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_DELETE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - Vault const vault{env}; - auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); - env(tx); - return true; - }); - - doInvariantCheck( - {"vault updated by a wrong transaction type", - "deleted Vault without deleting its pseudo-account"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - auto sleVault = ac.view().peek(keylet); - if (!sleVault) - return false; - ac.view().erase(sleVault); - return true; - }, - XRPAmount{}, - STTx{ttPAYMENT, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - Vault const vault{env}; - auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); - env(tx); - return true; - }); - - doInvariantCheck( - {"vault updated by a wrong transaction type"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - auto sleVault = ac.view().peek(keylet); - if (!sleVault) - return false; - ac.view().update(sleVault); - return true; - }, - XRPAmount{}, - STTx{ttPAYMENT, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - Vault const vault{env}; - auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); - env(tx); - return true; - }); - - doInvariantCheck( - {"vault updated by a wrong transaction type"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const sequence = ac.view().seq(); - auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(sequence)); - auto sleVault = std::make_shared(vaultKeylet); - auto const vaultPage = ac.view().dirInsert( - keylet::ownerDir(a1.id()), sleVault->key(), describeOwnerDir(a1.id())); - sleVault->setFieldU64(sfOwnerNode, *vaultPage); - sleVault->setAccountID(sfAccount, a1.id()); - ac.view().insert(sleVault); - return true; - }, - XRPAmount{}, - STTx{ttPAYMENT, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); - - doInvariantCheck( - {"vault deleted by a wrong transaction type", - "deleted Vault without deleting its pseudo-account"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - auto sleVault = ac.view().peek(keylet); - if (!sleVault) - return false; - ac.view().erase(sleVault); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_SET, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - Vault const vault{env}; - auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); - env(tx); - return true; - }); - - doInvariantCheck( - {"vault operation updated more than single vault", - "deleted Vault without deleting its pseudo-account"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - { - auto const keylet = - keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - auto sleVault = ac.view().peek(keylet); - if (!sleVault) - return false; - ac.view().erase(sleVault); - } - { - auto const keylet = - keylet::vault(a2.id(), SeqProxy::rawSequence(ac.view().seq())); - auto sleVault = ac.view().peek(keylet); - if (!sleVault) - return false; - ac.view().erase(sleVault); - } - return true; - }, - XRPAmount{}, - STTx{ttVAULT_DELETE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - Vault const vault{env}; - { - auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); - env(tx); - } - { - auto [tx, _] = vault.create({.owner = a2, .asset = xrpIssue()}); - env(tx); - } - return true; - }); - - doInvariantCheck( - {"vault operation updated more than single vault"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const sequence = ac.view().seq(); - auto const insertVault = [&](Account const a) { - auto const vaultKeylet = keylet::vault(a.id(), SeqProxy::rawSequence(sequence)); - auto sleVault = std::make_shared(vaultKeylet); - auto const vaultPage = ac.view().dirInsert( - keylet::ownerDir(a.id()), sleVault->key(), describeOwnerDir(a.id())); - sleVault->setFieldU64(sfOwnerNode, *vaultPage); - sleVault->setAccountID(sfAccount, a.id()); - ac.view().insert(sleVault); - }; - insertVault(a1); - insertVault(a2); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_CREATE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); - - doInvariantCheck( - {"deleted vault must also delete shares", - "deleted Vault without deleting its pseudo-account"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - auto sleVault = ac.view().peek(keylet); - if (!sleVault) - return false; - ac.view().erase(sleVault); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_DELETE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - Vault const vault{env}; - auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); - env(tx); - return true; - }); - - doInvariantCheck( - {"deleted vault must have no shares outstanding", - "deleted vault must have no assets outstanding", - "deleted vault must have no assets available"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - auto sleVault = ac.view().peek(keylet); - if (!sleVault) - return false; - auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID])); - if (!sleShares) - return false; - ac.view().erase(sleVault); - ac.view().erase(sleShares); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_DELETE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - Vault const vault{env}; - auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()}); - env(tx); - env(vault.deposit({.depositor = a1, .id = keylet.key, .amount = XRP(10)})); - return true; - }); - - doInvariantCheck( - {"vault operation succeeded without modifying a vault"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - auto sleVault = ac.view().peek(keylet); - if (!sleVault) - return false; - auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID])); - if (!sleShares) - return false; - // Note, such an "orphaned" update of MPT issuance attached to a - // vault is invalid; ttVAULT_SET must also update Vault object. - sleShares->setFieldH256(sfDomainID, uint256(13)); - ac.view().update(sleShares); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - doInvariantCheck( - {"vault operation succeeded without modifying a vault"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { return true; }, - XRPAmount{}, - STTx{ttVAULT_CREATE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - Vault const vault{env}; - auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); - env(tx); - return true; - }); - - doInvariantCheck( - {"vault operation succeeded without modifying a vault"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { return true; }, - XRPAmount{}, - STTx{ttVAULT_DEPOSIT, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - Vault const vault{env}; - auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); - env(tx); - return true; - }); - - doInvariantCheck( - {"vault operation succeeded without modifying a vault"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { return true; }, - XRPAmount{}, - STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - Vault const vault{env}; - auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); - env(tx); - return true; - }); - - doInvariantCheck( - {"vault operation succeeded without modifying a vault"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { return true; }, - XRPAmount{}, - STTx{ttVAULT_CLAWBACK, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - Vault const vault{env}; - auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); - env(tx); - return true; - }); - - doInvariantCheck( - {"vault operation succeeded without modifying a vault"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { return true; }, - XRPAmount{}, - STTx{ttVAULT_DELETE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - Vault const vault{env}; - auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); - env(tx); - return true; - }); - - doInvariantCheck( - {"updated vault must have shares"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - auto sleVault = ac.view().peek(keylet); - if (!sleVault) - return false; - (*sleVault)[sfAssetsMaximum] = 200; - ac.view().update(sleVault); - - auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID])); - if (!sleShares) - return false; - ac.view().erase(sleShares); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_SET, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - Vault const vault{env}; - auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); - env(tx); - return true; - }); - - doInvariantCheck( - {"vault operation succeeded without updating shares", - "assets available must not be greater than assets outstanding"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - auto sleVault = ac.view().peek(keylet); - if (!sleVault) - return false; - (*sleVault)[sfAssetsTotal] = 9; - ac.view().update(sleVault); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - Vault const vault{env}; - auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()}); - env(tx); - env(vault.deposit({.depositor = a1, .id = keylet.key, .amount = XRP(10)})); - return true; - }); - - doInvariantCheck( - {"set must not change assets outstanding", - "set must not change assets available", - "set must not change shares outstanding", - "set must not change vault balance", - "assets available must not be negative", - "assets available must not be greater than assets outstanding", - "assets outstanding must not be negative"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - auto sleVault = ac.view().peek(keylet); - if (!sleVault) - return false; - auto slePseudoAccount = ac.view().peek(keylet::account(*(*sleVault)[sfAccount])); - if (!slePseudoAccount) - return false; - (*slePseudoAccount)[sfBalance] = *(*slePseudoAccount)[sfBalance] - 10; - ac.view().update(slePseudoAccount); - - // Move 10 drops to A4 to enforce total XRP balance - auto sleA4 = ac.view().peek(keylet::account(a4.id())); - if (!sleA4) - return false; - (*sleA4)[sfBalance] = *(*sleA4)[sfBalance] + 10; - ac.view().update(sleA4); - - return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) { - sample.assetsAvailable = (kDropsPerXrp * -100).value(); - sample.assetsTotal = (kDropsPerXrp * -200).value(); - sample.sharesTotal = -1; - })); - }, - XRPAmount{}, - STTx{ttVAULT_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - doInvariantCheck( - {"violation of vault immutable data"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - auto sleVault = ac.view().peek(keylet); - if (!sleVault) - return false; - sleVault->setFieldIssue(sfAsset, STIssue{sfAsset, MPTIssue(MPTID(42))}); - ac.view().update(sleVault); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - precloseXrp); - - doInvariantCheck( - {"violation of vault immutable data"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - auto sleVault = ac.view().peek(keylet); - if (!sleVault) - return false; - sleVault->setAccountID(sfAccount, a2.id()); - ac.view().update(sleVault); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - precloseXrp); - - doInvariantCheck( - {"violation of vault immutable data"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - auto sleVault = ac.view().peek(keylet); - if (!sleVault) - return false; - (*sleVault)[sfShareMPTID] = MPTID(42); - ac.view().update(sleVault); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - precloseXrp); - - doInvariantCheck( - {"vault transaction must not change loss unrealized", - "set must not change assets outstanding"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) { - sample.lossUnrealized = 13; - sample.assetsTotal = 20; - })); - }, - XRPAmount{}, - STTx{ttVAULT_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - doInvariantCheck( - {"loss unrealized must not exceed the difference " - "between assets outstanding and available", - "vault transaction must not change loss unrealized"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - return kAdjust(ac.view(), keylet, kArgs(a2.id(), 100, [&](Adjustments& sample) { - sample.lossUnrealized = 13; - })); - }, - XRPAmount{}, - STTx{ - ttVAULT_DEPOSIT, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(200)); }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - // A negative loss unrealized must trip the invariant. ttLOAN_MANAGE is - // allowed to change loss unrealized, so it isolates this check from the - // "must not change loss unrealized" invariant. Gated behind - // fixCleanup3_4_0 (see below). - doInvariantCheck( - {"loss unrealized must not be negative"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) { - sample.lossUnrealized = -1; - })); - }, - XRPAmount{}, - STTx{ttLOAN_MANAGE, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - // Without fixCleanup3_4_0 the same state must NOT trip the invariant, - // preserving pre-amendment behavior (no fork risk). - doInvariantCheck( - makeEnv(defaultAmendments() - fixCleanup3_4_0), - {}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) { - sample.lossUnrealized = -1; - })); - }, - XRPAmount{}, - STTx{ttLOAN_MANAGE, [](STObject& tx) {}}, - {tesSUCCESS, tesSUCCESS}, - precloseXrp, - TxAccount::A2); - - doInvariantCheck( - {"set assets outstanding must not exceed assets maximum"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) { - sample.assetsMaximum = 1; - })); - }, - XRPAmount{}, - STTx{ttVAULT_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - doInvariantCheck( - {"assets maximum must not be negative"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) { - sample.assetsMaximum = -1; - })); - }, - XRPAmount{}, - STTx{ttVAULT_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - doInvariantCheck( - {"set must not change shares outstanding", - "updated zero sized vault must have no assets outstanding", - "updated zero sized vault must have no assets available"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - auto sleVault = ac.view().peek(keylet); - if (!sleVault) - return false; - ac.view().update(sleVault); - auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID])); - if (!sleShares) - return false; - (*sleShares)[sfOutstandingAmount] = 0; - ac.view().update(sleShares); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_SET, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - doInvariantCheck( - {"updated shares must not exceed maximum"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - auto sleVault = ac.view().peek(keylet); - if (!sleVault) - return false; - auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID])); - if (!sleShares) - return false; - (*sleShares)[sfMaximumAmount] = 10; - ac.view().update(sleShares); - - return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [](Adjustments&) {})); - }, - XRPAmount{}, - STTx{ttVAULT_DEPOSIT, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - doInvariantCheck( - {"updated shares must not exceed maximum"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [](Adjustments&) {})); - - auto sleVault = ac.view().peek(keylet); - if (!sleVault) - return false; - auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID])); - if (!sleShares) - return false; - (*sleShares)[sfOutstandingAmount] = kMaxMpTokenAmount + 1; - ac.view().update(sleShares); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_DEPOSIT, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - testcase << "Vault create"; - doInvariantCheck( - { - "created vault must be empty", - "updated zero sized vault must have no assets outstanding", - "create operation must not have updated a vault", - }, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - auto sleVault = ac.view().peek(keylet); - if (!sleVault) - return false; - (*sleVault)[sfAssetsTotal] = 9; - ac.view().update(sleVault); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_CREATE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - Vault const vault{env}; - auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()}); - env(tx); - return true; - }); - - doInvariantCheck( - { - "created vault must be empty", - "updated zero sized vault must have no assets available", - "assets available must not be greater than assets outstanding", - "create operation must not have updated a vault", - }, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - auto sleVault = ac.view().peek(keylet); - if (!sleVault) - return false; - (*sleVault)[sfAssetsAvailable] = 9; - ac.view().update(sleVault); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_CREATE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - Vault const vault{env}; - auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()}); - env(tx); - return true; - }); - - doInvariantCheck( - { - "created vault must be empty", - "loss unrealized must not exceed the difference between assets " - "outstanding and available", - "vault transaction must not change loss unrealized", - "create operation must not have updated a vault", - }, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - auto sleVault = ac.view().peek(keylet); - if (!sleVault) - return false; - (*sleVault)[sfLossUnrealized] = 1; - ac.view().update(sleVault); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_CREATE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - Vault const vault{env}; - auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()}); - env(tx); - return true; - }); - - doInvariantCheck( - { - "created vault must be empty", - "create operation must not have updated a vault", - "invalid OutstandingAmount balance 0 9 0", - }, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - auto sleVault = ac.view().peek(keylet); - if (!sleVault) - return false; - auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID])); - if (!sleShares) - return false; - ac.view().update(sleVault); - (*sleShares)[sfOutstandingAmount] = 9; - ac.view().update(sleShares); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_CREATE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - Vault const vault{env}; - auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()}); - env(tx); - return true; - }); - - doInvariantCheck( - { - "assets maximum must not be negative", - "create operation must not have updated a vault", - }, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - auto sleVault = ac.view().peek(keylet); - if (!sleVault) - return false; - (*sleVault)[sfAssetsMaximum] = Number(-1); - ac.view().update(sleVault); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_CREATE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - Vault const vault{env}; - auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()}); - env(tx); - return true; - }); - - doInvariantCheck( - {"create operation must not have updated a vault", - "shares issuer and vault pseudo-account must be the same", - "shares issuer must be a pseudo-account", - "shares issuer pseudo-account must point back to the vault"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - auto sleVault = ac.view().peek(keylet); - if (!sleVault) - return false; - auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID])); - if (!sleShares) - return false; - ac.view().update(sleVault); - (*sleShares)[sfIssuer] = a1.id(); - ac.view().update(sleShares); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_CREATE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - Vault const vault{env}; - auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()}); - env(tx); - return true; - }); - - doInvariantCheck( - {"vault created by a wrong transaction type", "account root created illegally"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - // The code below will create a valid vault with (almost) all - // the invariants holding. Except one: it is created by the - // wrong transaction type. - auto const sequence = ac.view().seq(); - auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(sequence)); - auto sleVault = std::make_shared(vaultKeylet); - auto const vaultPage = ac.view().dirInsert( - keylet::ownerDir(a1.id()), sleVault->key(), describeOwnerDir(a1.id())); - sleVault->setFieldU64(sfOwnerNode, *vaultPage); - - auto pseudoId = pseudoAccountAddress(ac.view(), vaultKeylet.key); - // Create pseudo-account. - auto sleAccount = std::make_shared(keylet::account(pseudoId)); - sleAccount->setAccountID(sfAccount, pseudoId); - sleAccount->setFieldAmount(sfBalance, STAmount{}); - std::uint32_t const seqno = // - ac.view().rules().enabled(featureSingleAssetVault) // - ? 0 // - : sequence; - sleAccount->setFieldU32(sfSequence, seqno); - sleAccount->setFieldU32( - sfFlags, lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth); - sleAccount->setFieldH256(sfVaultID, vaultKeylet.key); - ac.view().insert(sleAccount); - - auto const sharesMptId = makeMptID(sequence, pseudoId); - auto const sharesKeylet = keylet::mptokenIssuance(sharesMptId); - auto sleShares = std::make_shared(sharesKeylet); - auto const sharesPage = ac.view().dirInsert( - keylet::ownerDir(pseudoId), sharesKeylet, describeOwnerDir(pseudoId)); - sleShares->setFieldU64(sfOwnerNode, *sharesPage); - - sleShares->at(sfFlags) = 0; - sleShares->at(sfIssuer) = pseudoId; - sleShares->at(sfOutstandingAmount) = 0; - sleShares->at(sfSequence) = sequence; - - sleVault->at(sfAccount) = pseudoId; - sleVault->at(sfFlags) = 0; - sleVault->at(sfSequence) = sequence; - sleVault->at(sfOwner) = a1.id(); - sleVault->at(sfAssetsTotal) = Number(0); - sleVault->at(sfAssetsAvailable) = Number(0); - sleVault->at(sfLossUnrealized) = Number(0); - sleVault->at(sfShareMPTID) = sharesMptId; - sleVault->at(sfWithdrawalPolicy) = kVaultStrategyFirstComeFirstServe; - - ac.view().insert(sleVault); - ac.view().insert(sleShares); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_SET, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); - - doInvariantCheck( - {"shares issuer and vault pseudo-account must be the same", - "shares issuer pseudo-account must point back to the vault"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const sequence = ac.view().seq(); - auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(sequence)); - auto sleVault = std::make_shared(vaultKeylet); - auto const vaultPage = ac.view().dirInsert( - keylet::ownerDir(a1.id()), sleVault->key(), describeOwnerDir(a1.id())); - sleVault->setFieldU64(sfOwnerNode, *vaultPage); - - auto pseudoId = pseudoAccountAddress(ac.view(), vaultKeylet.key); - // Create pseudo-account. - auto sleAccount = std::make_shared(keylet::account(pseudoId)); - sleAccount->setAccountID(sfAccount, pseudoId); - sleAccount->setFieldAmount(sfBalance, STAmount{}); - std::uint32_t const seqno = // - ac.view().rules().enabled(featureSingleAssetVault) // - ? 0 // - : sequence; - sleAccount->setFieldU32(sfSequence, seqno); - sleAccount->setFieldU32( - sfFlags, lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth); - // sleAccount->setFieldH256(sfVaultID, vaultKeylet.key); - // Setting wrong vault key - sleAccount->setFieldH256(sfVaultID, uint256(42)); - ac.view().insert(sleAccount); - - auto const sharesMptId = makeMptID(sequence, pseudoId); - auto const sharesKeylet = keylet::mptokenIssuance(sharesMptId); - auto sleShares = std::make_shared(sharesKeylet); - auto const sharesPage = ac.view().dirInsert( - keylet::ownerDir(pseudoId), sharesKeylet, describeOwnerDir(pseudoId)); - sleShares->setFieldU64(sfOwnerNode, *sharesPage); - - sleShares->at(sfFlags) = 0; - sleShares->at(sfIssuer) = pseudoId; - sleShares->at(sfOutstandingAmount) = 0; - sleShares->at(sfSequence) = sequence; - - // sleVault->at(sfAccount) = pseudoId; - // Setting wrong pseudo account ID - sleVault->at(sfAccount) = a2.id(); - sleVault->at(sfFlags) = 0; - sleVault->at(sfSequence) = sequence; - sleVault->at(sfOwner) = a1.id(); - sleVault->at(sfAssetsTotal) = Number(0); - sleVault->at(sfAssetsAvailable) = Number(0); - sleVault->at(sfLossUnrealized) = Number(0); - sleVault->at(sfShareMPTID) = sharesMptId; - sleVault->at(sfWithdrawalPolicy) = kVaultStrategyFirstComeFirstServe; - - ac.view().insert(sleVault); - ac.view().insert(sleShares); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_CREATE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); - - doInvariantCheck( - {"shares issuer and vault pseudo-account must be the same", "shares issuer must exist"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const sequence = ac.view().seq(); - auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(sequence)); - auto sleVault = std::make_shared(vaultKeylet); - auto const vaultPage = ac.view().dirInsert( - keylet::ownerDir(a1.id()), sleVault->key(), describeOwnerDir(a1.id())); - sleVault->setFieldU64(sfOwnerNode, *vaultPage); - - auto const sharesMptId = makeMptID(sequence, a2.id()); - auto const sharesKeylet = keylet::mptokenIssuance(sharesMptId); - auto sleShares = std::make_shared(sharesKeylet); - auto const sharesPage = ac.view().dirInsert( - keylet::ownerDir(a2.id()), sharesKeylet, describeOwnerDir(a2.id())); - sleShares->setFieldU64(sfOwnerNode, *sharesPage); - - sleShares->at(sfFlags) = 0; - // Setting wrong pseudo account ID - sleShares->at(sfIssuer) = AccountID(42); - sleShares->at(sfOutstandingAmount) = 0; - sleShares->at(sfSequence) = sequence; - - sleVault->at(sfAccount) = a2.id(); - sleVault->at(sfFlags) = 0; - sleVault->at(sfSequence) = sequence; - sleVault->at(sfOwner) = a1.id(); - sleVault->at(sfAssetsTotal) = Number(0); - sleVault->at(sfAssetsAvailable) = Number(0); - sleVault->at(sfLossUnrealized) = Number(0); - sleVault->at(sfShareMPTID) = sharesMptId; - sleVault->at(sfWithdrawalPolicy) = kVaultStrategyFirstComeFirstServe; - - ac.view().insert(sleVault); - ac.view().insert(sleShares); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_CREATE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); - - testcase << "Vault deposit"; - doInvariantCheck( - {"deposit must change vault balance"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [](Adjustments& sample) { - sample.vaultAssets.reset(); - })); - }, - XRPAmount{}, - STTx{ttVAULT_DEPOSIT, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - precloseXrp); - - doInvariantCheck( - {"deposit assets outstanding must not exceed assets maximum"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - return kAdjust(ac.view(), keylet, kArgs(a2.id(), 200, [&](Adjustments& sample) { - sample.assetsMaximum = 1; - })); - }, - XRPAmount{}, - STTx{ - ttVAULT_DEPOSIT, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(200)); }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - // This really convoluted unit tests makes the zero balance on the - // depositor, by sending them the same amount as the transaction fee. - // The operation makes no sense, but the defensive check in - // ValidVault::finalize is otherwise impossible to trigger. - doInvariantCheck( - {"deposit must increase vault balance", "deposit must change depositor balance"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - - // Move 10 drops to A4 to enforce total XRP balance - auto sleA4 = ac.view().peek(keylet::account(a4.id())); - if (!sleA4) - return false; - (*sleA4)[sfBalance] = *(*sleA4)[sfBalance] + 10; - ac.view().update(sleA4); - - return kAdjust(ac.view(), keylet, kArgs(a3.id(), -10, [&](Adjustments& sample) { - sample.accountAssets->amount = -100; - })); - }, - XRPAmount{100}, - STTx{ - ttVAULT_DEPOSIT, - [&](STObject& tx) { - tx[sfFee] = XRPAmount(100); - tx[sfAccount] = a3.id(); - }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseXrp); - - doInvariantCheck( - {"deposit must increase vault balance", - "deposit must decrease depositor balance", - "deposit must change vault and depositor balance by equal amount", - "deposit and assets outstanding must add up", - "deposit and assets available must add up"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - - // Move 10 drops from A2 to A3 to enforce total XRP balance - auto sleA3 = ac.view().peek(keylet::account(a3.id())); - if (!sleA3) - return false; - (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] + 10; - ac.view().update(sleA3); - - return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) { - sample.vaultAssets = -20; - sample.accountAssets->amount = 10; - })); - }, - XRPAmount{}, - STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - doInvariantCheck( - {"deposit must change depositor balance"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - - // Move 10 drops from A3 to vault to enforce total XRP balance - auto sleA3 = ac.view().peek(keylet::account(a3.id())); - if (!sleA3) - return false; - (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] - 10; - ac.view().update(sleA3); - - return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) { - sample.accountAssets->amount = 0; - })); - }, - XRPAmount{}, - STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - doInvariantCheck( - {"deposit must change depositor shares"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) { - sample.accountShares.reset(); - })); - }, - XRPAmount{}, - STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - doInvariantCheck( - {"deposit must change vault shares"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - - return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [](Adjustments& sample) { - sample.sharesTotal = 0; - })); - }, - XRPAmount{}, - STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - doInvariantCheck( - {"deposit must increase depositor shares", - "deposit must change depositor and vault shares by equal amount", - "deposit must not change vault balance by more than deposited " - "amount"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) { - sample.accountShares->amount = -5; - sample.sharesTotal = -10; - })); - }, - XRPAmount{}, - STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(5); }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - doInvariantCheck( - {"deposit and assets outstanding must add up"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto sleA3 = ac.view().peek(keylet::account(a3.id())); - (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] - 2000; - ac.view().update(sleA3); - - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) { - sample.assetsTotal = 11; - })); - }, - XRPAmount{2000}, - STTx{ - ttVAULT_DEPOSIT, - [&](STObject& tx) { - tx[sfAmount] = XRPAmount(10); - tx[sfDelegate] = a3.id(); - tx[sfFee] = XRPAmount(2000); - }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - doInvariantCheck( - {"deposit and assets outstanding must add up", - "deposit and assets available must add up"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) { - sample.assetsTotal = 7; - sample.assetsAvailable = 7; - })); - }, - XRPAmount{}, - STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - testcase << "Vault withdrawal"; - doInvariantCheck( - {"withdrawal must change vault balance"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [](Adjustments& sample) { - sample.vaultAssets.reset(); - })); - }, - XRPAmount{}, - STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - precloseXrp); - - // Almost identical to the really convoluted test for deposit, where the - // depositor spends only the transaction fee. In case of withdrawal, - // this test is almost the same as normal withdrawal where the - // sfDestination would have been A4, but has been omitted. - doInvariantCheck( - {"withdrawal must change one destination balance"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - - // Move 10 drops to A4 to enforce total XRP balance - auto sleA4 = ac.view().peek(keylet::account(a4.id())); - if (!sleA4) - return false; - (*sleA4)[sfBalance] = *(*sleA4)[sfBalance] + 10; - ac.view().update(sleA4); - - return kAdjust(ac.view(), keylet, kArgs(a3.id(), -10, [&](Adjustments& sample) { - sample.accountAssets->amount = -100; - })); - }, - XRPAmount{100}, - STTx{ - ttVAULT_WITHDRAW, - [&](STObject& tx) { - tx[sfFee] = XRPAmount(100); - tx[sfAccount] = a3.id(); - // This commented out line causes the invariant violation. - // tx[sfDestination] = A4.id(); - }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseXrp); - - doInvariantCheck( - { - "withdrawal must change vault and destination balance by equal amount", - "withdrawal must decrease vault balance", - "withdrawal must increase destination balance", - "withdrawal and assets outstanding must add up", - "withdrawal and assets available must add up", - }, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - - // Move 10 drops from A2 to A3 to enforce total XRP balance - auto sleA3 = ac.view().peek(keylet::account(a3.id())); - if (!sleA3) - return false; - (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] + 10; - ac.view().update(sleA3); - - return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) { - sample.vaultAssets = 10; - sample.accountAssets->amount = -20; - })); - }, - XRPAmount{}, - STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - doInvariantCheck( - {"withdrawal must change one destination balance"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - if (!kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) { - *sample.vaultAssets -= 5; - }))) - return false; - auto sleA3 = ac.view().peek(keylet::account(a3.id())); - if (!sleA3) - return false; - (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] + 5; - ac.view().update(sleA3); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_WITHDRAW, [&](STObject& tx) { tx.setAccountID(sfDestination, a3.id()); }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - doInvariantCheck( - {"withdrawal must change depositor shares"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) { - sample.accountShares.reset(); - })); - }, - XRPAmount{}, - STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - doInvariantCheck( - {"withdrawal must change vault shares"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [](Adjustments& sample) { - sample.sharesTotal = 0; - })); - }, - XRPAmount{}, - STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - doInvariantCheck( - {"withdrawal must decrease depositor shares", - "withdrawal must change depositor and vault shares by equal " - "amount"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) { - sample.accountShares->amount = 5; - sample.sharesTotal = 10; - })); - }, - XRPAmount{}, - STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - doInvariantCheck( - {"withdrawal and assets outstanding must add up", - "withdrawal and assets available must add up"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) { - sample.assetsTotal = -15; - sample.assetsAvailable = -15; - })); - }, - XRPAmount{}, - STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - doInvariantCheck( - {"withdrawal and assets outstanding must add up"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto sleA3 = ac.view().peek(keylet::account(a3.id())); - (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] - 2000; - ac.view().update(sleA3); - - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) { - sample.assetsTotal = -7; - })); - }, - XRPAmount{2000}, - STTx{ - ttVAULT_WITHDRAW, - [&](STObject& tx) { - tx[sfAmount] = XRPAmount(10); - tx[sfDelegate] = a3.id(); - tx[sfFee] = XRPAmount(2000); - }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseXrp, - TxAccount::A2); - - auto const precloseMpt = [&](Account const& a1, Account const& a2, Env& env) -> bool { - env.fund(XRP(1000), a3, a4); - - // Create MPT asset - { - json::Value jv; - jv[sfAccount] = a3.human(); - jv[sfTransactionType] = jss::MPTokenIssuanceCreate; - jv[sfFlags] = tfMPTCanTransfer; - env(jv); - env.close(); - } - - auto const mptID = makeMptID(env.seq(a3) - 1, a3); - Asset const asset = MPTIssue(mptID); - // Authorize A1 A2 A4 - { - json::Value jv; - jv[sfAccount] = a1.human(); - jv[sfTransactionType] = jss::MPTokenAuthorize; - jv[sfMPTokenIssuanceID] = to_string(mptID); - env(jv); - jv[sfAccount] = a2.human(); - env(jv); - jv[sfAccount] = a4.human(); - env(jv); - - env.close(); - } - // Send tokens to A1 A2 A4 - { - env(pay(a3, a1, asset(1000))); - env(pay(a3, a2, asset(1000))); - env(pay(a3, a4, asset(1000))); - env.close(); - } - - Vault const vault{env}; - auto [tx, keylet] = vault.create({.owner = a1, .asset = asset}); - env(tx); - env(vault.deposit({.depositor = a1, .id = keylet.key, .amount = asset(10)})); - env(vault.deposit({.depositor = a2, .id = keylet.key, .amount = asset(10)})); - env(vault.deposit({.depositor = a4, .id = keylet.key, .amount = asset(10)})); - return true; - }; - - doInvariantCheck( - {"withdrawal must decrease depositor shares", - "withdrawal must change depositor and vault shares by equal " - "amount"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = - keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2)); - return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) { - sample.accountShares->amount = 5; - })); - }, - XRPAmount{}, - STTx{ttVAULT_WITHDRAW, [&](STObject& tx) { tx[sfAccount] = a3.id(); }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseMpt, - TxAccount::A2); - - testcase << "Vault clawback"; - doInvariantCheck( - {"clawback must change vault balance"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = - keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2)); - return kAdjust(ac.view(), keylet, kArgs(a2.id(), -1, [&](Adjustments& sample) { - sample.vaultAssets.reset(); - })); - }, - XRPAmount{}, - STTx{ttVAULT_CLAWBACK, [&](STObject& tx) { tx[sfAccount] = a3.id(); }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseMpt); - - // Not the same as below check: attempt to clawback XRP - doInvariantCheck( - {"clawback may only be performed by the asset issuer"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); - return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) {})); - }, - XRPAmount{}, - STTx{ttVAULT_CLAWBACK, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - precloseXrp); - - // Not the same as above check: attempt to clawback MPT by bad account - doInvariantCheck( - {"clawback may only be performed by the asset issuer"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = - keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2)); - return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) {})); - }, - XRPAmount{}, - STTx{ttVAULT_CLAWBACK, [&](STObject& tx) { tx[sfAccount] = a4.id(); }}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - precloseMpt); - - doInvariantCheck( - {"clawback must decrease vault balance", - "clawback must decrease holder shares", - "clawback must change vault shares"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = - keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2)); - return kAdjust(ac.view(), keylet, kArgs(a4.id(), 10, [&](Adjustments& sample) { - sample.sharesTotal = 0; - })); - }, - XRPAmount{}, - STTx{ - ttVAULT_CLAWBACK, - [&](STObject& tx) { - tx[sfAccount] = a3.id(); - tx[sfHolder] = a4.id(); - }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseMpt); - - doInvariantCheck( - {"clawback must change holder shares"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = - keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2)); - return kAdjust(ac.view(), keylet, kArgs(a4.id(), -10, [&](Adjustments& sample) { - sample.accountShares.reset(); - })); - }, - XRPAmount{}, - STTx{ - ttVAULT_CLAWBACK, - [&](STObject& tx) { - tx[sfAccount] = a3.id(); - tx[sfHolder] = a4.id(); - }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseMpt); - - doInvariantCheck( - {"clawback must change holder and vault shares by equal amount", - "clawback and assets outstanding must add up", - "clawback and assets available must add up"}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const keylet = - keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2)); - return kAdjust(ac.view(), keylet, kArgs(a4.id(), -10, [&](Adjustments& sample) { - sample.accountShares->amount = -8; - sample.assetsTotal = -7; - sample.assetsAvailable = -7; - })); - }, - XRPAmount{}, - STTx{ - ttVAULT_CLAWBACK, - [&](STObject& tx) { - tx[sfAccount] = a3.id(); - tx[sfHolder] = a4.id(); - }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseMpt); - - // ───────────────────────────────────────────────────────────── - // Closed-ended vault invariants added in ValidVault::finalize (create must supply both - // dates and satisfy the redemption-buffer gap), deposit only in Subscription / NoPhase, - // withdraw not in Investment, loan origination only in Investment. - - using d = NetClock::duration; - using tp = NetClock::time_point; - - auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded); - - // Vault keylet captured by precloseClosedEnded so precheck does not have to rederive it - // from ac.view().seq(), which depends on how many env.close() calls preclose issued. - Keylet closedEndedKeylet = keylet::amendments(); - - // Preclose that creates a closed-ended vault (in Subscription), optionally seeds it with - // three deposits (so a1/a2/a3 hold a share MPToken that kAdjust can then adjust), and - // optionally advances parent close time past SubscriptionDate. A negative @p advanceBySub - // leaves the vault in Subscription. - auto const precloseClosedEnded = [&](std::int32_t advanceBySub, bool doDeposit) { - return [&, advanceBySub, doDeposit]( - Account const& a1, Account const& a2, Env& env) -> bool { - env.fund(XRP(1000), a3, a4); - auto const sub = env.now().time_since_epoch().count() + 60; - auto const red = sub + kMinInvestmentPeriod + 1'000'000; - Vault const vault{env}; - auto [tx, keylet] = vault.create( - {.owner = a1, - .asset = xrpIssue(), - .vaultKind = closedEnded, - .subscriptionDate = sub, - .redemptionDate = red}); - env(tx); - closedEndedKeylet = keylet; - if (doDeposit) - { - env(vault.deposit({.depositor = a1, .id = keylet.key, .amount = XRP(10)})); - env(vault.deposit({.depositor = a2, .id = keylet.key, .amount = XRP(10)})); - env(vault.deposit({.depositor = a3, .id = keylet.key, .amount = XRP(10)})); - } - if (advanceBySub >= 0) - env.close(tp{d{sub + advanceBySub}}); - return true; - }; - }; - - // Manually insert a bare closed-ended vault (+ pseudo-account + share MPTokenIssuance) - // directly into the view, bypassing the transactor path. Used to synthesize ttVAULT_CREATE - // states no legitimate transactor would produce. - auto const insertBareClosedEndedVault = - [closedEnded]( - ApplyContext& ac, - Account const& owner, - std::optional subscriptionDate, - std::optional redemptionDate) -> bool { - auto const sequence = ac.view().seq(); - auto const vaultKeylet = keylet::vault(owner.id(), SeqProxy::rawSequence(sequence)); - auto sleVault = std::make_shared(vaultKeylet); - auto const vaultPage = ac.view().dirInsert( - keylet::ownerDir(owner.id()), sleVault->key(), describeOwnerDir(owner.id())); - if (!vaultPage) - return false; - sleVault->setFieldU64(sfOwnerNode, *vaultPage); - - auto const pseudoId = pseudoAccountAddress(ac.view(), vaultKeylet.key); - auto sleAccount = std::make_shared(keylet::account(pseudoId)); - sleAccount->setAccountID(sfAccount, pseudoId); - sleAccount->setFieldAmount(sfBalance, STAmount{}); - sleAccount->setFieldU32(sfSequence, 0); - sleAccount->setFieldU32(sfFlags, lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth); - sleAccount->setFieldH256(sfVaultID, vaultKeylet.key); - ac.view().insert(sleAccount); - - auto const sharesMptId = makeMptID(sequence, pseudoId); - auto const sharesKeylet = keylet::mptokenIssuance(sharesMptId); - auto sleShares = std::make_shared(sharesKeylet); - auto const sharesPage = ac.view().dirInsert( - keylet::ownerDir(pseudoId), sharesKeylet, describeOwnerDir(pseudoId)); - if (!sharesPage) - return false; - sleShares->setFieldU64(sfOwnerNode, *sharesPage); - sleShares->at(sfFlags) = 0; - sleShares->at(sfIssuer) = pseudoId; - sleShares->at(sfOutstandingAmount) = 0; - sleShares->at(sfSequence) = sequence; - - sleVault->at(sfAccount) = pseudoId; - sleVault->at(sfFlags) = 0; - sleVault->at(sfSequence) = sequence; - sleVault->at(sfOwner) = owner.id(); - sleVault->setFieldIssue(sfAsset, STIssue{sfAsset, Asset{xrpIssue()}}); - sleVault->at(sfAssetsTotal) = Number(0); - sleVault->at(sfAssetsAvailable) = Number(0); - sleVault->at(sfLossUnrealized) = Number(0); - sleVault->at(sfShareMPTID) = sharesMptId; - sleVault->at(sfWithdrawalPolicy) = kVaultStrategyFirstComeFirstServe; - sleVault->at(sfVaultKind) = closedEnded; - if (subscriptionDate) - sleVault->at(sfSubscriptionDate) = *subscriptionDate; - if (redemptionDate) - sleVault->at(sfRedemptionDate) = *redemptionDate; - - ac.view().insert(sleVault); - ac.view().insert(sleShares); - return true; - }; - - testcase << "Vault create closed-ended"; - - // A fresh closed-ended vault must carry both SubscriptionDate and RedemptionDate. - doInvariantCheck( - {"closed-ended vault must have SubscriptionDate and RedemptionDate"}, - [&](Account const& a1, Account const&, ApplyContext& ac) { - return insertBareClosedEndedVault(ac, a1, std::nullopt, std::nullopt); - }, - XRPAmount{}, - STTx{ttVAULT_CREATE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); - - // Gap smaller than MIN_INVESTMENT_PERIOD but with RedemptionDate > SubscriptionDate; - // exercises the sub-minimum branch of the gap check. - doInvariantCheck( - {"closed-ended vault RedemptionDate - SubscriptionDate must be " - "within [MIN_INVESTMENT_PERIOD, MAX_INVESTMENT_PERIOD)"}, - [&](Account const& a1, Account const&, ApplyContext& ac) { - std::uint32_t const sub = 1'000'000'000; - std::uint32_t const red = sub + kMinInvestmentPeriod - 1; - return insertBareClosedEndedVault(ac, a1, sub, red); - }, - XRPAmount{}, - STTx{ttVAULT_CREATE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); - - // RedemptionDate strictly before SubscriptionDate; the signed int64 gap is negative and - // is caught by the sub-minimum branch of the gap check. - doInvariantCheck( - {"closed-ended vault RedemptionDate - SubscriptionDate must be " - "within [MIN_INVESTMENT_PERIOD, MAX_INVESTMENT_PERIOD)"}, - [&](Account const& a1, Account const&, ApplyContext& ac) { - std::uint32_t const sub = 1'000'000'000; - std::uint32_t const red = sub - 1; - return insertBareClosedEndedVault(ac, a1, sub, red); - }, - XRPAmount{}, - STTx{ttVAULT_CREATE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); - - // Gap exactly MAX_INVESTMENT_PERIOD is out of range (bound is half-open on the right). - doInvariantCheck( - {"closed-ended vault RedemptionDate - SubscriptionDate must be " - "within [MIN_INVESTMENT_PERIOD, MAX_INVESTMENT_PERIOD)"}, - [&](Account const& a1, Account const&, ApplyContext& ac) { - std::uint32_t const sub = 1'000'000'000; - std::uint32_t const red = sub + kMaxInvestmentPeriod; - return insertBareClosedEndedVault(ac, a1, sub, red); - }, - XRPAmount{}, - STTx{ttVAULT_CREATE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); - - testcase << "Vault deposit closed-ended"; - - // A deposit into a closed-ended vault that has advanced past SubscriptionDate. kArgs - // simulates an otherwise valid deposit shape so only the phase invariant fires. - doInvariantCheck( - {"deposit only allowed in Subscription or NoPhase"}, - [&](Account const&, Account const& a2, ApplyContext& ac) { - return kAdjust( - ac.view(), closedEndedKeylet, kArgs(a2.id(), 10, [](Adjustments&) {})); - }, - XRPAmount{}, - STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseClosedEnded(/*advanceBySub=*/1, /*doDeposit=*/true), - TxAccount::A2); - - testcase << "Vault withdrawal closed-ended"; - - // A withdrawal from a closed-ended vault in the Investment phase. - doInvariantCheck( - {"withdrawal not allowed during Investment phase"}, - [&](Account const&, Account const& a2, ApplyContext& ac) { - return kAdjust( - ac.view(), closedEndedKeylet, kArgs(a2.id(), -10, [](Adjustments&) {})); - }, - XRPAmount{}, - STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseClosedEnded(/*advanceBySub=*/1, /*doDeposit=*/true), - TxAccount::A2); - - testcase << "Vault loan set"; - - // ttLOAN_SET against a closed-ended vault that is not in Investment. finalizeLoanSet fires - // on any vault mutation; touching the vault SLE with no field change is sufficient. - doInvariantCheck( - {"loan origination only allowed in Investment phase"}, - [&](Account const&, Account const&, ApplyContext& ac) { - auto sleVault = ac.view().peek(closedEndedKeylet); - if (!sleVault) - return false; - ac.view().update(sleVault); - return true; - }, - XRPAmount{}, - STTx{ttLOAN_SET, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - precloseClosedEnded(/*advanceBySub=*/-1, /*doDeposit=*/false)); - - testcase << "Vault loan set - closed-ended final payment past " - "RedemptionDate"; - - // A newly-created loan against a closed-ended vault must satisfy StartDate + - // PaymentInterval * PaymentRemaining < RedemptionDate. LoanSet::preclaim enforces the same - // bound; this test synthesises an invalid loan directly in the ApplyView so the invariant - // catches it even when preclaim is bypassed. - Keylet closedEndedBrokerKeylet = keylet::amendments(); - std::uint32_t closedEndedRed = 0; - doInvariantCheck( - {"closed-ended loan final payment must precede RedemptionDate"}, - [&](Account const& a1, Account const&, ApplyContext& ac) { - // Touch the vault so ValidVault::finalizeLoanSet sees an - // entry in afterVault_; the vault is in Investment, so - // finalizeLoanSet itself passes. - auto sleVault = ac.view().peek(closedEndedKeylet); - if (!sleVault) - return false; - ac.view().update(sleVault); - - // Read the broker's next loan sequence to build the loan - // keylet the same way LoanSet::doApply would. - auto sleBroker = ac.view().peek(closedEndedBrokerKeylet); - if (!sleBroker) - return false; - std::uint32_t const loanSeq = sleBroker->at(sfLoanSequence); - - // Synthesize a Loan whose final scheduled payment lands - // exactly at RedemptionDate: StartDate = red, interval = 60, - // remaining = 1 => red + 60 >= red. - auto sleLoan = std::make_shared( - keylet::loan(closedEndedBrokerKeylet.key, SeqProxy::rawSequence(loanSeq))); - sleLoan->at(sfLoanBrokerID) = closedEndedBrokerKeylet.key; - sleLoan->at(sfLoanSequence) = loanSeq; - sleLoan->at(sfBorrower) = a1.id(); - sleLoan->at(sfStartDate) = closedEndedRed; - sleLoan->at(sfPaymentInterval) = 60; - sleLoan->at(sfPaymentRemaining) = 1; - sleLoan->at(sfTotalValueOutstanding) = Number(100); - sleLoan->at(sfPeriodicPayment) = Number(1); - ac.view().insert(sleLoan); - return true; - }, - XRPAmount{}, - STTx{ttLOAN_SET, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - [&](Account const& a1, Account const&, Env& env) -> bool { - auto const sub = env.now().time_since_epoch().count() + 60; - auto const red = sub + kMinInvestmentPeriod + 1'000'000; - closedEndedRed = red; - - Vault const vault{env}; - auto [tx, keylet] = vault.create( - {.owner = a1, - .asset = xrpIssue(), - .vaultKind = closedEnded, - .subscriptionDate = sub, - .redemptionDate = red}); - env(tx); - closedEndedKeylet = keylet; - - // Create the loan broker; LoanBrokerSet has no phase gate. - closedEndedBrokerKeylet = - keylet::loanBroker(a1.id(), SeqProxy::rawSequence(env.seq(a1))); - env(loan_broker::set(a1, keylet.key)); - - // Advance parent close time into Investment so - // ValidVault::finalizeLoanSet is satisfied. - env.close(tp{d{sub + 1}}); - return true; - }); - } - - void - testMPT() - { - using namespace test::jtx; - testcase << "MPT"; - - MPTIssue const nonCanonicalMPTIssue{makeMptID(1, AccountID(0x4985601))}; - auto const nonCanonicalMPTAmount = [&](SField const& field) { - return STAmount{ - field, - nonCanonicalMPTIssue, - kMaxMpTokenAmount + std::uint64_t{1}, - 0, - false, - STAmount::Unchecked{}}; - }; - auto const negativeMPTAmount = [&](SField const& field) { - return STAmount{field, nonCanonicalMPTIssue, 2, 0, true, STAmount::Unchecked{}}; - }; - auto const nonCanonicalMPTPayment = [&]() { - return STTx{ttPAYMENT, [&](STObject& tx) { - tx.setFieldAmount(sfAmount, nonCanonicalMPTAmount(sfAmount)); - }}; - }; - - doInvariantCheck( - makeEnv(defaultAmendments() - fixCleanup3_2_0), - {}, - [](Account const&, Account const&, ApplyContext&) { return true; }, - XRPAmount{}, - nonCanonicalMPTPayment(), - {tesSUCCESS, tesSUCCESS}); - - doInvariantCheck( - {{"ledger entry contains non-canonical MPT or XRP amount"}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - - auto sleNew = std::make_shared( - keylet::check(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence]))); - sleNew->setAccountID(sfAccount, a1.id()); - sleNew->setAccountID(sfDestination, a2.id()); - sleNew->setFieldAmount(sfSendMax, nonCanonicalMPTAmount(sfSendMax)); - ac.view().insert(sleNew); - return true; - }); - - doInvariantCheck( - {{"ledger entry contains non-canonical MPT or XRP amount"}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - - auto sleNew = std::make_shared( - keylet::check(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence]))); - sleNew->setAccountID(sfAccount, a1.id()); - sleNew->setAccountID(sfDestination, a2.id()); - sleNew->setFieldAmount(sfSendMax, negativeMPTAmount(sfSendMax)); - ac.view().insert(sleNew); - return true; - }); - - // MPT OutstandingAmount > MaximumAmount - doInvariantCheck( - {{"OutstandingAmount overflow"}}, - [](Account const& a1, Account const&, ApplyContext& ac) { - // mptissuance outstanding is negative - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - - MPTIssue const mpt{makeMptID(sle->getFieldU32(sfSequence), a1)}; - auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID())); - sleNew->setFieldU64(sfOutstandingAmount, 110); - sleNew->setFieldU64(sfMaximumAmount, 100); - ac.view().insert(sleNew); - return true; - }); - - // MPTToken amount doesn't add up to OutstandingAmount - doInvariantCheck( - {{"invalid OutstandingAmount balance"}}, - [](Account const& a1, Account const& a2, ApplyContext& ac) { - // mptissuance outstanding is negative - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - - MPTIssue const mpt{makeMptID(sle->getFieldU32(sfSequence), a1)}; - auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID())); - sleNew->setFieldU64(sfOutstandingAmount, 100); - sleNew->setFieldU64(sfMaximumAmount, 100); - ac.view().insert(sleNew); - - sleNew = std::make_shared(keylet::mptoken(mpt.getMptID(), a2)); - sleNew->setFieldU64(sfMPTAmount, 90); - ac.view().insert(sleNew); - - return true; - }); - - // Overflow/Invalid balance on payment - auto testPayment = [&](std::string const& log, auto&& update) { - MPTID id; - doInvariantCheck( - {{log}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - return update(id, ac, a1); - }, - XRPAmount{}, - STTx{ttPAYMENT, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - Account const gw("gw"); - env.fund(XRP(1'000), gw); - MPTTester const mpt( - {.env = env, .issuer = gw, .holders = {a1}, .pay = 100, .maxAmt = 100}); - id = mpt.issuanceID(); - return true; - }); - }; - testPayment( - "invalid OutstandingAmount balance", - [&](MPTID const& id, ApplyContext& ac, Account const& a1) { - auto sle = ac.view().peek(keylet::mptoken(id, a1)); - if (!sle) - return false; - sle->setFieldU64(sfMPTAmount, 101); - ac.view().update(sle); - return true; - }); - testPayment( - "OutstandingAmount overflow", [&](MPTID const& id, ApplyContext& ac, Account const&) { - auto sle = ac.view().peek(keylet::mptokenIssuance(id)); - if (!sle) - return false; - sle->setFieldU64(sfOutstandingAmount, 101); - ac.view().update(sle); - return true; - }); - - // The on-failure MPT checks (OutstandingAmount balance / transfer) apply - // to every non-tesSUCCESS result, with no per-result exemption: on a tec - // the transactor discards the view and re-applies only offer, trust - // line, NFT offer and credential deletions, so an MPT change reaching - // the invariant is a bug whatever the code. Seeded via initialResult. - { - MPTID id; - // preclose: gw issues an MPT held by A1 and A2. - auto const setup = [&](Account const& a1, Account const& a2, Env& env) { - Account const gw("gw"); - env.fund(XRP(1'000), gw); - MPTTester const mpt( - {.env = env, .issuer = gw, .holders = {a1, a2}, .pay = 50, .maxAmt = 1'000}); - id = mpt.issuanceID(); - return true; - }; - - // Consistent mint: OutstandingAmount and A1's balance both grow by - // 10, so conservation holds and only the on-failure check fires. - Precheck const mint = [&](Account const& a1, Account const&, ApplyContext& ac) { - auto sleIss = ac.view().peek(keylet::mptokenIssuance(id)); - auto sleTok = ac.view().peek(keylet::mptoken(id, a1.id())); - if (!sleIss || !sleTok) - return false; - (*sleIss)[sfOutstandingAmount] = (*sleIss)[sfOutstandingAmount] + 10; - (*sleTok)[sfMPTAmount] = (*sleTok)[sfMPTAmount] + 10; - ac.view().update(sleIss); - ac.view().update(sleTok); - return true; - }; - - // Holder-to-holder transfer (A1 -> A2 by 10). OutstandingAmount is - // unchanged, and CanTransfer keeps the ordinary transfer check - // quiet, so only the on-failure check fires. - Precheck const transfer = [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto sleIss = ac.view().peek(keylet::mptokenIssuance(id)); - auto sleA = ac.view().peek(keylet::mptoken(id, a1.id())); - auto sleB = ac.view().peek(keylet::mptoken(id, a2.id())); - if (!sleIss || !sleA || !sleB) - return false; - (*sleIss)[sfFlags] = (*sleIss)[sfFlags] | lsfMPTCanTransfer; - (*sleA)[sfMPTAmount] = (*sleA)[sfMPTAmount] - 10; - (*sleB)[sfMPTAmount] = (*sleB)[sfMPTAmount] + 10; - ac.view().update(sleIss); - ac.view().update(sleA); - ac.view().update(sleB); - return true; - }; - - STTx const payment{ttPAYMENT, [](STObject&) {}}; - - // Negative controls: nothing fires on tesSUCCESS. Without these, the - // cases below would still pass if the result guard were dropped. - doInvariantCheck({}, mint, XRPAmount{}, payment, {tesSUCCESS, tesSUCCESS}, setup); - doInvariantCheck({}, transfer, XRPAmount{}, payment, {tesSUCCESS, tesSUCCESS}, setup); - - // tecKILLED and tecINCOMPLETE are not special: an MPT change paired - // with either fires, as with any other failure. - doInvariantCheck( - {{"OutstandingAmount balance changed on failure"}}, - mint, - XRPAmount{}, - payment, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - setup, - TxAccount::None, - std::source_location::current(), - tecKILLED); - doInvariantCheck( - {{"OutstandingAmount balance changed on failure"}}, - mint, - XRPAmount{}, - payment, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - setup, - TxAccount::None, - std::source_location::current(), - tecINCOMPLETE); - doInvariantCheck( - {{"MPToken balance changed on failure"}}, - transfer, - XRPAmount{}, - payment, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - setup, - TxAccount::None, - std::source_location::current(), - tecKILLED); - doInvariantCheck( - {{"MPToken balance changed on failure"}}, - transfer, - XRPAmount{}, - payment, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - setup, - TxAccount::None, - std::source_location::current(), - tecINCOMPLETE); - // The same change under a third failure result: the check keys off - // "not tesSUCCESS", nothing finer. - doInvariantCheck( - {{"OutstandingAmount balance changed on failure"}}, - mint, - XRPAmount{}, - payment, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - setup, - TxAccount::None, - std::source_location::current(), - tecEXPIRED); - doInvariantCheck( - {{"MPToken balance changed on failure"}}, - transfer, - XRPAmount{}, - payment, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - setup, - TxAccount::None, - std::source_location::current(), - tecEXPIRED); - - // A lock moves value within one holder, so it is not a two-sided - // transfer and the `senders || receivers` form is what catches it. - // OutstandingAmount and the holder total are unchanged, so the - // balance check stays quiet. - Precheck const lock = [&](Account const& a1, Account const&, ApplyContext& ac) { - auto sleTok = ac.view().peek(keylet::mptoken(id, a1.id())); - if (!sleTok || (*sleTok)[sfMPTAmount] < 10) - return false; - // A fresh MPToken has no locked amount, so set it directly. - (*sleTok)[sfMPTAmount] = (*sleTok)[sfMPTAmount] - 10; - sleTok->setFieldU64(sfLockedAmount, 10); - ac.view().update(sleTok); - return true; - }; - // Negative control: a lock is legitimate on tesSUCCESS. - doInvariantCheck({}, lock, XRPAmount{}, payment, {tesSUCCESS, tesSUCCESS}, setup); - doInvariantCheck( - {{"MPToken balance changed on failure"}}, - lock, - XRPAmount{}, - payment, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - setup, - TxAccount::None, - std::source_location::current(), - tecKILLED); - // The lock is caught under any failure result. - doInvariantCheck( - {{"MPToken balance changed on failure"}}, - lock, - XRPAmount{}, - payment, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - setup, - TxAccount::None, - std::source_location::current(), - tecEXPIRED); - - // A deleted MPToken has no amtAfter, so the sender/receiver counts - // skip it and only the deletedAuthorized_ term can catch it. That - // needs holders authorized but never paid, so the MPToken can be - // erased with a zero balance and OutstandingAmount untouched -- - // otherwise the holder would register as a sender instead. - MPTID emptyId; - auto const setupEmpty = [&](Account const& a1, Account const& a2, Env& env) { - Account const gw("gw"); - env.fund(XRP(1'000), gw); - MPTTester const mpt({.env = env, .issuer = gw, .holders = {a1, a2}, .maxAmt = 100}); - emptyId = mpt.issuanceID(); - return true; - }; - Precheck const eraseToken = [&](Account const& a1, Account const&, ApplyContext& ac) { - auto sleTok = ac.view().peek(keylet::mptoken(emptyId, a1.id())); - if (!sleTok || (*sleTok)[sfMPTAmount] != 0) - return false; - ac.view().erase(sleTok); - return true; - }; - // ValidMPTIssuance also reports the deletion, so assert on - // ValidMPTTransfer's message, which only the new check can produce. - doInvariantCheck( - {{"MPToken deleted on failure"}}, - eraseToken, - XRPAmount{}, - payment, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - setupEmpty, - TxAccount::None, - std::source_location::current(), - tecEXPIRED); - } - - // Invalid IOU clawback delta must fail once MPTokensV2 enforces before/after validation. - { - Env env(*this, defaultAmendments()); - Account const issuer{"issuer"}; - Account const holder{"holder"}; - Account const other{"other"}; - env.fund(XRP(1'000), issuer, holder, other); - auto const usd = issuer["USD"]; - env.trust(usd(100), holder); - env(pay(issuer, holder, usd(100))); - env.close(); - - doInvariantCheck( - std::move(env), - holder, - other, - {{"Invariant failed: trustline clawback balance change is invalid"}}, - [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) { - auto sle = - ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency)); - if (!sle) - return false; - - STAmount balance{Issue{usd.currency, issuer.id()}, 80}; - if (holder.id() > issuer.id()) - balance.negate(); - sle->setFieldAmount(sfBalance, balance); - ac.view().update(sle); - return true; - }, - XRPAmount{}, - STTx{ - ttCLAWBACK, - [&](STObject& tx) { - tx[sfAccount] = issuer.id(); - tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10}; - }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); - } - - // Full IOU clawback may delete the trustline; missing after-SLE represents zero balance. - { - Env env(*this, defaultAmendments()); - Account const issuer{"issuer"}; - Account const holder{"holder"}; - Account const other{"other"}; - env.fund(XRP(1'000), issuer, holder, other); - auto const usd = issuer["USD"]; - env.trust(usd(100), holder); - env(pay(issuer, holder, usd(100))); - env.close(); - - doInvariantCheck( - std::move(env), - holder, - other, - {}, - [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) { - auto const sle = - ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency)); - if (!sle) - return false; - - ac.view().erase(sle); - return true; - }, - XRPAmount{}, - STTx{ - ttCLAWBACK, - [&](STObject& tx) { - tx[sfAccount] = issuer.id(); - tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 100}; - }}, - {tesSUCCESS, tesSUCCESS}); - } - - // Pre-MPTokensV2 invalid IOU clawback delta logs but remains non-enforcing. - { - Env env(*this, defaultAmendments() - featureMPTokensV2); - Account const issuer{"issuer"}; - Account const holder{"holder"}; - Account const other{"other"}; - env.fund(XRP(1'000), issuer, holder, other); - auto const usd = issuer["USD"]; - env.trust(usd(100), holder); - env(pay(issuer, holder, usd(100))); - env.close(); - - doInvariantCheck( - std::move(env), - holder, - other, - {{"Invariant failed: trustline clawback balance change is invalid"}}, - [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) { - auto sle = - ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency)); - if (!sle) - return false; - - STAmount balance{Issue{usd.currency, issuer.id()}, 80}; - if (holder.id() > issuer.id()) - balance.negate(); - sle->setFieldAmount(sfBalance, balance); - ac.view().update(sle); - return true; - }, - XRPAmount{}, - STTx{ - ttCLAWBACK, - [&](STObject& tx) { - tx[sfAccount] = issuer.id(); - tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10}; - }}, - {tesSUCCESS, tesSUCCESS}); - } - - // Invalid MPT clawback delta must fail when raw MPToken debit mismatches sfAmount. - { - Env env(*this, defaultAmendments()); - Account const issuer{"issuer"}; - Account const holder{"holder"}; - Account const other{"other"}; - env.fund(XRP(1'000), issuer, holder, other); - MPTTester const mpt( - {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100}); - auto const id = mpt.issuanceID(); - - doInvariantCheck( - std::move(env), - holder, - other, - {{"Invariant failed: MPT clawback balance change is invalid"}}, - [id](Account const& holder, Account const&, ApplyContext& ac) { - auto const sleToken = ac.view().peek(keylet::mptoken(id, holder)); - auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id)); - if (!sleToken || !sleIssuance) - return false; - - sleToken->setFieldU64(sfMPTAmount, 80); - sleIssuance->setFieldU64(sfOutstandingAmount, 80); - ac.view().update(sleToken); - ac.view().update(sleIssuance); - return true; - }, - XRPAmount{}, - STTx{ - ttCLAWBACK, - [&](STObject& tx) { - tx[sfAccount] = issuer.id(); - tx[sfHolder] = holder.id(); - tx[sfAmount] = STAmount{MPTIssue{id}, 10}; - }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); - } - - // A clawback that mutates both IOU and MPT entries must fail under MPTokensV2. - { - Env env(*this, defaultAmendments()); - Account const issuer{"issuer"}; - Account const holder{"holder"}; - Account const other{"other"}; - env.fund(XRP(1'000), issuer, holder, other); - auto const usd = issuer["USD"]; - env.trust(usd(100), holder); - env(pay(issuer, holder, usd(100))); - MPTTester const mpt( - {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100}); - auto const id = mpt.issuanceID(); - - doInvariantCheck( - std::move(env), - holder, - other, - {{"Invariant failed: trustline and MPToken both changed"}}, - [issuer, usd, id](Account const& holder, Account const&, ApplyContext& ac) { - auto const sleLine = - ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency)); - auto const sleToken = ac.view().peek(keylet::mptoken(id, holder.id())); - auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id)); - if (!sleLine || !sleToken || !sleIssuance) - return false; - - STAmount balance{Issue{usd.currency, issuer.id()}, 90}; - if (holder.id() > issuer.id()) - balance.negate(); - sleLine->setFieldAmount(sfBalance, balance); - sleToken->setFieldU64(sfMPTAmount, 90); - sleIssuance->setFieldU64(sfOutstandingAmount, 90); - ac.view().update(sleLine); - ac.view().update(sleToken); - ac.view().update(sleIssuance); - return true; - }, - XRPAmount{}, - STTx{ - ttCLAWBACK, - [&](STObject& tx) { - tx[sfAccount] = issuer.id(); - tx[sfHolder] = holder.id(); - tx[sfAmount] = STAmount{MPTIssue{id}, 10}; - }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); - } - - // Clawback that modifies a trustline other than the one implied by the - // tx amount: clawbackTrustLineBalanceInHolderTerms returns nullopt for - // the mismatched line. - { - Env env(*this, defaultAmendments()); - Account const issuer{"issuer"}; - Account const holder{"holder"}; - Account const other{"other"}; - env.fund(XRP(1'000), issuer, holder, other); - auto const usd = issuer["USD"]; - auto const eur = issuer["EUR"]; - env.trust(eur(100), holder); - env(pay(issuer, holder, eur(100))); - env.close(); - - doInvariantCheck( - std::move(env), - holder, - other, - {{"Invariant failed: trustline clawback changed the wrong line"}}, - [issuer, eur](Account const& holder, Account const&, ApplyContext& ac) { - auto sle = - ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), eur.currency)); - if (!sle) - return false; - STAmount balance{Issue{eur.currency, issuer.id()}, 90}; - if (holder.id() > issuer.id()) - balance.negate(); - sle->setFieldAmount(sfBalance, balance); - ac.view().update(sle); - return true; - }, - XRPAmount{}, - STTx{ - ttCLAWBACK, - [&](STObject& tx) { - tx[sfAccount] = issuer.id(); - tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10}; - }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); - } - - // Clawback leaving the holder's balance negative. - { - Env env(*this, defaultAmendments()); - Account const issuer{"issuer"}; - Account const holder{"holder"}; - Account const other{"other"}; - env.fund(XRP(1'000), issuer, holder, other); - auto const usd = issuer["USD"]; - env.trust(usd(100), holder); - env(pay(issuer, holder, usd(100))); - env.close(); - - doInvariantCheck( - std::move(env), - holder, - other, - {{"Invariant failed: trustline or MPT balance is negative"}}, - [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) { - auto sle = - ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency)); - if (!sle) - return false; - // Make the holder's balance negative from their perspective. - STAmount balance{Issue{usd.currency, issuer.id()}, 80}; - if (holder.id() < issuer.id()) - balance.negate(); - sle->setFieldAmount(sfBalance, balance); - ac.view().update(sle); - return true; - }, - XRPAmount{}, - STTx{ - ttCLAWBACK, - [&](STObject& tx) { - tx[sfAccount] = issuer.id(); - tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10}; - }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); - } - - // IOU-amount clawback while only an MPToken changed: no trustline was - // recorded, so iou_.before is empty. - { - Env env(*this, defaultAmendments()); - Account const issuer{"issuer"}; - Account const holder{"holder"}; - Account const other{"other"}; - env.fund(XRP(1'000), issuer, holder, other); - auto const usd = issuer["USD"]; - MPTTester const mpt( - {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100}); - auto const id = mpt.issuanceID(); - - doInvariantCheck( - std::move(env), - holder, - other, - {{"Invariant failed: trustline clawback changed the wrong line"}}, - [id](Account const& holder, Account const&, ApplyContext& ac) { - auto const sleToken = ac.view().peek(keylet::mptoken(id, holder)); - auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id)); - if (!sleToken || !sleIssuance) - return false; - sleToken->setFieldU64(sfMPTAmount, 90); - sleIssuance->setFieldU64(sfOutstandingAmount, 90); - ac.view().update(sleToken); - ac.view().update(sleIssuance); - return true; - }, - XRPAmount{}, - STTx{ - ttCLAWBACK, - [&](STObject& tx) { - tx[sfAccount] = issuer.id(); - tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10}; - }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); - } - - // Valid trustline change but a zero clawback amount. - { - Env env(*this, defaultAmendments()); - Account const issuer{"issuer"}; - Account const holder{"holder"}; - Account const other{"other"}; - env.fund(XRP(1'000), issuer, holder, other); - auto const usd = issuer["USD"]; - env.trust(usd(100), holder); - env(pay(issuer, holder, usd(100))); - env.close(); - - doInvariantCheck( - std::move(env), - holder, - other, - {{"Invariant failed: trustline clawback amount is invalid"}}, - [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) { - auto sle = - ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency)); - if (!sle) - return false; - STAmount balance{Issue{usd.currency, issuer.id()}, 90}; - if (holder.id() > issuer.id()) - balance.negate(); - sle->setFieldAmount(sfBalance, balance); - ac.view().update(sle); - return true; - }, - XRPAmount{}, - STTx{ - ttCLAWBACK, - [&](STObject& tx) { - tx[sfAccount] = issuer.id(); - tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 0}; - }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); - } - - // MPT clawback tx missing the Holder field. - { - Env env(*this, defaultAmendments()); - Account const issuer{"issuer"}; - Account const holder{"holder"}; - Account const other{"other"}; - env.fund(XRP(1'000), issuer, holder, other); - MPTTester const mpt( - {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100}); - auto const id = mpt.issuanceID(); - - doInvariantCheck( - std::move(env), - holder, - other, - {{"Invariant failed: MPT clawback missing holder"}}, - [id](Account const& holder, Account const&, ApplyContext& ac) { - auto const sleToken = ac.view().peek(keylet::mptoken(id, holder)); - auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id)); - if (!sleToken || !sleIssuance) - return false; - sleToken->setFieldU64(sfMPTAmount, 90); - sleIssuance->setFieldU64(sfOutstandingAmount, 90); - ac.view().update(sleToken); - ac.view().update(sleIssuance); - return true; - }, - XRPAmount{}, - STTx{ - ttCLAWBACK, - [&](STObject& tx) { - tx[sfAccount] = issuer.id(); - tx[sfAmount] = STAmount{MPTIssue{id}, 10}; - }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); - } - - // MPT clawback where the holder's MPToken was deleted (after is empty). - { - Env env(*this, defaultAmendments()); - Account const issuer{"issuer"}; - Account const holder{"holder"}; - Account const other{"other"}; - env.fund(XRP(1'000), issuer, holder, other); - MPTTester const mpt( - {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100}); - auto const id = mpt.issuanceID(); - - doInvariantCheck( - std::move(env), - holder, - other, - {{"Invariant failed: MPT clawback token is missing"}}, - [id](Account const& holder, Account const&, ApplyContext& ac) { - auto const sleToken = ac.view().peek(keylet::mptoken(id, holder)); - auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id)); - if (!sleToken || !sleIssuance) - return false; - // Keep the issuance consistent after removing the token. - sleIssuance->setFieldU64(sfOutstandingAmount, 0); - ac.view().update(sleIssuance); - ac.view().erase(sleToken); - return true; - }, - XRPAmount{}, - STTx{ - ttCLAWBACK, - [&](STObject& tx) { - tx[sfAccount] = issuer.id(); - tx[sfHolder] = holder.id(); - tx[sfAmount] = STAmount{MPTIssue{id}, 10}; - }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); - } - - // MPT clawback that changed a different holder's MPToken. - { - Env env(*this, defaultAmendments()); - Account const issuer{"issuer"}; - Account const holder{"holder"}; - Account const other{"other"}; - env.fund(XRP(1'000), issuer, holder, other); - MPTTester const mpt( - {.env = env, - .issuer = issuer, - .holders = {holder, other}, - .pay = 100, - .maxAmt = 200}); - auto const id = mpt.issuanceID(); - - doInvariantCheck( - std::move(env), - holder, - other, - {{"Invariant failed: MPT clawback changed the wrong token"}}, - [id](Account const&, Account const& other, ApplyContext& ac) { - auto const sleToken = ac.view().peek(keylet::mptoken(id, other)); - auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id)); - if (!sleToken || !sleIssuance) - return false; - sleToken->setFieldU64(sfMPTAmount, 90); - sleIssuance->setFieldU64(sfOutstandingAmount, 190); - ac.view().update(sleToken); - ac.view().update(sleIssuance); - return true; - }, - XRPAmount{}, - STTx{ - ttCLAWBACK, - [&](STObject& tx) { - tx[sfAccount] = issuer.id(); - tx[sfHolder] = holder.id(); - tx[sfAmount] = STAmount{MPTIssue{id}, 10}; - }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); - } - - // Valid MPToken change but a zero MPT clawback amount. - { - Env env(*this, defaultAmendments()); - Account const issuer{"issuer"}; - Account const holder{"holder"}; - Account const other{"other"}; - env.fund(XRP(1'000), issuer, holder, other); - MPTTester const mpt( - {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100}); - auto const id = mpt.issuanceID(); - - doInvariantCheck( - std::move(env), - holder, - other, - {{"Invariant failed: MPT clawback amount is invalid"}}, - [id](Account const& holder, Account const&, ApplyContext& ac) { - auto const sleToken = ac.view().peek(keylet::mptoken(id, holder)); - auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id)); - if (!sleToken || !sleIssuance) - return false; - sleToken->setFieldU64(sfMPTAmount, 90); - sleIssuance->setFieldU64(sfOutstandingAmount, 90); - ac.view().update(sleToken); - ac.view().update(sleIssuance); - return true; - }, - XRPAmount{}, - STTx{ - ttCLAWBACK, - [&](STObject& tx) { - tx[sfAccount] = issuer.id(); - tx[sfHolder] = holder.id(); - tx[sfAmount] = STAmount{MPTIssue{id}, 0}; - }}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); - } - - // More MPTokens created than expected - std::array, 4> const tests = { - std::make_pair(ttAMM_WITHDRAW, 2), - std::make_pair(ttAMM_CLAWBACK, 2), - std::make_pair(ttAMM_CREATE, 3), - std::make_pair(ttCHECK_CASH, 2)}; - for (auto const& [tx, nTokens] : tests) - { - doInvariantCheck( - {{std::string("MPToken created for the MPT issuer")}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - - auto seq = sle->getFieldU32(sfSequence); - for (int i = 0; i < nTokens; ++i) - { - MPTIssue const mpt{makeMptID(seq + i, a1)}; - auto sleNew = - std::make_shared(keylet::mptokenIssuance(mpt.getMptID())); - ac.view().insert(sleNew); - - sleNew = std::make_shared(keylet::mptoken(mpt.getMptID(), a2)); - ac.view().insert(sleNew); - } - - return true; - }, - XRPAmount{}, - STTx{tx, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); - } - - // More MPTokens deleted than expected - for (auto const& tx : {ttAMM_WITHDRAW, ttAMM_CLAWBACK}) - { - MPTID id; - Account const a3("A3"); - doInvariantCheck( - {{"MPT authorize succeeded but created/deleted bad number of mptokens"}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - for (auto const& a : {a1, a2, a3}) - { - auto sle = ac.view().peek(keylet::mptoken(id, a)); - if (!sle) - return false; - ac.view().erase(sle); - } - return true; - }, - XRPAmount{}, - STTx{tx, [](STObject& tx) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - [&](Account const& a1, Account const& a2, Env& env) { - Account const gw("gw"); - env.fund(XRP(1'000), gw, a3); - MPTTester const mpt({.env = env, .issuer = gw, .holders = {a1, a2, a3}}); - id = mpt.issuanceID(); - return true; - }); - } - - // sfReferenceHolding can only be set on creation by VaultCreate. A - // non-VaultCreate transaction that creates an MPTokenIssuance with - // sfReferenceHolding present must trip the invariant. - doInvariantCheck( - {{"sfReferenceHolding set on a new MPTokenIssuance by a " - "non-VaultCreate transaction"}}, - [](Account const& a1, Account const&, ApplyContext& ac) { - auto const sleAcct = ac.view().peek(keylet::account(a1.id())); - if (!sleAcct) - return false; - MPTIssue const mpt{makeMptID(sleAcct->getFieldU32(sfSequence), a1)}; - auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID())); - sleNew->setFieldH256(sfReferenceHolding, uint256{1}); - ac.view().insert(sleNew); - return true; - }, - XRPAmount{}, - STTx{ttACCOUNT_SET, [](STObject&) {}}); - - // sfReferenceHolding is immutable: changing the field on an - // existing MPTokenIssuance must trip the invariant. Set up a real - // vault via preclose (so the share issuance carries - // sfReferenceHolding), then mutate it in precheck to produce a - // before/after pair. - { - uint256 vaultKey; - doInvariantCheck( - {{"sfReferenceHolding was modified on an existing " - "MPTokenIssuance"}}, - [&](Account const&, Account const&, ApplyContext& ac) { - auto const sleVault = ac.view().peek(keylet::vault(vaultKey)); - if (!sleVault) - return false; - auto sleIssuance = - ac.view().peek(keylet::mptokenIssuance(sleVault->at(sfShareMPTID))); - if (!sleIssuance) - return false; - sleIssuance->setFieldH256(sfReferenceHolding, uint256{2}); - ac.view().update(sleIssuance); - return true; - }, - XRPAmount{}, - STTx{ttACCOUNT_SET, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - [&](Account const& a1, Account const&, Env& env) { - Account const issuer{"issuer"}; - env.fund(XRP(10'000), issuer); - env.close(); - MPTTester mptt{env, issuer, kMptInitNoFund}; - mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock}); - PrettyAsset const asset = mptt.issuanceID(); - mptt.authorize({.account = a1}); - env.close(); - - Vault const vault{env}; - auto [tx, keylet] = vault.create({.owner = a1, .asset = asset}); - env(tx); - env.close(); - vaultKey = keylet.key; - return true; - }); - } - - // A vault pseudo-account's MPToken cannot be deleted by anything - // other than a VaultDelete transaction. Set up a vault, then have - // an arbitrary tx erase the pseudo's MPToken in precheck. - { - uint256 vaultKey; - doInvariantCheck( - {{"vault pseudo-account holding deleted by a " - "non-VaultDelete transaction"}}, - [&](Account const&, Account const&, ApplyContext& ac) { - auto const sleVault = ac.view().peek(keylet::vault(vaultKey)); - if (!sleVault) - return false; - auto const sleIssuance = - ac.view().peek(keylet::mptokenIssuance(sleVault->at(sfShareMPTID))); - if (!sleIssuance || !sleIssuance->isFieldPresent(sfReferenceHolding)) - return false; - auto sleHolding = ac.view().peek( - keylet::unchecked(sleIssuance->getFieldH256(sfReferenceHolding))); - if (!sleHolding) - return false; - ac.view().erase(sleHolding); - return true; - }, - XRPAmount{}, - STTx{ttACCOUNT_SET, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - [&](Account const& a1, Account const&, Env& env) { - Account const issuer{"issuer"}; - env.fund(XRP(10'000), issuer); - env.close(); - MPTTester mptt{env, issuer, kMptInitNoFund}; - mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock}); - PrettyAsset const asset = mptt.issuanceID(); - mptt.authorize({.account = a1}); - env.close(); - - Vault const vault{env}; - auto [tx, keylet] = vault.create({.owner = a1, .asset = asset}); - env(tx); - env.close(); - vaultKey = keylet.key; - return true; - }); - } - - // Invalid transfer - std::array, 3> const invalidTransferTests = { - std::make_pair(ttAMM_WITHDRAW, false), - std::make_pair(ttPAYMENT, false), - std::make_pair(ttPAYMENT, true)}; - // The two amendments that gate enforcement, in all four combinations. - FeatureBitset const gatesEnabled{featureMPTokensV2, fixCleanup3_4_0}; - for (auto const gates : - {gatesEnabled, - gatesEnabled - featureMPTokensV2, - gatesEnabled - fixCleanup3_4_0, - FeatureBitset{}}) - { - for (auto const& [tx, crossCurrencyPayment] : invalidTransferTests) - { - for (auto const flag : - {static_cast(lsfMPTLocked), - ~lsfMPTCanTransfer, - ~lsfMPTCanTrade, - 0u}) - { - MPTID id{}; - auto const isSuccess = !gates.any() || flag == 0 || - (tx == ttPAYMENT && !crossCurrencyPayment && (flag == ~lsfMPTCanTrade)) || - (tx == ttAMM_WITHDRAW && - (flag == ~lsfMPTCanTrade || flag == ~lsfMPTCanTransfer)); - std::pair const error = isSuccess - ? std::make_pair(TER(tesSUCCESS), TER(tesSUCCESS)) - : std::make_pair(TER(tecINVARIANT_FAILED), TER(tefINVARIANT_FAILED)); - doInvariantCheck( - {{isSuccess ? "" : "invalid MPToken transfer between holders"}}, - [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto update = [&](AccountID const& a, std::uint64_t v) { - auto sle = ac.view().peek(keylet::mptoken(id, a)); - if (!sle) - return false; - sle->at(sfMPTAmount) = v; - ac.view().update(sle); - return true; - }; - auto issuanceSle = ac.view().peek(keylet::mptokenIssuance(id)); - if (!issuanceSle) - return false; - auto const flags = issuanceSle->at(sfFlags); - if (flag == lsfMPTLocked) - { - issuanceSle->at(sfFlags) = flags | lsfMPTLocked; - } - else if (flag != 0u) - { - issuanceSle->at(sfFlags) = flags & flag; - } - issuanceSle->at(sfOutstandingAmount) = 200; - ac.view().update(issuanceSle); - return update(a1, 101) && update(a2, 99); - }, - XRPAmount{}, - STTx{ - tx, - [&](STObject& tx) { - if (crossCurrencyPayment) - { - tx.setFieldAmount( - sfSendMax, STAmount(MPTAmount{100}, MPTIssue{id})); - } - }}, - {error.first, error.second}, - [&](Account const& a1, Account const& a2, Env& env) { - Account const gw("gw"); - env.fund(XRP(1'000), gw); - MPTTester const usd( - {.env = env, .issuer = gw, .holders = {a1, a2}, .pay = 100}); - id = usd.issuanceID(); - // Either gate enforces, so both must be off to stay - // advisory. Disable after setting up the MPT; the - // next env.close() is what makes it take effect. - if (!gates[featureMPTokensV2]) - env.disableFeature(featureMPTokensV2); - if (!gates[fixCleanup3_4_0]) - env.disableFeature(fixCleanup3_4_0); - return true; - }); - } - } - } - - // An orphan has a zero balance, so only deletion is legitimate (see - // "Skipping Deleted MPTs" in testConfidentialMPTTransfer). - { - MPTID orphanID; - auto const setupOrphan = [&](Account const& a1, Account const& a2, Env& env) { - MPTTester mpt(env, a1, {.holders = {a2}, .fund = false}); - mpt.create({.flags = tfMPTCanTransfer}); - orphanID = mpt.issuanceID(); - // A2 is authorized but never paid, so its balance is zero and - // the issuance can be destroyed while its MPToken lives on. - mpt.authorize({.account = a2}); - mpt.destroy(); - return true; - }; - // ValidMPTBalanceChanges also reports this, so assert on the - // orphan message, which only the missing-issuance branch produces. - doInvariantCheck( - {{"orphaned MPToken balance changed"}}, - [&](Account const&, Account const& a2, ApplyContext& ac) { - auto sleTok = ac.view().peek(keylet::mptoken(orphanID, a2.id())); - if (!sleTok || (*sleTok)[sfMPTAmount] != 0) - return false; - (*sleTok)[sfMPTAmount] = (*sleTok)[sfMPTAmount] + 10; - ac.view().update(sleTok); - return true; - }, - XRPAmount{}, - STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - setupOrphan); - // Negative control: erasing the orphan is how it gets cleaned up. - doInvariantCheck( - {}, - [&](Account const&, Account const& a2, ApplyContext& ac) { - auto sleTok = ac.view().peek(keylet::mptoken(orphanID, a2.id())); - if (!sleTok) - return false; - ac.view().erase(sleTok); - return true; - }, - XRPAmount{}, - STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, - {tesSUCCESS, tesSUCCESS}, - setupOrphan); - // The same erase on a failure. The orphan branch continues, so only - // the pre-loop deletion check can report this one. - doInvariantCheck( - {{"MPToken deleted on failure"}}, - [&](Account const&, Account const& a2, ApplyContext& ac) { - auto sleTok = ac.view().peek(keylet::mptoken(orphanID, a2.id())); - if (!sleTok) - return false; - ac.view().erase(sleTok); - return true; - }, - XRPAmount{}, - STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - setupOrphan, - TxAccount::None, - std::source_location::current(), - tecEXPIRED); - } - - // Vault-share freeze invariant: isVaultPseudoAccountFrozen descends - // through sfReferenceHolding to test the vault's underlying asset for - // each changed holder. - { - Account const gw{"gw"}; - MPTID shareID{}; - - // Vault setup: a1 and a2 both deposit IOU and hold vault shares. - auto const setupVault = [&](Account const& a1, - Account const& a2, - Env& env) -> std::tuple { - env.fund(XRP(1'000), gw); - env.trust(gw["IOU"](10'000), a1); - env.trust(gw["IOU"](10'000), a2); - env.close(); - env(pay(gw, a1, gw["IOU"](500))); - env(pay(gw, a2, gw["IOU"](500))); - env.close(); - - Vault const vault{env}; - auto [createTx, vaultKeylet] = vault.create({.owner = a1, .asset = gw["IOU"]}); - env(createTx); - env.close(); - env(vault.deposit( - {.depositor = a1, .id = vaultKeylet.key, .amount = gw["IOU"](100)})); - env(vault.deposit( - {.depositor = a2, .id = vaultKeylet.key, .amount = gw["IOU"](100)})); - env.close(); - - return {env.le(vaultKeylet)->at(sfShareMPTID), env.le(vaultKeylet)->at(sfAccount)}; - }; - - // Simulate a vault-share transfer: a1 sends 10 shares to a2. - auto const precheck = - [&](Account const& a1, Account const& a2, ApplyContext& ac) -> bool { - auto sle1 = ac.view().peek(keylet::mptoken(shareID, a1.id())); - auto sle2 = ac.view().peek(keylet::mptoken(shareID, a2.id())); - if (!sle1 || !sle2) - return false; - (*sle1)[sfMPTAmount] -= 10; - (*sle2)[sfMPTAmount] += 10; - ac.view().update(sle1); - ac.view().update(sle2); - return true; - }; - - // Case: vault pseudo-account's IOU trustline is frozen. - { - auto const preclose = [&](Account const& a1, Account const& a2, Env& env) -> bool { - auto [sid, vid] = setupVault(a1, a2, env); - shareID = sid; - env(trust(gw, gw["IOU"](0), Account{"vaultPseudo", vid}, tfSetFreeze)); - env.close(); - return true; - }; - - doInvariantCheck( - Env{*this, defaultAmendments()}, - {{"invalid MPToken transfer between holders"}}, - precheck, - XRPAmount{}, - STTx{ttPAYMENT, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - preclose); - } - - // Case: receiver's (a2's) IOU trustline is frozen. - { - auto const preclose = [&](Account const& a1, Account const& a2, Env& env) -> bool { - auto [sid, vid] = setupVault(a1, a2, env); - shareID = sid; - env(trust(gw, gw["IOU"](0), a2, tfSetFreeze)); - env.close(); - return true; - }; - - doInvariantCheck( - Env{*this, defaultAmendments()}, - {{"invalid MPToken transfer between holders"}}, - precheck, - XRPAmount{}, - STTx{ttPAYMENT, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - preclose); - } - } - } - - void - testAMM() - { - testcase << "AMM"; - using namespace jtx; - - MPTID mptID{}; - uint256 ammID{}; - AccountID ammAccountID{}; - Account const gw{"gw"}; - Issue lptIssue{}; - PrettyAsset poolAsset{xrpIssue()}; - - auto deleteAMMAccount = [&](ApplyContext& ac, bool) { - auto sle = ac.view().peek(keylet::account(ammAccountID)); - if (!sle) - return false; - ac.view().erase(sle); - return true; - }; - - auto updateLPTokensBalance = [&](ApplyContext& ac, std::int64_t amount) { - auto sle = ac.view().peek(keylet::amm(ammID)); - if (!sle) - return false; - sle->setFieldAmount(sfLPTokenBalance, STAmount{lptIssue, amount}); - ac.view().update(sle); - return true; - }; - auto updateLPTokensBadAmount = [&](ApplyContext& ac, bool) { - return updateLPTokensBalance(ac, -1); - }; - auto updateLPTokensBadBalance = [&](ApplyContext& ac, bool) { - return updateLPTokensBalance(ac, 200'000'000); - }; - auto updateAMM = [&](ApplyContext& ac, bool) { return updateLPTokensBalance(ac, 10); }; - - auto updateAMMPool = [&](ApplyContext& ac, bool isMPT) { - if (isMPT) - { - auto sle = ac.view().peek(keylet::mptoken(mptID, ammAccountID)); - if (!sle) - return false; - sle->setFieldU64(sfMPTAmount, 1); - ac.view().update(sle); - return true; - } - auto sle = ac.view().peek(keylet::account(ammAccountID)); - if (!sle) - return false; - sle->setFieldAmount(sfBalance, XRP(1)); - ac.view().update(sle); - return true; - }; - - auto test = [&](auto const txType, - auto&& update, - bool isMPT, - TER error = tecINVARIANT_FAILED) { - doInvariantCheck( - {{"AMM"}}, - [&](Account const&, Account const&, ApplyContext& ac) { return update(ac, isMPT); }, - XRPAmount{}, - STTx{txType, [&](STObject& tx) {}}, - {tecINVARIANT_FAILED, error}, - [&](Account const&, Account const&, Env& env) { - env.fund(XRP(1'000), gw); - poolAsset = [&]() -> PrettyAsset { - if (isMPT) - { - MPT const mpt = MPTTester({.env = env, .issuer = gw}); - mptID = mpt.issuanceID; - return mpt; - } - return gw["USD"]; - }(); - AMM const amm(env, gw, XRP(100), poolAsset(100)); - ammAccountID = amm.ammAccount(); - ammID = amm.ammID(); - lptIssue = amm.lptIssue(); - return true; - }); - }; - - for (bool const isMPT : {false, true}) - { - // Under fixCleanup3_4_0 the MPT balance invariants also fire on the - // second pass, so both IOU and MPT pools now escalate to tef. - auto const error = TER(tefINVARIANT_FAILED); - for (auto txType : {ttAMM_CREATE, ttAMM_DEPOSIT, ttAMM_CLAWBACK, ttAMM_WITHDRAW}) - { - test(txType, deleteAMMAccount, isMPT, tefINVARIANT_FAILED); - test(txType, updateLPTokensBadAmount, isMPT); - test(txType, updateLPTokensBadBalance, isMPT); - } - for (auto txType : {ttAMM_BID, ttAMM_VOTE}) - { - test(txType, updateAMMPool, isMPT, error); - test(txType, updateLPTokensBadAmount, isMPT); - test(txType, updateLPTokensBadBalance, isMPT); - } - for (auto txType : {ttAMM_DELETE, ttCHECK_CASH, ttOFFER_CREATE, ttPAYMENT}) - { - test(txType, updateAMM, isMPT); - } - } - } - - // Test the invariant overwrite fix for both pre- and post-amendment - // behavior. With the fix enabled, |= accumulates violations across - // entries so a later valid entry cannot clear an earlier violation. - // Without the fix, = assignment means the last-visited entry wins. - void - testInvariantOverwrite(FeatureBitset features) - { - using namespace test::jtx; - bool const fixEnabled = features[fixCleanup3_1_3]; - std::initializer_list const failTers = {tecINVARIANT_FAILED, tefINVARIANT_FAILED}; - std::initializer_list const passTers = {tesSUCCESS, tesSUCCESS}; - - // Insert two trust line SLEs in hash-sorted order, with the "bad" - // entry at the lower-sorting key so it is visited first by - // ApplyStateTable::visit(). The configurer callables receive the - // SLE and the Issue corresponding to that side's keylet currency. - auto const insertOrderedTrustLinePair = [](ApplyContext& ac, - Account const& a1, - Account const& a2, - Account const& a3, - auto const& badConfig, - auto const& goodConfig) { - char const* const c1 = "USD"; - char const* const c2 = "EUR"; - auto const k1 = keylet::trustLine(a1, a2, a1[c1].currency); - auto const k2 = keylet::trustLine(a1, a3, a1[c2].currency); - - bool const k1First = k1.key < k2.key; - auto const& badKey = k1First ? k1 : k2; - auto const& goodKey = k1First ? k2 : k1; - Issue const badIss{k1First ? a1[c1].currency : a1[c2].currency, a1.id()}; - Issue const goodIss{k1First ? a1[c2].currency : a1[c1].currency, a1.id()}; - - auto const sleBad = std::make_shared(badKey); - badConfig(*sleBad, badIss); - ac.view().insert(sleBad); - - auto const sleGood = std::make_shared(goodKey); - goodConfig(*sleGood, goodIss); - ac.view().insert(sleGood); - }; - - // Regression: bad XRP trust line followed by a valid trust line. - // With the fix, the invariant catches the violation. Without it, - // the valid entry overwrites the flag to false. The keylet - // currencies are non-XRP (the invariant inspects sfLowLimit / - // sfHighLimit issue, not the keylet currency). - testcase << "overwrite: NoXRPTrustLines" + std::string(fixEnabled ? " fix" : ""); - doInvariantCheck( - makeEnv(features), - fixEnabled ? std::vector{{"an XRP trust line was created"}} - : std::vector{}, - [&insertOrderedTrustLinePair](Account const& a1, Account const& a2, ApplyContext& ac) { - Account const a3{"A3"}; - insertOrderedTrustLinePair( - ac, - a1, - a2, - a3, - [](SLE& sle, Issue const& iss) { - // sfLowLimit has xrpIssue, making isXrp = true - sle.setFieldAmount(sfLowLimit, STAmount{xrpIssue(), 0}); - sle.setFieldAmount(sfHighLimit, STAmount{iss, 0}); - }, - [](SLE& sle, Issue const& iss) { - sle.setFieldAmount(sfLowLimit, STAmount{iss, 0}); - sle.setFieldAmount(sfHighLimit, STAmount{iss, 0}); - }); - return true; - }, - XRPAmount{}, - STTx{ttACCOUNT_SET, [](STObject&) {}}, - fixEnabled ? failTers : passTers); - - // Regression: bad deep-freeze trust line followed by a valid one. - testcase << "overwrite: NoDeepFreeze" + std::string(fixEnabled ? " fix" : ""); - doInvariantCheck( - makeEnv(features), - fixEnabled ? std::vector{{"a trust line with deep freeze flag without " - "normal freeze was created"}} - : std::vector{}, - [&insertOrderedTrustLinePair](Account const& a1, Account const& a2, ApplyContext& ac) { - Account const a3{"A3"}; - insertOrderedTrustLinePair( - ac, - a1, - a2, - a3, - [](SLE& sle, Issue const& iss) { - sle.setFieldAmount(sfLowLimit, STAmount{iss, 0}); - sle.setFieldAmount(sfHighLimit, STAmount{iss, 0}); - sle.setFieldU32(sfFlags, lsfLowDeepFreeze); - }, - [](SLE& sle, Issue const& iss) { - sle.setFieldAmount(sfLowLimit, STAmount{iss, 0}); - sle.setFieldAmount(sfHighLimit, STAmount{iss, 0}); - sle.setFieldU32(sfFlags, 0u); - }); - return true; - }, - XRPAmount{}, - STTx{ttACCOUNT_SET, [](STObject&) {}}, - fixEnabled ? failTers : passTers); - - // Regression: MPT OutstandingAmount exceeds max, but locked <= - // outstanding. Plain assignment would overwrite bad_ = true. - // With the fix, NoZeroEscrow catches it. - // Without the fix, NoZeroEscrow passes but ValidMPTIssuance - // still fires ("a MPT issuance was created"). - testcase << "overwrite: NoZeroEscrow MPT" + std::string(fixEnabled ? " fix" : ""); - doInvariantCheck( - makeEnv(features), - fixEnabled ? std::vector{{"escrow specifies invalid amount"}} - : std::vector{{"a MPT issuance was created"}}, - [](Account const& a1, Account const&, ApplyContext& ac) { - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - - MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))}; - auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID())); - // outstanding exceeds kMaxMpTokenAmount -> checkAmount sets bad_ - sleNew->setFieldU64(sfOutstandingAmount, kMaxMpTokenAmount + 1); - // locked is valid and <= outstanding -> must NOT clear bad_ - sleNew->setFieldU64(sfLockedAmount, 10); - ac.view().insert(sleNew); - return true; - }, - XRPAmount{}, - STTx{ttACCOUNT_SET, [](STObject&) {}}, - failTers); - } - - void - testVaultComputeCoarsestScale() - { - using namespace jtx; - - Account const issuer{"issuer"}; - PrettyAsset const vaultAsset = issuer["IOU"]; - - struct TestCase - { - std::string name; - std::int32_t expectedMinScale; - std::vector values; - }; - - for (auto const mantissaScale : MantissaRange::getAllScales()) - { - if (mantissaScale == MantissaRange::MantissaScale::Small) - continue; - NumberMantissaScaleGuard const g{mantissaScale}; - - auto makeDelta = [&vaultAsset](Number const& n) -> ValidVault::DeltaInfo { - return {.delta = n, .scale = scale(n, vaultAsset.raw())}; - }; - - auto const testCases = std::vector{ - { - .name = "No values", - .expectedMinScale = 0, - .values = {}, - }, - { - .name = "Mixed integer and Number values", - .expectedMinScale = -15, - .values = {makeDelta(1), makeDelta(-1), makeDelta(Number{10, -1})}, - }, - { - .name = "Mixed scales", - .expectedMinScale = -17, - .values = - {makeDelta(Number{1, -2}), - makeDelta(Number{5, -3}), - makeDelta(Number{3, -2})}, - }, - { - .name = "Equal scales", - .expectedMinScale = -16, - .values = - {makeDelta(Number{1, -1}), - makeDelta(Number{5, -1}), - makeDelta(Number{1, -1})}, - }, - { - .name = "Mixed mantissa sizes", - .expectedMinScale = -12, - .values = - {makeDelta(Number{1}), - makeDelta(Number{1234, -3}), - makeDelta(Number{12345, -6}), - makeDelta(Number{123, 1})}, - }, - }; - - for (auto const& tc : testCases) - { - testcase("vault computeCoarsestScale: " + tc.name); - - auto const actualScale = ValidVault::computeCoarsestScale(tc.values); - - BEAST_EXPECTS( - actualScale == tc.expectedMinScale, - "expected: " + std::to_string(tc.expectedMinScale) + - ", actual: " + std::to_string(actualScale)); - for (auto const& num : tc.values) - { - // None of these scales are far enough apart that rounding the - // values would lose information, so check that the rounded - // value matches the original. - auto const actualRounded = roundToAsset(vaultAsset, num.delta, actualScale); - BEAST_EXPECTS( - actualRounded == num.delta, - "number " + to_string(num.delta) + " rounded to scale " + - std::to_string(actualScale) + " is " + to_string(actualRounded)); - } - } - - auto const testCases2 = std::vector{ - { - .name = "False equivalence", - .expectedMinScale = -15, - .values = - { - makeDelta(Number{1234567890123456789, -18}), - makeDelta(Number{12345, -4}), - makeDelta(Number{1}), - }, - }, - }; - - // Unlike the first set of test cases, the values in these test could - // look equivalent if using the wrong scale. - for (auto const& tc : testCases2) - { - testcase("vault computeCoarsestScale: " + tc.name); - - auto const actualScale = ValidVault::computeCoarsestScale(tc.values); - - BEAST_EXPECTS( - actualScale == tc.expectedMinScale, - "expected: " + std::to_string(tc.expectedMinScale) + - ", actual: " + std::to_string(actualScale)); - std::optional first; - Number firstRounded; - for (auto const& num : tc.values) - { - if (!first) - { - first = num.delta; - firstRounded = roundToAsset(vaultAsset, num.delta, actualScale); - continue; - } - auto const numRounded = roundToAsset(vaultAsset, num.delta, actualScale); - BEAST_EXPECTS( - numRounded != firstRounded, - "at a scale of " + std::to_string(actualScale) + " " + - to_string(num.delta) + " == " + to_string(*first)); - } - } - } - } - - void - testSponsorship() - { - using namespace test::jtx; - using namespace std::string_literals; - testcase("Sponsorship"); - { - auto const expectMessage = - "SponsoredOwnerCount does not equal SponsoringOwnerCount delta."; - - doInvariantCheck( - {{expectMessage}}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - sle->setFieldU32(sfSponsoredOwnerCount, 1); - ac.view().update(sle); - return true; - }); - - doInvariantCheck( - {{expectMessage}}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - sle->setFieldU32(sfSponsoringOwnerCount, 1); - ac.view().update(sle); - return true; - }); - } - - { - auto const expectMessage = - "OwnerCount must be greater than or equal to SponsoredOwnerCount."; - - doInvariantCheck( - {{expectMessage}}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - sle->setFieldU32(sfOwnerCount, 0); - sle->setFieldU32(sfSponsoredOwnerCount, 1); - ac.view().update(sle); - - auto const sle2 = ac.view().peek(keylet::account(a2.id())); - if (!sle2) - return false; - sle2->setFieldU32(sfSponsoringOwnerCount, 1); - ac.view().update(sle2); - return true; - }); - } - - { - auto const expectMessage = - "SponsoredObjectOwnerCount does not equal SponsoredOwnerCount delta."; - uint256 checkID; - - doInvariantCheck( - {{expectMessage}}, - [&](Account const&, Account const& a2, ApplyContext& ac) { - auto const check = ac.view().peek(keylet::check(checkID)); - if (!check) - return false; - check->setAccountID(sfSponsor, a2.id()); - ac.view().update(check); - return true; - }, - XRPAmount{}, - STTx{ttACCOUNT_SET, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - [&checkID](Account const& a1, Account const& a2, Env& env) { - checkID = keylet::check(a1.id(), SeqProxy::rawSequence(env.seq(a1))).key; - env(check::create(a1, a2, XRP(1))); - return true; - }); - } - - { - auto const expectMessage = - "Invariant failed: Net delta of SponsoringAccountCount does " - "not match net delta of sfSponsor presence."; - - doInvariantCheck( - {{expectMessage}}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - sle->setFieldU32(sfSponsoringAccountCount, 1); - ac.view().update(sle); - return true; - }); - - doInvariantCheck( - {{expectMessage}}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { - auto const sle = ac.view().peek(keylet::account(a1.id())); - if (!sle) - return false; - sle->setAccountID(sfSponsor, a2.id()); - ac.view().update(sle); - return true; - }); - } - } - - void - testObjectHasPseudoAccount() - { - testcase << "object has pseudo-account"; - using namespace jtx; - - auto const amendments = defaultAmendments() | fixCleanup3_3_0; - - // Vault: object deleted without its pseudo-account - { - Keylet vaultKeylet = keylet::amendments(); - doInvariantCheck( - Env{*this, amendments}, - {{"deleted Vault without deleting its pseudo-account"}}, - [&vaultKeylet](Account const&, Account const&, ApplyContext& ac) { - auto sle = ac.view().peek(vaultKeylet); - if (!sle) - return false; - ac.view().erase(sle); - return true; - }, - XRPAmount{}, - STTx{ttVAULT_DELETE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - [&vaultKeylet](Account const& a1, Account const&, Env& env) { - Vault const vault{env}; - auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()}); - env(tx); - vaultKeylet = keylet; - return true; - }); - } - - // AMM: object deleted without its pseudo-account - { - uint256 ammID{}; - Account const gw{"gw"}; - doInvariantCheck( - Env{*this, amendments}, - {{"deleted AMM without deleting its pseudo-account"}}, - [&ammID](Account const&, Account const&, ApplyContext& ac) { - auto sle = ac.view().peek(keylet::amm(ammID)); - if (!sle) - return false; - ac.view().erase(sle); - return true; - }, - XRPAmount{}, - STTx{ttAMM_DELETE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - [&ammID, &gw](Account const&, Account const&, Env& env) { - env.fund(XRP(1'000), gw); - AMM const amm(env, gw, XRP(100), gw["USD"](100)); - ammID = amm.ammID(); - return true; - }); - } - - // LoanBroker: object deleted without its pseudo-account - { - Keylet loanBrokerKeylet = keylet::amendments(); - doInvariantCheck( - Env{*this, amendments}, - {{"deleted LoanBroker without deleting its pseudo-account"}}, - [&loanBrokerKeylet](Account const&, Account const&, ApplyContext& ac) { - auto sle = ac.view().peek(loanBrokerKeylet); - if (!sle) - return false; - ac.view().erase(sle); - return true; - }, - XRPAmount{}, - STTx{ttLOAN_BROKER_DELETE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - [&loanBrokerKeylet, this](Account const& a1, Account const&, Env& env) { - PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; - loanBrokerKeylet = this->createLoanBroker(a1, env, xrpAsset); - return BEAST_EXPECT(env.le(loanBrokerKeylet)); - }); - } - - // Deleted object missing sfAccount field (defensive check). - // Manually construct the view to place a vault SLE without - // sfAccount into the base ledger, then erase it. - { - Env env{*this, amendments}; - Account const a1{"A1"}; - Account const a2{"A2"}; - env.fund(XRP(1000), a1, a2); - env.close(); - - OpenView ov{*env.current()}; - - auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ov.seq())); - auto sleVault = std::make_shared(vaultKeylet); - sleVault->makeFieldAbsent(sfAccount); - ov.rawInsert(sleVault); - - STTx const tx{ttVAULT_DELETE, [](STObject&) {}}; - test::StreamSink sink{beast::Severity::Warning}; - beast::Journal const jlog{sink}; - ApplyContext ac{ - env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog}; - CurrentTransactionRulesGuard const rulesGuard(ov.rules()); - - auto sle = ac.view().peek(vaultKeylet); - if (!BEAST_EXPECT(sle)) - return; - ac.view().erase(sle); - - auto transactor = makeTransactor(ac); - if (!BEAST_EXPECT(transactor)) - return; - TER const result = transactor->checkInvariants( - tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full); - BEAST_EXPECT(result == tecINVARIANT_FAILED); - BEAST_EXPECT(sink.messages().str().contains("is missing pseudo-account field")); - } - } - - void - testTxCheckException() - { - testcase << "txCheck exception"; - using namespace jtx; - - // A TxInvariantCheck that throws from the requested hook, so we can - // exercise checkInvariantsHelper's catch block via the - // transaction-specific layer (as opposed to the protocol layer, - // which testObjectHasPseudoAccount's last case already covers via a - // real Transactor's finalizeInvariants). - enum class ThrowFrom { VisitEntry, Finalize }; - - struct ThrowingTxInvariantCheck : TxInvariantCheck - { - ThrowFrom const throwFrom; - - explicit ThrowingTxInvariantCheck(ThrowFrom throwFrom) : throwFrom(throwFrom) - { - } - - void - visitEntry(bool, SLE::const_ref, SLE::const_ref) override - { - if (throwFrom == ThrowFrom::VisitEntry) - throw std::runtime_error("test-injected visitEntry exception"); - } - - [[nodiscard]] bool - finalize(STTx const&, TER, XRPAmount, ReadView const&, beast::Journal const&) override - { - if (throwFrom == ThrowFrom::Finalize) - throw std::runtime_error("test-injected finalize exception"); - return true; - } - }; - - for (auto const throwFrom : {ThrowFrom::VisitEntry, ThrowFrom::Finalize}) - { - Env env{*this}; - Account const alice{"alice"}; - env.fund(XRP(1000), alice); - env.close(); - - OpenView ov{*env.current()}; - STTx const tx{ttACCOUNT_SET, [](STObject&) {}}; - test::StreamSink sink{beast::Severity::Warning}; - beast::Journal const jlog{sink}; - ApplyContext ac{ - env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog}; - CurrentTransactionRulesGuard const rulesGuard(ov.rules()); - - // visitEntry only runs for entries the transaction touched, so - // make a modification for the traversal to report. - auto sle = ac.view().peek(keylet::account(alice.id())); - if (!BEAST_EXPECT(sle)) - return; - sle->at(sfSequence) = sle->at(sfSequence) + 1; - ac.view().update(sle); - - ThrowingTxInvariantCheck throwing{throwFrom}; - TER terActual = tesSUCCESS; - for (TER const& terExpect : {TER(tecINVARIANT_FAILED), TER(tefINVARIANT_FAILED)}) - { - terActual = checkInvariants(ac, terActual, XRPAmount{}, throwing); - BEAST_EXPECT(terExpect == terActual); - BEAST_EXPECT(sink.messages().str().contains( - "Transaction caused an exception during invariant checks")); - } - } - } - - void - testTxCheckFinalizeFalse() - { - testcase << "txCheck finalize returns false"; - using namespace jtx; - - // A TxInvariantCheck whose finalize returns false, so we can exercise - // the "Transaction has failed one or more transaction invariants" - // log path in checkInvariantsHelper independently of any real - // transactor. This is the transaction-layer analogue of the - // protocol-layer coverage in testObjectHasPseudoAccount / others. - struct FailingTxInvariantCheck : TxInvariantCheck - { - void - visitEntry(bool, SLE::const_ref, SLE::const_ref) override - { - } - - [[nodiscard]] bool - finalize(STTx const&, TER, XRPAmount, ReadView const&, beast::Journal const&) override - { - return false; - } - }; - - Env env{*this}; - Account const alice{"alice"}; - env.fund(XRP(1000), alice); - env.close(); - - OpenView ov{*env.current()}; - STTx const tx{ttACCOUNT_SET, [](STObject&) {}}; - test::StreamSink sink{beast::Severity::Warning}; - beast::Journal const jlog{sink}; - ApplyContext ac{env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog}; - CurrentTransactionRulesGuard const rulesGuard(ov.rules()); - - FailingTxInvariantCheck failing; - TER terActual = tesSUCCESS; - for (TER const& terExpect : {TER(tecINVARIANT_FAILED), TER(tefINVARIANT_FAILED)}) - { - terActual = checkInvariants(ac, terActual, XRPAmount{}, failing); - BEAST_EXPECT(terExpect == terActual); - BEAST_EXPECT(sink.messages().str().contains( - "Transaction has failed one or more transaction invariants")); - // The protocol-layer log must not appear: only the tx-layer - // finalize failed here. - BEAST_EXPECT(!sink.messages().str().contains( - "Transaction has failed one or more global invariants")); - } - } - - void - testConfidentialMPTTransfer() - { - using namespace test::jtx; - testcase << "ValidConfidentialMPToken"; - - MPTID mptID; - - // Generate an MPT with privacy, issue 100 tokens to A2. - // Perform a confidential conversion to populate encrypted state. - auto const precloseConfidential = - [&mptID](Account const& a1, Account const& a2, Env& env) -> bool { - MPTTester mpt(env, a1, {.holders = {a2}, .fund = false}); - mpt.create({.flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance}); - mptID = mpt.issuanceID(); - - mpt.authorize({.account = a2}); - mpt.pay(a1, a2, 100); - - mpt.generateKeyPair(a1); - mpt.set({.account = a1, .issuerPubKey = mpt.getPubKey(a1)}); - - mpt.generateKeyPair(a2); - mpt.convert({ - .account = a2, - .amt = 100, - .holderPubKey = mpt.getPubKey(a2), - }); - return true; - }; - - // badDelete - doInvariantCheck( - {"MPToken deleted with encrypted fields while COA > 0"}, - [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) { - auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id())); - if (!sleToken) - return false; - // Force an erase of the object while the COA remains 100 - ac.view().erase(sleToken); - return true; - }, - XRPAmount{}, - STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseConfidential); - - // badConsistency - doInvariantCheck( - {"MPToken encrypted field existence inconsistency"}, - [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) { - auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id())); - if (!sleToken) - return false; - // Remove one of the required encrypted fields to create a mismatch - sleToken->makeFieldAbsent(sfIssuerEncryptedBalance); - ac.view().update(sleToken); - return true; - }, - XRPAmount{}, - STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - precloseConfidential); - - doInvariantCheck( - {"MPToken encrypted field existence inconsistency"}, - [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) { - auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id())); - if (!sleToken) - return false; - sleToken->makeFieldAbsent(sfIssuerEncryptedBalance); - sleToken->makeFieldAbsent(sfConfidentialBalanceInbox); - sleToken->makeFieldAbsent(sfConfidentialBalanceSpending); - sleToken->setFieldVL(sfAuditorEncryptedBalance, Blob{0x00}); - ac.view().update(sleToken); - return true; - }, - XRPAmount{}, - STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - precloseConfidential); - - // requiresPrivacyFlag - auto const precloseNoPrivacy = [&mptID]( - Account const& a1, Account const& a2, Env& env) -> bool { - MPTTester mpt(env, a1, {.holders = {a2}, .fund = false}); - // completely omitted the tfMPTCanHoldConfidentialBalance flag here. - mpt.create({.flags = tfMPTCanTransfer}); - mptID = mpt.issuanceID(); - mpt.authorize({.account = a2}); - mpt.pay(a1, a2, 100); - return true; - }; - - doInvariantCheck( - {"MPToken has encrypted fields but Issuance does not have " - "lsfMPTCanHoldConfidentialBalance " - "set"}, - [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) { - auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id())); - if (!sleToken) - return false; - // Inject all three encrypted fields consistently (inbox+spending+issuer must be - // in sync or badConsistency fires first and masks requiresPrivacyFlag). - sleToken->setFieldVL(sfConfidentialBalanceInbox, Blob{0x00}); - sleToken->setFieldVL(sfConfidentialBalanceSpending, Blob{0x00}); - sleToken->setFieldVL(sfIssuerEncryptedBalance, Blob{0x00}); - ac.view().update(sleToken); - return true; - }, - XRPAmount{}, - STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - precloseNoPrivacy); - - // badCOA - doInvariantCheck( - {"Confidential outstanding amount exceeds total outstanding amount"}, - [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) { - auto sleIssuance = ac.view().peek(keylet::mptokenIssuance(mptID)); - if (!sleIssuance) - return false; - // Total outstanding is natively 100; bloat the COA over 100 - sleIssuance->setFieldU64(sfConfidentialOutstandingAmount, 200); - ac.view().update(sleIssuance); - return true; - }, - XRPAmount{}, - STTx{ttMPTOKEN_ISSUANCE_SET, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - precloseConfidential); - - // Conservation Violation - doInvariantCheck( - {"Token conservation violation for MPT"}, - [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) { - auto sleIssuance = ac.view().peek(keylet::mptokenIssuance(mptID)); - if (!sleIssuance) - return false; - - sleIssuance->setFieldU64( - sfConfidentialOutstandingAmount, - sleIssuance->getFieldU64(sfConfidentialOutstandingAmount) - 10); - ac.view().update(sleIssuance); - - return true; - }, - XRPAmount{}, - STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - precloseConfidential); - - // Send/MergeInbox must not change OutstandingAmount (coaDelta == 0) - doInvariantCheck( - {"Invariant failed: OutstandingAmount changed " - "by confidential transaction that should not " - "modify it for MPT"}, - [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) { - auto sleIssuance = ac.view().peek(keylet::mptokenIssuance(mptID)); - if (!sleIssuance) - return false; - sleIssuance->setFieldU64( - sfOutstandingAmount, sleIssuance->getFieldU64(sfOutstandingAmount) + 1); - ac.view().update(sleIssuance); - return true; - }, - XRPAmount{}, - STTx{ttCONFIDENTIAL_MPT_SEND, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - precloseConfidential); - - // Send/MergeInbox and zero-COA-delta confidential transactions must not - // change public holder MPTAmount. - doInvariantCheck( - {"Invariant failed: MPTAmount changed by confidential " - "transaction that should not modify this field."}, - [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) { - auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id())); - if (!sleToken) - return false; - sleToken->setFieldU64(sfMPTAmount, sleToken->getFieldU64(sfMPTAmount) + 1); - ac.view().update(sleToken); - return true; - }, - XRPAmount{}, - STTx{ttCONFIDENTIAL_MPT_SEND, [](STObject&) {}}, - // Second pass is tef: the bumped MPTAmount also trips - // ValidMPTTransfer's on-failure check, which escalates the tec. - {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, - precloseConfidential); - - // badVersion - doInvariantCheck( - {"MPToken sfConfidentialBalanceVersion not updated when sfConfidentialBalanceSpending " - "changed"}, - [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) { - Blob const kChangedConfidentialSpending = {0xBA, 0xDD}; - auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id())); - if (!sleToken) - return false; - sleToken->setFieldVL(sfConfidentialBalanceSpending, kChangedConfidentialSpending); - - // DO NOT update sfConfidentialBalanceVersion - ac.view().update(sleToken); - return true; - }, - XRPAmount{}, - STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, - precloseConfidential); - - // Skipping Deleted MPTs (Issuance deleted) - auto const precloseOrphan = [&mptID]( - Account const& a1, Account const& a2, Env& env) -> bool { - MPTTester mpt(env, a1, {.holders = {a2}, .fund = false}); - mpt.create({.flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance}); - mptID = mpt.issuanceID(); - mpt.authorize({.account = a2}); - - // Generate privacy keys and convert 0 amount so Bob has the encrypted fields - mpt.generateKeyPair(a1); - mpt.set({.account = a1, .issuerPubKey = mpt.getPubKey(a1)}); - mpt.generateKeyPair(a2); - mpt.convert({ - .account = a2, - .amt = 0, - .holderPubKey = mpt.getPubKey(a2), - }); - - // Immediately destroy the issuance. A2's empty, encrypted token object lives on. - mpt.destroy(); - return true; - }; - - doInvariantCheck( - {}, - [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) { - auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id())); - if (!sleToken) - return false; - // Safely able to erase the deleted token. - ac.view().erase(sleToken); - return true; - }, - XRPAmount{}, - STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, - {tesSUCCESS, tesSUCCESS}, - precloseOrphan); - } - -public: - void - run() override - { - testXRPNotCreated(); - testAccountRootsNotRemoved(); - testAccountRootsDeletedClean(); - testTypesMatch(); - testNoXRPTrustLine(); - testNoDeepFreezeTrustLinesWithoutFreeze(); - testTransfersNotFrozen(); - testXRPBalanceCheck(); - testTransactionFeeCheck(); - testNoBadOffers(); - testNoZeroEscrow(); - testValidNewAccountRoot(); - testNFTokenPageInvariants(); - testAMMDeleteInvariants(defaultAmendments()); - testAMMDeleteInvariants(defaultAmendments() - fixCleanup3_3_0); - testPermissionedDomainInvariants(defaultAmendments() | fixCleanup3_1_3); - testPermissionedDomainInvariants(defaultAmendments() - fixCleanup3_1_3); - testPermissionedDEX(defaultAmendments() | fixCleanup3_1_3); - testPermissionedDEX(defaultAmendments() - fixCleanup3_1_3); - testPermissionedDEXDeletedOfferFallback(); - testBookDirectoryExchangeRate(); - testNoModifiedUnmodifiableFields(); - testValidPseudoAccounts(); - testValidLoanBroker(); - testVault(); - testConfidentialMPTTransfer(); - testMPT(); - testInvariantOverwrite(defaultAmendments()); - testInvariantOverwrite(defaultAmendments() - fixCleanup3_1_3); - testVaultComputeCoarsestScale(); - testAMM(); - testObjectHasPseudoAccount(); - testSponsorship(); - testTxCheckException(); - testTxCheckFinalizeFalse(); - } -}; - -BEAST_DEFINE_TESTSUITE(Invariants, app, xrpl); - -} // namespace xrpl::test diff --git a/src/test/app/NFTokenBurn_test.cpp b/src/test/app/NFTokenBurn_test.cpp index ae1d557bb9..cc54c4feb5 100644 --- a/src/test/app/NFTokenBurn_test.cpp +++ b/src/test/app/NFTokenBurn_test.cpp @@ -117,33 +117,30 @@ class NFTokenBurn_test : public beast::unit_test::Suite std::cout << "Ledger state is not array!" << std::endl; return; } - for (json::UInt i = 0; i < state.size(); ++i) + for (auto& i : state) { - if (state[i].isMember(sfNFTokens.jsonName) && - state[i][sfNFTokens.jsonName].isArray()) + if (i.isMember(sfNFTokens.jsonName) && i[sfNFTokens.jsonName].isArray()) { - std::uint32_t const tokenCount = state[i][sfNFTokens.jsonName].size(); - std::cout << tokenCount << " NFtokens in page " - << state[i][jss::index].asString() << std::endl; + std::uint32_t const tokenCount = i[sfNFTokens.jsonName].size(); + std::cout << tokenCount << " NFtokens in page " << i[jss::index].asString() + << std::endl; if (vol == Volume::Noisy) { - std::cout << state[i].toStyledString() << std::endl; + std::cout << i.toStyledString() << std::endl; } else { if (tokenCount > 0) { - std::cout - << "first: " << state[i][sfNFTokens.jsonName][0u].toStyledString() - << std::endl; + std::cout << "first: " << i[sfNFTokens.jsonName][0u].toStyledString() + << std::endl; } if (tokenCount > 1) { - std::cout - << "last: " - << state[i][sfNFTokens.jsonName][tokenCount - 1].toStyledString() - << std::endl; + std::cout << "last: " + << i[sfNFTokens.jsonName][tokenCount - 1].toStyledString() + << std::endl; } } } @@ -419,12 +416,11 @@ class NFTokenBurn_test : public beast::unit_test::Suite json::Value& state = jrr[jss::result][jss::state]; int pageCount = 0; - for (json::UInt i = 0; i < state.size(); ++i) + for (auto& i : state) { - if (state[i].isMember(sfNFTokens.jsonName) && - state[i][sfNFTokens.jsonName].isArray()) + if (i.isMember(sfNFTokens.jsonName) && i[sfNFTokens.jsonName].isArray()) { - BEAST_EXPECT(state[i][sfNFTokens.jsonName].size() == 32); + BEAST_EXPECT(i[sfNFTokens.jsonName].size() == 32); ++pageCount; } } @@ -459,11 +455,11 @@ class NFTokenBurn_test : public beast::unit_test::Suite { json::Value jrr = env.rpc("json", "ledger_data", to_string(jvParams)); - json::Value& state = jrr[jss::result][jss::state]; + json::Value const& state = jrr[jss::result][jss::state]; - for (json::UInt i = 0; i < state.size(); ++i) + for (auto const& i : state) { - BEAST_EXPECT(!state[i].isMember(sfNFTokens.jsonName)); + BEAST_EXPECT(!i.isMember(sfNFTokens.jsonName)); } } }; @@ -757,8 +753,8 @@ class NFTokenBurn_test : public beast::unit_test::Suite // We're going to fire an Invariant failure that is difficult to // cause. We do it here because the tools are here. // - // See Invariants_test.cpp for examples of other invariant tests - // that this one is modeled after. + // See InvariantsMisc_test.cpp for examples of other invariant + // tests that this one is modeled after. // Generate three closely packed NFTokenPages. std::vector nfts = genPackedTokens(); @@ -1076,12 +1072,11 @@ class NFTokenBurn_test : public beast::unit_test::Suite json::Value& state = jrr[jss::result][jss::state]; int pageCount = 0; - for (json::UInt i = 0; i < state.size(); ++i) + for (auto& i : state) { - if (state[i].isMember(sfNFTokens.jsonName) && - state[i][sfNFTokens.jsonName].isArray()) + if (i.isMember(sfNFTokens.jsonName) && i[sfNFTokens.jsonName].isArray()) { - BEAST_EXPECT(state[i][sfNFTokens.jsonName].size() == 32); + BEAST_EXPECT(i[sfNFTokens.jsonName].size() == 32); ++pageCount; } } diff --git a/src/test/app/invariants/InvariantsAMM_test.cpp b/src/test/app/invariants/InvariantsAMM_test.cpp new file mode 100644 index 0000000000..498c35c653 --- /dev/null +++ b/src/test/app/invariants/InvariantsAMM_test.cpp @@ -0,0 +1,249 @@ +#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 { + +class InvariantsAMM_test : public InvariantsBase +{ + FeatureBitset const all_{test::jtx::testableAmendments()}; + + void + testAMMDeleteInvariants(FeatureBitset features) + { + using namespace test::jtx; + + bool const enforceAMMDelete = features[fixCleanup3_3_0]; + testcase << "AMM delete invariants" + std::string(enforceAMMDelete ? " fix" : ""); + + Env env(*this, features); + Account const issuer{"issuer"}; + Issue const lptIssue{Currency(0x4c50540000000000), issuer.id()}; + STAmount const zeroLP{lptIssue, 0}; + STAmount const nonZeroLP{lptIssue, 1}; + + auto const makeAMM = [](STAmount const& lptBalance) { + auto sleAMM = std::make_shared(keylet::amm(uint256(1))); + sleAMM->setFieldAmount(sfLPTokenBalance, lptBalance); + return sleAMM; + }; + + auto const checkInvariant = [&](TxType txType, + TER result, + std::optional const& deletedLPBalance, + bool expected, + std::string const& expectedLog) { + test::StreamSink sink{beast::Severity::Warning}; + beast::Journal const jlog{sink}; + ValidAMM invariant; + + if (deletedLPBalance) + invariant.visitEntry(true, makeAMM(*deletedLPBalance), nullptr); + + bool const actual = invariant.finalize( + STTx{txType, [](STObject&) {}}, result, XRPAmount{}, *env.current(), jlog); + + BEAST_EXPECTS(actual == expected, "unexpected AMM delete invariant result"); + auto const messages = sink.messages().str(); + auto const expectedLogWhenEnforced = enforceAMMDelete ? expectedLog : ""; + if (!expectedLogWhenEnforced.empty()) + { + BEAST_EXPECTS(messages.contains(expectedLogWhenEnforced), expectedLogWhenEnforced); + } + else + { + BEAST_EXPECTS(messages.empty(), messages); + } + }; + + checkInvariant( + ttPAYMENT, + tesSUCCESS, + nonZeroLP, + !enforceAMMDelete, + "Invariant failed: AMM failed, unexpected AMM deletion by"); + checkInvariant( + ttAMM_DELETE, + tesSUCCESS, + std::nullopt, + !enforceAMMDelete, + "Invariant failed: AMMDelete failed, AMM object remained on tesSUCCESS"); + checkInvariant( + ttAMM_DELETE, + tesSUCCESS, + nonZeroLP, + !enforceAMMDelete, + "Invariant failed: AMMDelete failed, AMM object deleted with non-zero LP balance"); + checkInvariant( + ttAMM_DELETE, + tecINCOMPLETE, + zeroLP, + !enforceAMMDelete, + "Invariant failed: AMMDelete failed, AMM object deleted when result is not tesSUCCESS"); + + checkInvariant(ttAMM_WITHDRAW, tesSUCCESS, nonZeroLP, true, ""); + checkInvariant(ttAMM_CLAWBACK, tesSUCCESS, nonZeroLP, true, ""); + + checkInvariant(ttAMM_DELETE, tesSUCCESS, zeroLP, true, ""); + checkInvariant(ttAMM_WITHDRAW, tesSUCCESS, zeroLP, true, ""); + checkInvariant(ttAMM_CLAWBACK, tesSUCCESS, zeroLP, true, ""); + } + + void + testAMM() + { + testcase << "AMM"; + using namespace jtx; + + MPTID mptID{}; + uint256 ammID{}; + AccountID ammAccountID{}; + Account const gw{"gw"}; + Issue lptIssue{}; + PrettyAsset poolAsset{xrpIssue()}; + + auto deleteAMMAccount = [&](ApplyContext& ac, bool) { + auto sle = ac.view().peek(keylet::account(ammAccountID)); + if (!sle) + return false; + ac.view().erase(sle); + return true; + }; + + auto updateLPTokensBalance = [&](ApplyContext& ac, std::int64_t amount) { + auto sle = ac.view().peek(keylet::amm(ammID)); + if (!sle) + return false; + sle->setFieldAmount(sfLPTokenBalance, STAmount{lptIssue, amount}); + ac.view().update(sle); + return true; + }; + auto updateLPTokensBadAmount = [&](ApplyContext& ac, bool) { + return updateLPTokensBalance(ac, -1); + }; + auto updateLPTokensBadBalance = [&](ApplyContext& ac, bool) { + return updateLPTokensBalance(ac, 200'000'000); + }; + auto updateAMM = [&](ApplyContext& ac, bool) { return updateLPTokensBalance(ac, 10); }; + + auto updateAMMPool = [&](ApplyContext& ac, bool isMPT) { + if (isMPT) + { + auto sle = ac.view().peek(keylet::mptoken(mptID, ammAccountID)); + if (!sle) + return false; + sle->setFieldU64(sfMPTAmount, 1); + ac.view().update(sle); + return true; + } + auto sle = ac.view().peek(keylet::account(ammAccountID)); + if (!sle) + return false; + sle->setFieldAmount(sfBalance, XRP(1)); + ac.view().update(sle); + return true; + }; + + auto test = [&](auto const txType, + auto&& update, + bool isMPT, + TER error = tecINVARIANT_FAILED) { + doInvariantCheck( + {{"AMM"}}, + [&](Account const&, Account const&, ApplyContext& ac) { return update(ac, isMPT); }, + XRPAmount{}, + STTx{txType, [&](STObject& tx) {}}, + {tecINVARIANT_FAILED, error}, + [&](Account const&, Account const&, Env& env) { + env.fund(XRP(1'000), gw); + poolAsset = [&]() -> PrettyAsset { + if (isMPT) + { + MPT const mpt = MPTTester({.env = env, .issuer = gw}); + mptID = mpt.issuanceID; + return mpt; + } + return gw["USD"]; + }(); + AMM const amm(env, gw, XRP(100), poolAsset(100)); + ammAccountID = amm.ammAccount(); + ammID = amm.ammID(); + lptIssue = amm.lptIssue(); + return true; + }); + }; + + for (bool const isMPT : {false, true}) + { + // Under fixCleanup3_4_0 the MPT balance invariants also fire on the + // second pass, so both IOU and MPT pools now escalate to tef. + auto const error = TER(tefINVARIANT_FAILED); + for (auto txType : {ttAMM_CREATE, ttAMM_DEPOSIT, ttAMM_CLAWBACK, ttAMM_WITHDRAW}) + { + test(txType, deleteAMMAccount, isMPT, tefINVARIANT_FAILED); + test(txType, updateLPTokensBadAmount, isMPT); + test(txType, updateLPTokensBadBalance, isMPT); + } + for (auto txType : {ttAMM_BID, ttAMM_VOTE}) + { + test(txType, updateAMMPool, isMPT, error); + test(txType, updateLPTokensBadAmount, isMPT); + test(txType, updateLPTokensBadBalance, isMPT); + } + for (auto txType : {ttAMM_DELETE, ttCHECK_CASH, ttOFFER_CREATE, ttPAYMENT}) + { + test(txType, updateAMM, isMPT); + } + } + } + + // Test the invariant overwrite fix for both pre- and post-amendment + // behavior. With the fix enabled, |= accumulates violations across + // entries so a later valid entry cannot clear an earlier violation. + // Without the fix, = assignment means the last-visited entry wins. + + void + run() override + { + testAMMDeleteInvariants(all_); + testAMMDeleteInvariants(all_ - fixCleanup3_3_0); + testAMM(); + } +}; + +BEAST_DEFINE_TESTSUITE(InvariantsAMM, app, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/invariants/InvariantsBase.cpp b/src/test/app/invariants/InvariantsBase.cpp new file mode 100644 index 0000000000..92d75eca77 --- /dev/null +++ b/src/test/app/invariants/InvariantsBase.cpp @@ -0,0 +1,200 @@ +#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::jtx::Env +InvariantsBase::makeEnv(FeatureBitset features) +{ + return {*this, test::jtx::envconfig(), features, nullptr, beast::Severity::Disabled}; +} + +void +InvariantsBase::doInvariantCheck( + std::vector const& expectLogs, + Precheck const& precheck, + XRPAmount fee, + STTx tx, + std::initializer_list ters, + Preclose const& preclose, + TxAccount setTxAccount, + std::source_location const& loc, + TER initialResult) +{ + doInvariantCheck( + makeEnv(test::jtx::testableAmendments()), + expectLogs, + precheck, + fee, + tx, + ters, + preclose, + setTxAccount, + loc, + initialResult); +} + +void +InvariantsBase::doInvariantCheck( + test::jtx::Env&& env, + std::vector const& expectLogs, + Precheck const& precheck, + XRPAmount fee, + STTx tx, + std::initializer_list ters, + Preclose const& preclose, + TxAccount setTxAccount, + std::source_location const& loc, + TER initialResult) +{ + using namespace test::jtx; + + Account const a1{"A1"}; + Account const a2{"A2"}; + env.fund(XRP(1000), a1, a2); + if (preclose) + BEAST_EXPECT(preclose(a1, a2, env)); + env.close(); + + if (setTxAccount != TxAccount::None) + tx.setAccountID(sfAccount, setTxAccount == TxAccount::A1 ? a1.id() : a2.id()); + + doInvariantCheck( + std::move(env), a1, a2, expectLogs, precheck, fee, tx, ters, loc, initialResult); +} + +void +InvariantsBase::doInvariantCheck( + // NOLINTNEXTLINE(cppcoreguidelines-rvalue-reference-param-not-moved) + test::jtx::Env&& env, + test::jtx::Account const& a1, + test::jtx::Account const& a2, + std::vector const& expectLogs, + Precheck const& precheck, + XRPAmount fee, + STTx tx, + std::initializer_list ters, + std::source_location const& loc, + TER initialResult) +{ + using namespace test::jtx; + + OpenView ov{*env.current()}; + test::StreamSink sink{beast::Severity::Warning}; + beast::Journal const jlog{sink}; + ApplyContext ac{env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog}; + + // Invariants normally run in the Transaction's "apply" (operator()) context, and can always + // access global Rules. + CurrentTransactionRulesGuard const rulesGuard(ov.rules()); + + BEAST_EXPECT(precheck(a1, a2, ac)); + + auto transactor = makeTransactor(ac); + if (!BEAST_EXPECT(transactor)) + return; + + // Invoke the check twice to cover the tec and tef cases. Both passes run + // against the same view -- production would discard it in between -- so + // the second sees the same violation and escalates tec -> tef. A + // {tec, tef} pair therefore means "enforced whatever the incoming + // result", not that the transaction ends in tef on ledger. + if (!BEAST_EXPECT(ters.size() == 2)) + return; + + TER terActual = initialResult; + for (TER const& terExpect : ters) + { + TER const terInput = terActual; + terActual = transactor->checkInvariants(terActual, fee, Transactor::InvariantScope::Full); + expect( + terExpect == terActual, + "expected: " + transToken(terExpect) + " got: " + transToken(terActual), + loc.file_name(), + loc.line()); + auto const messages = sink.messages().str(); + + // checkInvariants returns its input unchanged unless something + // fires, so a changed result means an invariant fired, and a firing + // invariant must log. + if (terActual != terInput) + { + expect( + messages.starts_with("Invariant failed:") || + messages.starts_with("Transaction caused an exception"), + messages, + loc.file_name(), + loc.line()); + } + + // std::cerr << messages << '\n'; + for (auto const& m : expectLogs) + { + expect(messages.contains(m), m, loc.file_name(), loc.line()); + } + } +} + +Keylet +InvariantsBase::createLoanBroker( + jtx::Account const& a, + jtx::Env& env, + jtx::PrettyAsset const& asset) +{ + using namespace jtx; + + // Create vault + uint256 vaultID; + Vault const vault{env}; + auto [tx, vKeylet] = vault.create({.owner = a, .asset = asset}); + env(tx); + BEAST_EXPECT(env.le(vKeylet)); + + vaultID = vKeylet.key; + + // Create Loan Broker + using namespace loan_broker; + + auto const loanBrokerKeylet = keylet::loanBroker(a.id(), SeqProxy::rawSequence(env.seq(a))); + // Create a Loan Broker with all default values. + env(set(a, vaultID), Fee(kIncrement)); + + return loanBrokerKeylet; +} + +} // namespace xrpl::test diff --git a/src/test/app/invariants/InvariantsBase.h b/src/test/app/invariants/InvariantsBase.h new file mode 100644 index 0000000000..73319d0ef8 --- /dev/null +++ b/src/test/app/invariants/InvariantsBase.h @@ -0,0 +1,122 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +class Transactor; + +// Test-only factory — not part of the public API. +// The returned Transactor holds a raw reference to ctx; the caller must ensure +// the ApplyContext outlives the Transactor. Implemented in applySteps.cpp +std::unique_ptr +makeTransactor(ApplyContext& ctx); + +} // namespace xrpl + +namespace xrpl::test { + +class InvariantsBase : public beast::unit_test::Suite +{ +protected: + // The optional Preclose function is used to process additional transactions + // on the ledger after creating two accounts, but before closing it, and + // before the Precheck function. These should only be valid functions, and + // not direct manipulations. Preclose is not commonly used. + using Preclose = std::function< + bool(test::jtx::Account const& a, test::jtx::Account const& b, test::jtx::Env& env)>; + + // this is common setup/method for running a failing invariant check. The + // precheck function is used to manipulate the ApplyContext with view + // changes that will cause the check to fail. + using Precheck = std::function< + bool(test::jtx::Account const& a, test::jtx::Account const& b, ApplyContext& ac)>; + + enum class TxAccount : int { None = 0, A1, A2 }; + + test::jtx::Env + makeEnv(FeatureBitset features); + + /** + * Run a specific test case to put the ledger into a state that will be + * detected by an invariant. Simulates the actions of a transaction that + * would violate an invariant. + * + * @param expectLogs One or more messages related to the failing invariant + * that should be in the log output + * @param precheck See "Precheck" above + * @param fee If provided, the fee amount paid by the simulated transaction. + * @param tx A mock transaction that took the actions to trigger the + * invariant. In most cases, only the type matters. + * @param ters The TER results expected on the two passes of the invariant + * checker. + * @param preclose See "Preclose" above. Note that @preclose runs *before* + * @precheck, but is the last parameter for historical reasons + * @param setTxAccount optionally set to add sfAccount to tx (either A1 or A2) + */ + void + doInvariantCheck( + std::vector const& expectLogs, + Precheck const& precheck, + XRPAmount fee = XRPAmount{}, + STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}}, + std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + Preclose const& preclose = {}, + TxAccount setTxAccount = TxAccount::None, + std::source_location const& loc = std::source_location::current(), + // Result fed to the invariant checker on the first pass. Set it to a + // tec to exercise result-dependent invariants; the harness runs no + // transactor, so one never arises on its own. + TER initialResult = tesSUCCESS); + + void + doInvariantCheck( + test::jtx::Env&& env, + std::vector const& expectLogs, + Precheck const& precheck, + XRPAmount fee = XRPAmount{}, + STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}}, + std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + Preclose const& preclose = {}, + TxAccount setTxAccount = TxAccount::None, + std::source_location const& loc = std::source_location::current(), + TER initialResult = tesSUCCESS); + + void + doInvariantCheck( + // NOLINTNEXTLINE(cppcoreguidelines-rvalue-reference-param-not-moved) + test::jtx::Env&& env, + test::jtx::Account const& a1, + test::jtx::Account const& a2, + std::vector const& expectLogs, + Precheck const& precheck, + XRPAmount fee = XRPAmount{}, + STTx tx = STTx{ttACCOUNT_SET, [](STObject&) {}}, + std::initializer_list ters = {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + std::source_location const& loc = std::source_location::current(), + TER initialResult = tesSUCCESS); + + Keylet + createLoanBroker(jtx::Account const& a, jtx::Env& env, jtx::PrettyAsset const& asset); +}; + +} // namespace xrpl::test diff --git a/src/test/app/invariants/InvariantsEscrowNFT_test.cpp b/src/test/app/invariants/InvariantsEscrowNFT_test.cpp new file mode 100644 index 0000000000..f0afa2377c --- /dev/null +++ b/src/test/app/invariants/InvariantsEscrowNFT_test.cpp @@ -0,0 +1,352 @@ +#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 { + +class InvariantsEscrowNFT_test : public InvariantsBase +{ + void + testNoZeroEscrow() + { + using namespace test::jtx; + testcase << "no zero escrow"; + + doInvariantCheck( + {{"XRP net change of -1000000 doesn't match fee 0"}, + {"escrow specifies invalid amount"}}, + [](Account const& a1, Account const&, ApplyContext& ac) { + // escrow with negative amount + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + auto sleNew = std::make_shared( + keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2))); + sleNew->setFieldAmount(sfAmount, XRP(-1)); + ac.view().insert(sleNew); + return true; + }); + + doInvariantCheck( + {{"XRP net change was positive: 100000000000000001"}, + {"escrow specifies invalid amount"}}, + [](Account const& a1, Account const&, ApplyContext& ac) { + // escrow with too-large amount + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + auto sleNew = std::make_shared( + keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2))); + // Use `drops(1)` to bypass a call to STAmount::canonicalize + // with an invalid value + sleNew->setFieldAmount(sfAmount, kInitialXrp + drops(1)); + ac.view().insert(sleNew); + return true; + }); + + // IOU < 0 + doInvariantCheck( + {{"escrow specifies invalid amount"}}, + [](Account const& a1, Account const&, ApplyContext& ac) { + // escrow with too-little iou + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + auto sleNew = std::make_shared( + keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2))); + + Issue const usd{Currency(0x5553440000000000), AccountID(0x4985601)}; + STAmount const amt(usd, -1); + sleNew->setFieldAmount(sfAmount, amt); + ac.view().insert(sleNew); + return true; + }); + + // IOU bad currency + doInvariantCheck( + {{"escrow specifies invalid amount"}}, + [](Account const& a1, Account const&, ApplyContext& ac) { + // escrow with bad iou currency + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + auto sleNew = std::make_shared( + keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2))); + + Issue const bad{badCurrency(), AccountID(0x4985601)}; + STAmount const amt(bad, 1); + sleNew->setFieldAmount(sfAmount, amt); + ac.view().insert(sleNew); + return true; + }); + + // MPT < 0 + doInvariantCheck( + {{"escrow specifies invalid amount"}}, + [](Account const& a1, Account const&, ApplyContext& ac) { + // escrow with too-little mpt + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + auto sleNew = std::make_shared( + keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2))); + + MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))}; + STAmount const amt(mpt, -1); + sleNew->setFieldAmount(sfAmount, amt); + ac.view().insert(sleNew); + return true; + }); + + // MPT OutstandingAmount < 0 + doInvariantCheck( + {{"escrow specifies invalid amount"}}, + [](Account const& a1, Account const&, ApplyContext& ac) { + // mptissuance outstanding is negative + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + + MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))}; + auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID())); + sleNew->setFieldU64(sfOutstandingAmount, std::numeric_limits::max()); + ac.view().insert(sleNew); + return true; + }); + + // MPT LockedAmount < 0 + doInvariantCheck( + {{"escrow specifies invalid amount"}}, + [](Account const& a1, Account const&, ApplyContext& ac) { + // mptissuance locked is less than locked + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + + MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))}; + auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID())); + sleNew->setFieldU64(sfLockedAmount, std::numeric_limits::max()); + ac.view().insert(sleNew); + return true; + }); + + // MPT OutstandingAmount < LockedAmount + doInvariantCheck( + {{"escrow specifies invalid amount"}}, + [](Account const& a1, Account const&, ApplyContext& ac) { + // mptissuance outstanding is less than locked + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + + MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))}; + auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID())); + sleNew->setFieldU64(sfOutstandingAmount, 1); + sleNew->setFieldU64(sfLockedAmount, 10); + ac.view().insert(sleNew); + return true; + }); + + // MPT MPTAmount < 0 + doInvariantCheck( + {{"escrow specifies invalid amount"}}, + [](Account const& a1, Account const&, ApplyContext& ac) { + // mptoken amount is negative + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + + MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))}; + auto sleNew = std::make_shared(keylet::mptoken(mpt.getMptID(), a1)); + sleNew->setFieldU64(sfMPTAmount, std::numeric_limits::max()); + ac.view().insert(sleNew); + return true; + }); + + // MPT LockedAmount < 0 + doInvariantCheck( + {{"escrow specifies invalid amount"}}, + [](Account const& a1, Account const&, ApplyContext& ac) { + // mptoken locked amount is negative + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + + MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))}; + auto sleNew = std::make_shared(keylet::mptoken(mpt.getMptID(), a1)); + sleNew->setFieldU64(sfLockedAmount, std::numeric_limits::max()); + ac.view().insert(sleNew); + return true; + }); + } + + void + testNFTokenPageInvariants() + { + using namespace test::jtx; + testcase << "NFTokenPage"; + + // lambda that returns an STArray of NFTokenIDs. + uint256 const firstNFTID( + "0000000000000000000000000000000000000001FFFFFFFFFFFFFFFF00000000"); + auto makeNFTokenIDs = [&firstNFTID](unsigned int nftCount) { + SOTemplate const* nfTokenTemplate = + InnerObjectFormats::getInstance().findSOTemplateBySField(sfNFToken); + + uint256 nftID(firstNFTID); + STArray ret; + for (int i = 0; i < nftCount; ++i) + { + STObject newNFToken(*nfTokenTemplate, sfNFToken, [&nftID](STObject& object) { + object.setFieldH256(sfNFTokenID, nftID); + }); + ret.pushBack(std::move(newNFToken)); + ++nftID; + } + return ret; + }; + + doInvariantCheck( + {{"NFT page has invalid size"}}, + [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) { + auto nftPage = std::make_shared(keylet::nftokenPageMax(a1)); + nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(0)); + + ac.view().insert(nftPage); + return true; + }); + + doInvariantCheck( + {{"NFT page has invalid size"}}, + [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) { + auto nftPage = std::make_shared(keylet::nftokenPageMax(a1)); + nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(33)); + + ac.view().insert(nftPage); + return true; + }); + + doInvariantCheck( + {{"NFTs on page are not sorted"}}, + [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) { + STArray nfTokens = makeNFTokenIDs(2); + std::iter_swap(nfTokens.begin(), nfTokens.begin() + 1); + + auto nftPage = std::make_shared(keylet::nftokenPageMax(a1)); + nftPage->setFieldArray(sfNFTokens, nfTokens); + + ac.view().insert(nftPage); + return true; + }); + + doInvariantCheck( + {{"NFT contains empty URI"}}, + [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) { + STArray nfTokens = makeNFTokenIDs(1); + nfTokens[0].setFieldVL(sfURI, Blob{}); + + auto nftPage = std::make_shared(keylet::nftokenPageMax(a1)); + nftPage->setFieldArray(sfNFTokens, nfTokens); + + ac.view().insert(nftPage); + return true; + }); + + doInvariantCheck( + {{"NFT page is improperly linked"}}, + [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) { + auto nftPage = std::make_shared(keylet::nftokenPageMax(a1)); + nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(1)); + nftPage->setFieldH256(sfPreviousPageMin, keylet::nftokenPageMax(a1).key); + + ac.view().insert(nftPage); + return true; + }); + + doInvariantCheck( + {{"NFT page is improperly linked"}}, + [&makeNFTokenIDs](Account const& a1, Account const& a2, ApplyContext& ac) { + auto nftPage = std::make_shared(keylet::nftokenPageMax(a1)); + nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(1)); + nftPage->setFieldH256(sfPreviousPageMin, keylet::nftokenPageMin(a2).key); + + ac.view().insert(nftPage); + return true; + }); + + doInvariantCheck( + {{"NFT page is improperly linked"}}, + [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) { + auto nftPage = std::make_shared(keylet::nftokenPageMax(a1)); + nftPage->setFieldArray(sfNFTokens, makeNFTokenIDs(1)); + nftPage->setFieldH256(sfNextPageMin, nftPage->key()); + + ac.view().insert(nftPage); + return true; + }); + + doInvariantCheck( + {{"NFT page is improperly linked"}}, + [&makeNFTokenIDs](Account const& a1, Account const& a2, ApplyContext& ac) { + STArray nfTokens = makeNFTokenIDs(1); + auto nftPage = std::make_shared(keylet::nftokenPage( + keylet::nftokenPageMax(a1), ++(nfTokens[0].getFieldH256(sfNFTokenID)))); + nftPage->setFieldArray(sfNFTokens, nfTokens); + nftPage->setFieldH256(sfNextPageMin, keylet::nftokenPageMax(a2).key); + + ac.view().insert(nftPage); + return true; + }); + + doInvariantCheck( + {{"NFT found in incorrect page"}}, + [&makeNFTokenIDs](Account const& a1, Account const&, ApplyContext& ac) { + STArray nfTokens = makeNFTokenIDs(2); + auto nftPage = std::make_shared(keylet::nftokenPage( + keylet::nftokenPageMax(a1), (nfTokens[1].getFieldH256(sfNFTokenID)))); + nftPage->setFieldArray(sfNFTokens, nfTokens); + + ac.view().insert(nftPage); + return true; + }); + } + + void + run() override + { + testNoZeroEscrow(); + testNFTokenPageInvariants(); + } +}; + +BEAST_DEFINE_TESTSUITE(InvariantsEscrowNFT, app, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/invariants/InvariantsMPT_test.cpp b/src/test/app/invariants/InvariantsMPT_test.cpp new file mode 100644 index 0000000000..4692463baa --- /dev/null +++ b/src/test/app/invariants/InvariantsMPT_test.cpp @@ -0,0 +1,1577 @@ +#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 +#include +#include +#include + +namespace xrpl::test { + +class InvariantsMPT_test : public InvariantsBase +{ + FeatureBitset const all_{test::jtx::testableAmendments()}; + + void + testMPT() + { + using namespace test::jtx; + testcase << "MPT"; + + MPTIssue const nonCanonicalMPTIssue{makeMptID(1, AccountID(0x4985601))}; + auto const nonCanonicalMPTAmount = [&](SField const& field) { + return STAmount{ + field, + nonCanonicalMPTIssue, + kMaxMpTokenAmount + std::uint64_t{1}, + 0, + false, + STAmount::Unchecked{}}; + }; + auto const negativeMPTAmount = [&](SField const& field) { + return STAmount{field, nonCanonicalMPTIssue, 2, 0, true, STAmount::Unchecked{}}; + }; + auto const nonCanonicalMPTPayment = [&]() { + return STTx{ttPAYMENT, [&](STObject& tx) { + tx.setFieldAmount(sfAmount, nonCanonicalMPTAmount(sfAmount)); + }}; + }; + + doInvariantCheck( + makeEnv(all_ - fixCleanup3_2_0), + {}, + [](Account const&, Account const&, ApplyContext&) { return true; }, + XRPAmount{}, + nonCanonicalMPTPayment(), + {tesSUCCESS, tesSUCCESS}); + + doInvariantCheck( + {{"ledger entry contains non-canonical MPT or XRP amount"}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + + auto sleNew = std::make_shared( + keylet::check(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence]))); + sleNew->setAccountID(sfAccount, a1.id()); + sleNew->setAccountID(sfDestination, a2.id()); + sleNew->setFieldAmount(sfSendMax, nonCanonicalMPTAmount(sfSendMax)); + ac.view().insert(sleNew); + return true; + }); + + doInvariantCheck( + {{"ledger entry contains non-canonical MPT or XRP amount"}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + + auto sleNew = std::make_shared( + keylet::check(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence]))); + sleNew->setAccountID(sfAccount, a1.id()); + sleNew->setAccountID(sfDestination, a2.id()); + sleNew->setFieldAmount(sfSendMax, negativeMPTAmount(sfSendMax)); + ac.view().insert(sleNew); + return true; + }); + + // MPT OutstandingAmount > MaximumAmount + doInvariantCheck( + {{"OutstandingAmount overflow"}}, + [](Account const& a1, Account const&, ApplyContext& ac) { + // mptissuance outstanding is negative + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + + MPTIssue const mpt{makeMptID(sle->getFieldU32(sfSequence), a1)}; + auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID())); + sleNew->setFieldU64(sfOutstandingAmount, 110); + sleNew->setFieldU64(sfMaximumAmount, 100); + ac.view().insert(sleNew); + return true; + }); + + // MPTToken amount doesn't add up to OutstandingAmount + doInvariantCheck( + {{"invalid OutstandingAmount balance"}}, + [](Account const& a1, Account const& a2, ApplyContext& ac) { + // mptissuance outstanding is negative + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + + MPTIssue const mpt{makeMptID(sle->getFieldU32(sfSequence), a1)}; + auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID())); + sleNew->setFieldU64(sfOutstandingAmount, 100); + sleNew->setFieldU64(sfMaximumAmount, 100); + ac.view().insert(sleNew); + + sleNew = std::make_shared(keylet::mptoken(mpt.getMptID(), a2)); + sleNew->setFieldU64(sfMPTAmount, 90); + ac.view().insert(sleNew); + + return true; + }); + + // Overflow/Invalid balance on payment + auto testPayment = [&](std::string const& log, auto&& update) { + MPTID id; + doInvariantCheck( + {{log}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + return update(id, ac, a1); + }, + XRPAmount{}, + STTx{ttPAYMENT, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + Account const gw("gw"); + env.fund(XRP(1'000), gw); + MPTTester const mpt( + {.env = env, .issuer = gw, .holders = {a1}, .pay = 100, .maxAmt = 100}); + id = mpt.issuanceID(); + return true; + }); + }; + testPayment( + "invalid OutstandingAmount balance", + [&](MPTID const& id, ApplyContext& ac, Account const& a1) { + auto sle = ac.view().peek(keylet::mptoken(id, a1)); + if (!sle) + return false; + sle->setFieldU64(sfMPTAmount, 101); + ac.view().update(sle); + return true; + }); + testPayment( + "OutstandingAmount overflow", [&](MPTID const& id, ApplyContext& ac, Account const&) { + auto sle = ac.view().peek(keylet::mptokenIssuance(id)); + if (!sle) + return false; + sle->setFieldU64(sfOutstandingAmount, 101); + ac.view().update(sle); + return true; + }); + + // The on-failure MPT checks (OutstandingAmount balance / transfer) apply + // to every non-tesSUCCESS result, with no per-result exemption: on a tec + // the transactor discards the view and re-applies only offer, trust + // line, NFT offer and credential deletions, so an MPT change reaching + // the invariant is a bug whatever the code. Seeded via initialResult. + { + MPTID id; + // preclose: gw issues an MPT held by A1 and A2. + auto const setup = [&](Account const& a1, Account const& a2, Env& env) { + Account const gw("gw"); + env.fund(XRP(1'000), gw); + MPTTester const mpt( + {.env = env, .issuer = gw, .holders = {a1, a2}, .pay = 50, .maxAmt = 1'000}); + id = mpt.issuanceID(); + return true; + }; + + // Consistent mint: OutstandingAmount and A1's balance both grow by + // 10, so conservation holds and only the on-failure check fires. + Precheck const mint = [&](Account const& a1, Account const&, ApplyContext& ac) { + auto sleIss = ac.view().peek(keylet::mptokenIssuance(id)); + auto sleTok = ac.view().peek(keylet::mptoken(id, a1.id())); + if (!sleIss || !sleTok) + return false; + (*sleIss)[sfOutstandingAmount] = (*sleIss)[sfOutstandingAmount] + 10; + (*sleTok)[sfMPTAmount] = (*sleTok)[sfMPTAmount] + 10; + ac.view().update(sleIss); + ac.view().update(sleTok); + return true; + }; + + // Holder-to-holder transfer (A1 -> A2 by 10). OutstandingAmount is + // unchanged, and CanTransfer keeps the ordinary transfer check + // quiet, so only the on-failure check fires. + Precheck const transfer = [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto sleIss = ac.view().peek(keylet::mptokenIssuance(id)); + auto sleA = ac.view().peek(keylet::mptoken(id, a1.id())); + auto sleB = ac.view().peek(keylet::mptoken(id, a2.id())); + if (!sleIss || !sleA || !sleB) + return false; + (*sleIss)[sfFlags] = (*sleIss)[sfFlags] | lsfMPTCanTransfer; + (*sleA)[sfMPTAmount] = (*sleA)[sfMPTAmount] - 10; + (*sleB)[sfMPTAmount] = (*sleB)[sfMPTAmount] + 10; + ac.view().update(sleIss); + ac.view().update(sleA); + ac.view().update(sleB); + return true; + }; + + STTx const payment{ttPAYMENT, [](STObject&) {}}; + + // Negative controls: nothing fires on tesSUCCESS. Without these, the + // cases below would still pass if the result guard were dropped. + doInvariantCheck({}, mint, XRPAmount{}, payment, {tesSUCCESS, tesSUCCESS}, setup); + doInvariantCheck({}, transfer, XRPAmount{}, payment, {tesSUCCESS, tesSUCCESS}, setup); + + // tecKILLED and tecINCOMPLETE are not special: an MPT change paired + // with either fires, as with any other failure. + doInvariantCheck( + {{"OutstandingAmount balance changed on failure"}}, + mint, + XRPAmount{}, + payment, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + setup, + TxAccount::None, + std::source_location::current(), + tecKILLED); + doInvariantCheck( + {{"OutstandingAmount balance changed on failure"}}, + mint, + XRPAmount{}, + payment, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + setup, + TxAccount::None, + std::source_location::current(), + tecINCOMPLETE); + doInvariantCheck( + {{"MPToken balance changed on failure"}}, + transfer, + XRPAmount{}, + payment, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + setup, + TxAccount::None, + std::source_location::current(), + tecKILLED); + doInvariantCheck( + {{"MPToken balance changed on failure"}}, + transfer, + XRPAmount{}, + payment, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + setup, + TxAccount::None, + std::source_location::current(), + tecINCOMPLETE); + // The same change under a third failure result: the check keys off + // "not tesSUCCESS", nothing finer. + doInvariantCheck( + {{"OutstandingAmount balance changed on failure"}}, + mint, + XRPAmount{}, + payment, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + setup, + TxAccount::None, + std::source_location::current(), + tecEXPIRED); + doInvariantCheck( + {{"MPToken balance changed on failure"}}, + transfer, + XRPAmount{}, + payment, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + setup, + TxAccount::None, + std::source_location::current(), + tecEXPIRED); + + // A lock moves value within one holder, so it is not a two-sided + // transfer and the `senders || receivers` form is what catches it. + // OutstandingAmount and the holder total are unchanged, so the + // balance check stays quiet. + Precheck const lock = [&](Account const& a1, Account const&, ApplyContext& ac) { + auto sleTok = ac.view().peek(keylet::mptoken(id, a1.id())); + if (!sleTok || (*sleTok)[sfMPTAmount] < 10) + return false; + // A fresh MPToken has no locked amount, so set it directly. + (*sleTok)[sfMPTAmount] = (*sleTok)[sfMPTAmount] - 10; + sleTok->setFieldU64(sfLockedAmount, 10); + ac.view().update(sleTok); + return true; + }; + // Negative control: a lock is legitimate on tesSUCCESS. + doInvariantCheck({}, lock, XRPAmount{}, payment, {tesSUCCESS, tesSUCCESS}, setup); + doInvariantCheck( + {{"MPToken balance changed on failure"}}, + lock, + XRPAmount{}, + payment, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + setup, + TxAccount::None, + std::source_location::current(), + tecKILLED); + // The lock is caught under any failure result. + doInvariantCheck( + {{"MPToken balance changed on failure"}}, + lock, + XRPAmount{}, + payment, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + setup, + TxAccount::None, + std::source_location::current(), + tecEXPIRED); + + // A deleted MPToken has no amtAfter, so the sender/receiver counts + // skip it and only the deletedAuthorized_ term can catch it. That + // needs holders authorized but never paid, so the MPToken can be + // erased with a zero balance and OutstandingAmount untouched -- + // otherwise the holder would register as a sender instead. + MPTID emptyId; + auto const setupEmpty = [&](Account const& a1, Account const& a2, Env& env) { + Account const gw("gw"); + env.fund(XRP(1'000), gw); + MPTTester const mpt({.env = env, .issuer = gw, .holders = {a1, a2}, .maxAmt = 100}); + emptyId = mpt.issuanceID(); + return true; + }; + Precheck const eraseToken = [&](Account const& a1, Account const&, ApplyContext& ac) { + auto sleTok = ac.view().peek(keylet::mptoken(emptyId, a1.id())); + if (!sleTok || (*sleTok)[sfMPTAmount] != 0) + return false; + ac.view().erase(sleTok); + return true; + }; + // ValidMPTIssuance also reports the deletion, so assert on + // ValidMPTTransfer's message, which only the new check can produce. + doInvariantCheck( + {{"MPToken deleted on failure"}}, + eraseToken, + XRPAmount{}, + payment, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + setupEmpty, + TxAccount::None, + std::source_location::current(), + tecEXPIRED); + } + + // Invalid IOU clawback delta must fail once MPTokensV2 enforces before/after validation. + { + Env env(*this, all_); + Account const issuer{"issuer"}; + Account const holder{"holder"}; + Account const other{"other"}; + env.fund(XRP(1'000), issuer, holder, other); + auto const usd = issuer["USD"]; + env.trust(usd(100), holder); + env(pay(issuer, holder, usd(100))); + env.close(); + + doInvariantCheck( + std::move(env), + holder, + other, + {{"Invariant failed: trustline clawback balance change is invalid"}}, + [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) { + auto sle = + ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency)); + if (!sle) + return false; + + STAmount balance{Issue{usd.currency, issuer.id()}, 80}; + if (holder.id() > issuer.id()) + balance.negate(); + sle->setFieldAmount(sfBalance, balance); + ac.view().update(sle); + return true; + }, + XRPAmount{}, + STTx{ + ttCLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = issuer.id(); + tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10}; + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + } + + // Full IOU clawback may delete the trustline; missing after-SLE represents zero balance. + { + Env env(*this, all_); + Account const issuer{"issuer"}; + Account const holder{"holder"}; + Account const other{"other"}; + env.fund(XRP(1'000), issuer, holder, other); + auto const usd = issuer["USD"]; + env.trust(usd(100), holder); + env(pay(issuer, holder, usd(100))); + env.close(); + + doInvariantCheck( + std::move(env), + holder, + other, + {}, + [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) { + auto const sle = + ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency)); + if (!sle) + return false; + + ac.view().erase(sle); + return true; + }, + XRPAmount{}, + STTx{ + ttCLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = issuer.id(); + tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 100}; + }}, + {tesSUCCESS, tesSUCCESS}); + } + + // Pre-MPTokensV2 invalid IOU clawback delta logs but remains non-enforcing. + { + Env env(*this, all_ - featureMPTokensV2); + Account const issuer{"issuer"}; + Account const holder{"holder"}; + Account const other{"other"}; + env.fund(XRP(1'000), issuer, holder, other); + auto const usd = issuer["USD"]; + env.trust(usd(100), holder); + env(pay(issuer, holder, usd(100))); + env.close(); + + doInvariantCheck( + std::move(env), + holder, + other, + {{"Invariant failed: trustline clawback balance change is invalid"}}, + [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) { + auto sle = + ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency)); + if (!sle) + return false; + + STAmount balance{Issue{usd.currency, issuer.id()}, 80}; + if (holder.id() > issuer.id()) + balance.negate(); + sle->setFieldAmount(sfBalance, balance); + ac.view().update(sle); + return true; + }, + XRPAmount{}, + STTx{ + ttCLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = issuer.id(); + tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10}; + }}, + {tesSUCCESS, tesSUCCESS}); + } + + // Invalid MPT clawback delta must fail when raw MPToken debit mismatches sfAmount. + { + Env env(*this, all_); + Account const issuer{"issuer"}; + Account const holder{"holder"}; + Account const other{"other"}; + env.fund(XRP(1'000), issuer, holder, other); + MPTTester const mpt( + {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100}); + auto const id = mpt.issuanceID(); + + doInvariantCheck( + std::move(env), + holder, + other, + {{"Invariant failed: MPT clawback balance change is invalid"}}, + [id](Account const& holder, Account const&, ApplyContext& ac) { + auto const sleToken = ac.view().peek(keylet::mptoken(id, holder)); + auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id)); + if (!sleToken || !sleIssuance) + return false; + + sleToken->setFieldU64(sfMPTAmount, 80); + sleIssuance->setFieldU64(sfOutstandingAmount, 80); + ac.view().update(sleToken); + ac.view().update(sleIssuance); + return true; + }, + XRPAmount{}, + STTx{ + ttCLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = issuer.id(); + tx[sfHolder] = holder.id(); + tx[sfAmount] = STAmount{MPTIssue{id}, 10}; + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + } + + // A clawback that mutates both IOU and MPT entries must fail under MPTokensV2. + { + Env env(*this, all_); + Account const issuer{"issuer"}; + Account const holder{"holder"}; + Account const other{"other"}; + env.fund(XRP(1'000), issuer, holder, other); + auto const usd = issuer["USD"]; + env.trust(usd(100), holder); + env(pay(issuer, holder, usd(100))); + MPTTester const mpt( + {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100}); + auto const id = mpt.issuanceID(); + + doInvariantCheck( + std::move(env), + holder, + other, + {{"Invariant failed: trustline and MPToken both changed"}}, + [issuer, usd, id](Account const& holder, Account const&, ApplyContext& ac) { + auto const sleLine = + ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency)); + auto const sleToken = ac.view().peek(keylet::mptoken(id, holder.id())); + auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id)); + if (!sleLine || !sleToken || !sleIssuance) + return false; + + STAmount balance{Issue{usd.currency, issuer.id()}, 90}; + if (holder.id() > issuer.id()) + balance.negate(); + sleLine->setFieldAmount(sfBalance, balance); + sleToken->setFieldU64(sfMPTAmount, 90); + sleIssuance->setFieldU64(sfOutstandingAmount, 90); + ac.view().update(sleLine); + ac.view().update(sleToken); + ac.view().update(sleIssuance); + return true; + }, + XRPAmount{}, + STTx{ + ttCLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = issuer.id(); + tx[sfHolder] = holder.id(); + tx[sfAmount] = STAmount{MPTIssue{id}, 10}; + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + } + + // Clawback that modifies a trustline other than the one implied by the + // tx amount: clawbackTrustLineBalanceInHolderTerms returns nullopt for + // the mismatched line. + { + Env env(*this, all_); + Account const issuer{"issuer"}; + Account const holder{"holder"}; + Account const other{"other"}; + env.fund(XRP(1'000), issuer, holder, other); + auto const usd = issuer["USD"]; + auto const eur = issuer["EUR"]; + env.trust(eur(100), holder); + env(pay(issuer, holder, eur(100))); + env.close(); + + doInvariantCheck( + std::move(env), + holder, + other, + {{"Invariant failed: trustline clawback changed the wrong line"}}, + [issuer, eur](Account const& holder, Account const&, ApplyContext& ac) { + auto sle = + ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), eur.currency)); + if (!sle) + return false; + STAmount balance{Issue{eur.currency, issuer.id()}, 90}; + if (holder.id() > issuer.id()) + balance.negate(); + sle->setFieldAmount(sfBalance, balance); + ac.view().update(sle); + return true; + }, + XRPAmount{}, + STTx{ + ttCLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = issuer.id(); + tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10}; + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + } + + // Clawback leaving the holder's balance negative. + { + Env env(*this, all_); + Account const issuer{"issuer"}; + Account const holder{"holder"}; + Account const other{"other"}; + env.fund(XRP(1'000), issuer, holder, other); + auto const usd = issuer["USD"]; + env.trust(usd(100), holder); + env(pay(issuer, holder, usd(100))); + env.close(); + + doInvariantCheck( + std::move(env), + holder, + other, + {{"Invariant failed: trustline or MPT balance is negative"}}, + [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) { + auto sle = + ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency)); + if (!sle) + return false; + // Make the holder's balance negative from their perspective. + STAmount balance{Issue{usd.currency, issuer.id()}, 80}; + if (holder.id() < issuer.id()) + balance.negate(); + sle->setFieldAmount(sfBalance, balance); + ac.view().update(sle); + return true; + }, + XRPAmount{}, + STTx{ + ttCLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = issuer.id(); + tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10}; + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + } + + // IOU-amount clawback while only an MPToken changed: no trustline was + // recorded, so iou_.before is empty. + { + Env env(*this, all_); + Account const issuer{"issuer"}; + Account const holder{"holder"}; + Account const other{"other"}; + env.fund(XRP(1'000), issuer, holder, other); + auto const usd = issuer["USD"]; + MPTTester const mpt( + {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100}); + auto const id = mpt.issuanceID(); + + doInvariantCheck( + std::move(env), + holder, + other, + {{"Invariant failed: trustline clawback changed the wrong line"}}, + [id](Account const& holder, Account const&, ApplyContext& ac) { + auto const sleToken = ac.view().peek(keylet::mptoken(id, holder)); + auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id)); + if (!sleToken || !sleIssuance) + return false; + sleToken->setFieldU64(sfMPTAmount, 90); + sleIssuance->setFieldU64(sfOutstandingAmount, 90); + ac.view().update(sleToken); + ac.view().update(sleIssuance); + return true; + }, + XRPAmount{}, + STTx{ + ttCLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = issuer.id(); + tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 10}; + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + } + + // Valid trustline change but a zero clawback amount. + { + Env env(*this, all_); + Account const issuer{"issuer"}; + Account const holder{"holder"}; + Account const other{"other"}; + env.fund(XRP(1'000), issuer, holder, other); + auto const usd = issuer["USD"]; + env.trust(usd(100), holder); + env(pay(issuer, holder, usd(100))); + env.close(); + + doInvariantCheck( + std::move(env), + holder, + other, + {{"Invariant failed: trustline clawback amount is invalid"}}, + [issuer, usd](Account const& holder, Account const&, ApplyContext& ac) { + auto sle = + ac.view().peek(keylet::trustLine(holder.id(), issuer.id(), usd.currency)); + if (!sle) + return false; + STAmount balance{Issue{usd.currency, issuer.id()}, 90}; + if (holder.id() > issuer.id()) + balance.negate(); + sle->setFieldAmount(sfBalance, balance); + ac.view().update(sle); + return true; + }, + XRPAmount{}, + STTx{ + ttCLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = issuer.id(); + tx[sfAmount] = STAmount{Issue{usd.currency, holder.id()}, 0}; + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + } + + // MPT clawback tx missing the Holder field. + { + Env env(*this, all_); + Account const issuer{"issuer"}; + Account const holder{"holder"}; + Account const other{"other"}; + env.fund(XRP(1'000), issuer, holder, other); + MPTTester const mpt( + {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100}); + auto const id = mpt.issuanceID(); + + doInvariantCheck( + std::move(env), + holder, + other, + {{"Invariant failed: MPT clawback missing holder"}}, + [id](Account const& holder, Account const&, ApplyContext& ac) { + auto const sleToken = ac.view().peek(keylet::mptoken(id, holder)); + auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id)); + if (!sleToken || !sleIssuance) + return false; + sleToken->setFieldU64(sfMPTAmount, 90); + sleIssuance->setFieldU64(sfOutstandingAmount, 90); + ac.view().update(sleToken); + ac.view().update(sleIssuance); + return true; + }, + XRPAmount{}, + STTx{ + ttCLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = issuer.id(); + tx[sfAmount] = STAmount{MPTIssue{id}, 10}; + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + } + + // MPT clawback where the holder's MPToken was deleted (after is empty). + { + Env env(*this, all_); + Account const issuer{"issuer"}; + Account const holder{"holder"}; + Account const other{"other"}; + env.fund(XRP(1'000), issuer, holder, other); + MPTTester const mpt( + {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100}); + auto const id = mpt.issuanceID(); + + doInvariantCheck( + std::move(env), + holder, + other, + {{"Invariant failed: MPT clawback token is missing"}}, + [id](Account const& holder, Account const&, ApplyContext& ac) { + auto const sleToken = ac.view().peek(keylet::mptoken(id, holder)); + auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id)); + if (!sleToken || !sleIssuance) + return false; + // Keep the issuance consistent after removing the token. + sleIssuance->setFieldU64(sfOutstandingAmount, 0); + ac.view().update(sleIssuance); + ac.view().erase(sleToken); + return true; + }, + XRPAmount{}, + STTx{ + ttCLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = issuer.id(); + tx[sfHolder] = holder.id(); + tx[sfAmount] = STAmount{MPTIssue{id}, 10}; + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + } + + // MPT clawback that changed a different holder's MPToken. + { + Env env(*this, all_); + Account const issuer{"issuer"}; + Account const holder{"holder"}; + Account const other{"other"}; + env.fund(XRP(1'000), issuer, holder, other); + MPTTester const mpt( + {.env = env, + .issuer = issuer, + .holders = {holder, other}, + .pay = 100, + .maxAmt = 200}); + auto const id = mpt.issuanceID(); + + doInvariantCheck( + std::move(env), + holder, + other, + {{"Invariant failed: MPT clawback changed the wrong token"}}, + [id](Account const&, Account const& other, ApplyContext& ac) { + auto const sleToken = ac.view().peek(keylet::mptoken(id, other)); + auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id)); + if (!sleToken || !sleIssuance) + return false; + sleToken->setFieldU64(sfMPTAmount, 90); + sleIssuance->setFieldU64(sfOutstandingAmount, 190); + ac.view().update(sleToken); + ac.view().update(sleIssuance); + return true; + }, + XRPAmount{}, + STTx{ + ttCLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = issuer.id(); + tx[sfHolder] = holder.id(); + tx[sfAmount] = STAmount{MPTIssue{id}, 10}; + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + } + + // Valid MPToken change but a zero MPT clawback amount. + { + Env env(*this, all_); + Account const issuer{"issuer"}; + Account const holder{"holder"}; + Account const other{"other"}; + env.fund(XRP(1'000), issuer, holder, other); + MPTTester const mpt( + {.env = env, .issuer = issuer, .holders = {holder}, .pay = 100, .maxAmt = 100}); + auto const id = mpt.issuanceID(); + + doInvariantCheck( + std::move(env), + holder, + other, + {{"Invariant failed: MPT clawback amount is invalid"}}, + [id](Account const& holder, Account const&, ApplyContext& ac) { + auto const sleToken = ac.view().peek(keylet::mptoken(id, holder)); + auto const sleIssuance = ac.view().peek(keylet::mptokenIssuance(id)); + if (!sleToken || !sleIssuance) + return false; + sleToken->setFieldU64(sfMPTAmount, 90); + sleIssuance->setFieldU64(sfOutstandingAmount, 90); + ac.view().update(sleToken); + ac.view().update(sleIssuance); + return true; + }, + XRPAmount{}, + STTx{ + ttCLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = issuer.id(); + tx[sfHolder] = holder.id(); + tx[sfAmount] = STAmount{MPTIssue{id}, 0}; + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + } + + // More MPTokens created than expected + std::array, 4> const tests = { + std::make_pair(ttAMM_WITHDRAW, 2), + std::make_pair(ttAMM_CLAWBACK, 2), + std::make_pair(ttAMM_CREATE, 3), + std::make_pair(ttCHECK_CASH, 2)}; + for (auto const& [tx, nTokens] : tests) + { + doInvariantCheck( + {{std::string("MPToken created for the MPT issuer")}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + + auto seq = sle->getFieldU32(sfSequence); + for (int i = 0; i < nTokens; ++i) + { + MPTIssue const mpt{makeMptID(seq + i, a1)}; + auto sleNew = + std::make_shared(keylet::mptokenIssuance(mpt.getMptID())); + ac.view().insert(sleNew); + + sleNew = std::make_shared(keylet::mptoken(mpt.getMptID(), a2)); + ac.view().insert(sleNew); + } + + return true; + }, + XRPAmount{}, + STTx{tx, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + } + + // More MPTokens deleted than expected + for (auto const& tx : {ttAMM_WITHDRAW, ttAMM_CLAWBACK}) + { + MPTID id; + Account const a3("A3"); + doInvariantCheck( + {{"MPT authorize succeeded but created/deleted bad number of mptokens"}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + for (auto const& a : {a1, a2, a3}) + { + auto sle = ac.view().peek(keylet::mptoken(id, a)); + if (!sle) + return false; + ac.view().erase(sle); + } + return true; + }, + XRPAmount{}, + STTx{tx, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + Account const gw("gw"); + env.fund(XRP(1'000), gw, a3); + MPTTester const mpt({.env = env, .issuer = gw, .holders = {a1, a2, a3}}); + id = mpt.issuanceID(); + return true; + }); + } + + // sfReferenceHolding can only be set on creation by VaultCreate. A + // non-VaultCreate transaction that creates an MPTokenIssuance with + // sfReferenceHolding present must trip the invariant. + doInvariantCheck( + {{"sfReferenceHolding set on a new MPTokenIssuance by a " + "non-VaultCreate transaction"}}, + [](Account const& a1, Account const&, ApplyContext& ac) { + auto const sleAcct = ac.view().peek(keylet::account(a1.id())); + if (!sleAcct) + return false; + MPTIssue const mpt{makeMptID(sleAcct->getFieldU32(sfSequence), a1)}; + auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID())); + sleNew->setFieldH256(sfReferenceHolding, uint256{1}); + ac.view().insert(sleNew); + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_SET, [](STObject&) {}}); + + // sfReferenceHolding is immutable: changing the field on an + // existing MPTokenIssuance must trip the invariant. Set up a real + // vault via preclose (so the share issuance carries + // sfReferenceHolding), then mutate it in precheck to produce a + // before/after pair. + { + uint256 vaultKey; + doInvariantCheck( + {{"sfReferenceHolding was modified on an existing " + "MPTokenIssuance"}}, + [&](Account const&, Account const&, ApplyContext& ac) { + auto const sleVault = ac.view().peek(keylet::vault(vaultKey)); + if (!sleVault) + return false; + auto sleIssuance = + ac.view().peek(keylet::mptokenIssuance(sleVault->at(sfShareMPTID))); + if (!sleIssuance) + return false; + sleIssuance->setFieldH256(sfReferenceHolding, uint256{2}); + ac.view().update(sleIssuance); + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_SET, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + [&](Account const& a1, Account const&, Env& env) { + Account const issuer{"issuer"}; + env.fund(XRP(10'000), issuer); + env.close(); + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock}); + PrettyAsset const asset = mptt.issuanceID(); + mptt.authorize({.account = a1}); + env.close(); + + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = a1, .asset = asset}); + env(tx); + env.close(); + vaultKey = keylet.key; + return true; + }); + } + + // A vault pseudo-account's MPToken cannot be deleted by anything + // other than a VaultDelete transaction. Set up a vault, then have + // an arbitrary tx erase the pseudo's MPToken in precheck. + { + uint256 vaultKey; + doInvariantCheck( + {{"vault pseudo-account holding deleted by a " + "non-VaultDelete transaction"}}, + [&](Account const&, Account const&, ApplyContext& ac) { + auto const sleVault = ac.view().peek(keylet::vault(vaultKey)); + if (!sleVault) + return false; + auto const sleIssuance = + ac.view().peek(keylet::mptokenIssuance(sleVault->at(sfShareMPTID))); + if (!sleIssuance || !sleIssuance->isFieldPresent(sfReferenceHolding)) + return false; + auto sleHolding = ac.view().peek( + keylet::unchecked(sleIssuance->getFieldH256(sfReferenceHolding))); + if (!sleHolding) + return false; + ac.view().erase(sleHolding); + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_SET, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + [&](Account const& a1, Account const&, Env& env) { + Account const issuer{"issuer"}; + env.fund(XRP(10'000), issuer); + env.close(); + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create({.flags = tfMPTCanTransfer | tfMPTCanLock}); + PrettyAsset const asset = mptt.issuanceID(); + mptt.authorize({.account = a1}); + env.close(); + + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = a1, .asset = asset}); + env(tx); + env.close(); + vaultKey = keylet.key; + return true; + }); + } + + // Invalid transfer + std::array, 3> const invalidTransferTests = { + std::make_pair(ttAMM_WITHDRAW, false), + std::make_pair(ttPAYMENT, false), + std::make_pair(ttPAYMENT, true)}; + // The two amendments that gate enforcement, in all four combinations. + FeatureBitset const gatesEnabled{featureMPTokensV2, fixCleanup3_4_0}; + for (auto const gates : + {gatesEnabled, + gatesEnabled - featureMPTokensV2, + gatesEnabled - fixCleanup3_4_0, + FeatureBitset{}}) + { + for (auto const& [tx, crossCurrencyPayment] : invalidTransferTests) + { + for (auto const flag : + {static_cast(lsfMPTLocked), + ~lsfMPTCanTransfer, + ~lsfMPTCanTrade, + 0u}) + { + MPTID id{}; + auto const isSuccess = !gates.any() || flag == 0 || + (tx == ttPAYMENT && !crossCurrencyPayment && (flag == ~lsfMPTCanTrade)) || + (tx == ttAMM_WITHDRAW && + (flag == ~lsfMPTCanTrade || flag == ~lsfMPTCanTransfer)); + std::pair const error = isSuccess + ? std::make_pair(TER(tesSUCCESS), TER(tesSUCCESS)) + : std::make_pair(TER(tecINVARIANT_FAILED), TER(tefINVARIANT_FAILED)); + doInvariantCheck( + {{isSuccess ? "" : "invalid MPToken transfer between holders"}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto update = [&](AccountID const& a, std::uint64_t v) { + auto sle = ac.view().peek(keylet::mptoken(id, a)); + if (!sle) + return false; + sle->at(sfMPTAmount) = v; + ac.view().update(sle); + return true; + }; + auto issuanceSle = ac.view().peek(keylet::mptokenIssuance(id)); + if (!issuanceSle) + return false; + auto const flags = issuanceSle->at(sfFlags); + if (flag == lsfMPTLocked) + { + issuanceSle->at(sfFlags) = flags | lsfMPTLocked; + } + else if (flag != 0u) + { + issuanceSle->at(sfFlags) = flags & flag; + } + issuanceSle->at(sfOutstandingAmount) = 200; + ac.view().update(issuanceSle); + return update(a1, 101) && update(a2, 99); + }, + XRPAmount{}, + STTx{ + tx, + [&](STObject& tx) { + if (crossCurrencyPayment) + { + tx.setFieldAmount( + sfSendMax, STAmount(MPTAmount{100}, MPTIssue{id})); + } + }}, + {error.first, error.second}, + [&](Account const& a1, Account const& a2, Env& env) { + Account const gw("gw"); + env.fund(XRP(1'000), gw); + MPTTester const usd( + {.env = env, .issuer = gw, .holders = {a1, a2}, .pay = 100}); + id = usd.issuanceID(); + // Either gate enforces, so both must be off to stay + // advisory. Disable after setting up the MPT; the + // next env.close() is what makes it take effect. + if (!gates[featureMPTokensV2]) + env.disableFeature(featureMPTokensV2); + if (!gates[fixCleanup3_4_0]) + env.disableFeature(fixCleanup3_4_0); + return true; + }); + } + } + } + + // An orphan has a zero balance, so only deletion is legitimate (see + // "Skipping Deleted MPTs" in testConfidentialMPTTransfer). + { + MPTID orphanID; + auto const setupOrphan = [&](Account const& a1, Account const& a2, Env& env) { + MPTTester mpt(env, a1, {.holders = {a2}, .fund = false}); + mpt.create({.flags = tfMPTCanTransfer}); + orphanID = mpt.issuanceID(); + // A2 is authorized but never paid, so its balance is zero and + // the issuance can be destroyed while its MPToken lives on. + mpt.authorize({.account = a2}); + mpt.destroy(); + return true; + }; + // ValidMPTBalanceChanges also reports this, so assert on the + // orphan message, which only the missing-issuance branch produces. + doInvariantCheck( + {{"orphaned MPToken balance changed"}}, + [&](Account const&, Account const& a2, ApplyContext& ac) { + auto sleTok = ac.view().peek(keylet::mptoken(orphanID, a2.id())); + if (!sleTok || (*sleTok)[sfMPTAmount] != 0) + return false; + (*sleTok)[sfMPTAmount] = (*sleTok)[sfMPTAmount] + 10; + ac.view().update(sleTok); + return true; + }, + XRPAmount{}, + STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + setupOrphan); + // Negative control: erasing the orphan is how it gets cleaned up. + doInvariantCheck( + {}, + [&](Account const&, Account const& a2, ApplyContext& ac) { + auto sleTok = ac.view().peek(keylet::mptoken(orphanID, a2.id())); + if (!sleTok) + return false; + ac.view().erase(sleTok); + return true; + }, + XRPAmount{}, + STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, + {tesSUCCESS, tesSUCCESS}, + setupOrphan); + // The same erase on a failure. The orphan branch continues, so only + // the pre-loop deletion check can report this one. + doInvariantCheck( + {{"MPToken deleted on failure"}}, + [&](Account const&, Account const& a2, ApplyContext& ac) { + auto sleTok = ac.view().peek(keylet::mptoken(orphanID, a2.id())); + if (!sleTok) + return false; + ac.view().erase(sleTok); + return true; + }, + XRPAmount{}, + STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + setupOrphan, + TxAccount::None, + std::source_location::current(), + tecEXPIRED); + } + + // Vault-share freeze invariant: isVaultPseudoAccountFrozen descends + // through sfReferenceHolding to test the vault's underlying asset for + // each changed holder. + { + Account const gw{"gw"}; + MPTID shareID{}; + + // Vault setup: a1 and a2 both deposit IOU and hold vault shares. + auto const setupVault = [&](Account const& a1, + Account const& a2, + Env& env) -> std::tuple { + env.fund(XRP(1'000), gw); + env.trust(gw["IOU"](10'000), a1); + env.trust(gw["IOU"](10'000), a2); + env.close(); + env(pay(gw, a1, gw["IOU"](500))); + env(pay(gw, a2, gw["IOU"](500))); + env.close(); + + Vault const vault{env}; + auto [createTx, vaultKeylet] = vault.create({.owner = a1, .asset = gw["IOU"]}); + env(createTx); + env.close(); + env(vault.deposit( + {.depositor = a1, .id = vaultKeylet.key, .amount = gw["IOU"](100)})); + env(vault.deposit( + {.depositor = a2, .id = vaultKeylet.key, .amount = gw["IOU"](100)})); + env.close(); + + return {env.le(vaultKeylet)->at(sfShareMPTID), env.le(vaultKeylet)->at(sfAccount)}; + }; + + // Simulate a vault-share transfer: a1 sends 10 shares to a2. + auto const precheck = + [&](Account const& a1, Account const& a2, ApplyContext& ac) -> bool { + auto sle1 = ac.view().peek(keylet::mptoken(shareID, a1.id())); + auto sle2 = ac.view().peek(keylet::mptoken(shareID, a2.id())); + if (!sle1 || !sle2) + return false; + (*sle1)[sfMPTAmount] -= 10; + (*sle2)[sfMPTAmount] += 10; + ac.view().update(sle1); + ac.view().update(sle2); + return true; + }; + + // Case: vault pseudo-account's IOU trustline is frozen. + { + auto const preclose = [&](Account const& a1, Account const& a2, Env& env) -> bool { + auto [sid, vid] = setupVault(a1, a2, env); + shareID = sid; + env(trust(gw, gw["IOU"](0), Account{"vaultPseudo", vid}, tfSetFreeze)); + env.close(); + return true; + }; + + doInvariantCheck( + Env{*this, all_}, + {{"invalid MPToken transfer between holders"}}, + precheck, + XRPAmount{}, + STTx{ttPAYMENT, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + preclose); + } + + // Case: receiver's (a2's) IOU trustline is frozen. + { + auto const preclose = [&](Account const& a1, Account const& a2, Env& env) -> bool { + auto [sid, vid] = setupVault(a1, a2, env); + shareID = sid; + env(trust(gw, gw["IOU"](0), a2, tfSetFreeze)); + env.close(); + return true; + }; + + doInvariantCheck( + Env{*this, all_}, + {{"invalid MPToken transfer between holders"}}, + precheck, + XRPAmount{}, + STTx{ttPAYMENT, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + preclose); + } + } + } + + void + testConfidentialMPTTransfer() + { + using namespace test::jtx; + testcase << "ValidConfidentialMPToken"; + + MPTID mptID; + + // Generate an MPT with privacy, issue 100 tokens to A2. + // Perform a confidential conversion to populate encrypted state. + auto const precloseConfidential = + [&mptID](Account const& a1, Account const& a2, Env& env) -> bool { + MPTTester mpt(env, a1, {.holders = {a2}, .fund = false}); + mpt.create({.flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance}); + mptID = mpt.issuanceID(); + + mpt.authorize({.account = a2}); + mpt.pay(a1, a2, 100); + + mpt.generateKeyPair(a1); + mpt.set({.account = a1, .issuerPubKey = mpt.getPubKey(a1)}); + + mpt.generateKeyPair(a2); + mpt.convert({ + .account = a2, + .amt = 100, + .holderPubKey = mpt.getPubKey(a2), + }); + return true; + }; + + // badDelete + doInvariantCheck( + {"MPToken deleted with encrypted fields while COA > 0"}, + [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) { + auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id())); + if (!sleToken) + return false; + // Force an erase of the object while the COA remains 100 + ac.view().erase(sleToken); + return true; + }, + XRPAmount{}, + STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseConfidential); + + // badConsistency + doInvariantCheck( + {"MPToken encrypted field existence inconsistency"}, + [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) { + auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id())); + if (!sleToken) + return false; + // Remove one of the required encrypted fields to create a mismatch + sleToken->makeFieldAbsent(sfIssuerEncryptedBalance); + ac.view().update(sleToken); + return true; + }, + XRPAmount{}, + STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseConfidential); + + doInvariantCheck( + {"MPToken encrypted field existence inconsistency"}, + [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) { + auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id())); + if (!sleToken) + return false; + sleToken->makeFieldAbsent(sfIssuerEncryptedBalance); + sleToken->makeFieldAbsent(sfConfidentialBalanceInbox); + sleToken->makeFieldAbsent(sfConfidentialBalanceSpending); + sleToken->setFieldVL(sfAuditorEncryptedBalance, Blob{0x00}); + ac.view().update(sleToken); + return true; + }, + XRPAmount{}, + STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseConfidential); + + // requiresPrivacyFlag + auto const precloseNoPrivacy = [&mptID]( + Account const& a1, Account const& a2, Env& env) -> bool { + MPTTester mpt(env, a1, {.holders = {a2}, .fund = false}); + // completely omitted the tfMPTCanHoldConfidentialBalance flag here. + mpt.create({.flags = tfMPTCanTransfer}); + mptID = mpt.issuanceID(); + mpt.authorize({.account = a2}); + mpt.pay(a1, a2, 100); + return true; + }; + + doInvariantCheck( + {"MPToken has encrypted fields but Issuance does not have " + "lsfMPTCanHoldConfidentialBalance " + "set"}, + [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) { + auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id())); + if (!sleToken) + return false; + // Inject all three encrypted fields consistently (inbox+spending+issuer must be + // in sync or badConsistency fires first and masks requiresPrivacyFlag). + sleToken->setFieldVL(sfConfidentialBalanceInbox, Blob{0x00}); + sleToken->setFieldVL(sfConfidentialBalanceSpending, Blob{0x00}); + sleToken->setFieldVL(sfIssuerEncryptedBalance, Blob{0x00}); + ac.view().update(sleToken); + return true; + }, + XRPAmount{}, + STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseNoPrivacy); + + // badCOA + doInvariantCheck( + {"Confidential outstanding amount exceeds total outstanding amount"}, + [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) { + auto sleIssuance = ac.view().peek(keylet::mptokenIssuance(mptID)); + if (!sleIssuance) + return false; + // Total outstanding is natively 100; bloat the COA over 100 + sleIssuance->setFieldU64(sfConfidentialOutstandingAmount, 200); + ac.view().update(sleIssuance); + return true; + }, + XRPAmount{}, + STTx{ttMPTOKEN_ISSUANCE_SET, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseConfidential); + + // Conservation Violation + doInvariantCheck( + {"Token conservation violation for MPT"}, + [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) { + auto sleIssuance = ac.view().peek(keylet::mptokenIssuance(mptID)); + if (!sleIssuance) + return false; + + sleIssuance->setFieldU64( + sfConfidentialOutstandingAmount, + sleIssuance->getFieldU64(sfConfidentialOutstandingAmount) - 10); + ac.view().update(sleIssuance); + + return true; + }, + XRPAmount{}, + STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseConfidential); + + // Send/MergeInbox must not change OutstandingAmount (coaDelta == 0) + doInvariantCheck( + {"Invariant failed: OutstandingAmount changed " + "by confidential transaction that should not " + "modify it for MPT"}, + [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) { + auto sleIssuance = ac.view().peek(keylet::mptokenIssuance(mptID)); + if (!sleIssuance) + return false; + sleIssuance->setFieldU64( + sfOutstandingAmount, sleIssuance->getFieldU64(sfOutstandingAmount) + 1); + ac.view().update(sleIssuance); + return true; + }, + XRPAmount{}, + STTx{ttCONFIDENTIAL_MPT_SEND, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseConfidential); + + // Send/MergeInbox and zero-COA-delta confidential transactions must not + // change public holder MPTAmount. + doInvariantCheck( + {"Invariant failed: MPTAmount changed by confidential " + "transaction that should not modify this field."}, + [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) { + auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id())); + if (!sleToken) + return false; + sleToken->setFieldU64(sfMPTAmount, sleToken->getFieldU64(sfMPTAmount) + 1); + ac.view().update(sleToken); + return true; + }, + XRPAmount{}, + STTx{ttCONFIDENTIAL_MPT_SEND, [](STObject&) {}}, + // Second pass is tef: the bumped MPTAmount also trips + // ValidMPTTransfer's on-failure check, which escalates the tec. + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseConfidential); + + // badVersion + doInvariantCheck( + {"MPToken sfConfidentialBalanceVersion not updated when sfConfidentialBalanceSpending " + "changed"}, + [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) { + Blob const kChangedConfidentialSpending = {0xBA, 0xDD}; + auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id())); + if (!sleToken) + return false; + sleToken->setFieldVL(sfConfidentialBalanceSpending, kChangedConfidentialSpending); + + // DO NOT update sfConfidentialBalanceVersion + ac.view().update(sleToken); + return true; + }, + XRPAmount{}, + STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseConfidential); + + // Skipping Deleted MPTs (Issuance deleted) + auto const precloseOrphan = [&mptID]( + Account const& a1, Account const& a2, Env& env) -> bool { + MPTTester mpt(env, a1, {.holders = {a2}, .fund = false}); + mpt.create({.flags = tfMPTCanTransfer | tfMPTCanHoldConfidentialBalance}); + mptID = mpt.issuanceID(); + mpt.authorize({.account = a2}); + + // Generate privacy keys and convert 0 amount so Bob has the encrypted fields + mpt.generateKeyPair(a1); + mpt.set({.account = a1, .issuerPubKey = mpt.getPubKey(a1)}); + mpt.generateKeyPair(a2); + mpt.convert({ + .account = a2, + .amt = 0, + .holderPubKey = mpt.getPubKey(a2), + }); + + // Immediately destroy the issuance. A2's empty, encrypted token object lives on. + mpt.destroy(); + return true; + }; + + doInvariantCheck( + {}, + [&mptID](Account const& a1, Account const& a2, ApplyContext& ac) { + auto sleToken = ac.view().peek(keylet::mptoken(mptID, a2.id())); + if (!sleToken) + return false; + // Safely able to erase the deleted token. + ac.view().erase(sleToken); + return true; + }, + XRPAmount{}, + STTx{ttMPTOKEN_AUTHORIZE, [](STObject&) {}}, + {tesSUCCESS, tesSUCCESS}, + precloseOrphan); + } + +public: + void + run() override + { + testConfidentialMPTTransfer(); + testMPT(); + } +}; + +BEAST_DEFINE_TESTSUITE(InvariantsMPT, app, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/invariants/InvariantsMisc_test.cpp b/src/test/app/invariants/InvariantsMisc_test.cpp new file mode 100644 index 0000000000..b0b6c02f5c --- /dev/null +++ b/src/test/app/invariants/InvariantsMisc_test.cpp @@ -0,0 +1,1333 @@ +#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 +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl::test { + +class InvariantsMisc_test : public InvariantsBase +{ + FeatureBitset const all_{test::jtx::testableAmendments()}; + + void + testXRPNotCreated() + { + using namespace test::jtx; + testcase << "XRP created"; + doInvariantCheck( + {{"XRP net change was positive: 500"}}, + [](Account const& a1, Account const&, ApplyContext& ac) { + // put a single account in the view and "manufacture" some XRP + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + auto amt = sle->getFieldAmount(sfBalance); + sle->setFieldAmount(sfBalance, amt + STAmount{500}); + ac.view().update(sle); + return true; + }); + } + + void + testAccountRootsNotRemoved() + { + using namespace test::jtx; + testcase << "account root removed"; + + // An account was deleted, but not by an AccountDelete transaction. + doInvariantCheck( + {{"an account root was deleted"}}, + [](Account const& a1, Account const&, ApplyContext& ac) { + // remove an account from the view + auto sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + // Clear the balance so the "account deletion left behind a + // non-zero balance" check doesn't trip earlier than the desired + // check. + sle->at(sfBalance) = beast::kZero; + ac.view().erase(sle); + return true; + }); + + // Successful AccountDelete transaction that didn't delete an account. + // + // Note that this is a case where a second invocation of the invariant + // checker returns a tecINVARIANT_FAILED, not a tefINVARIANT_FAILED. + // After a discussion with the team, we believe that's okay. + doInvariantCheck( + {{"account deletion succeeded without deleting an account"}}, + [](Account const&, Account const&, ApplyContext& ac) { return true; }, + XRPAmount{}, + STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); + + // Successful AccountDelete that deleted more than one account. + doInvariantCheck( + {{"account deletion succeeded but deleted multiple accounts"}}, + [](Account const& a1, Account const& a2, ApplyContext& ac) { + // remove two accounts from the view + auto sleA1 = ac.view().peek(keylet::account(a1.id())); + auto sleA2 = ac.view().peek(keylet::account(a2.id())); + if (!sleA1 || !sleA2) + return false; + // Clear the balance so the "account deletion left behind a + // non-zero balance" check doesn't trip earlier than the desired + // check. + sleA1->at(sfBalance) = beast::kZero; + sleA2->at(sfBalance) = beast::kZero; + ac.view().erase(sleA1); + ac.view().erase(sleA2); + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}); + } + + void + testAccountRootsDeletedClean() + { + using namespace test::jtx; + testcase << "account root deletion left artifact"; + + doInvariantCheck( + {{"account deletion left behind a non-zero balance"}}, + // NOLINTNEXTLINE(readability-identifier-naming) + [&](Account const& A1, Account const& A2, ApplyContext& ac) { + // A1 has a balance. Delete A1 + auto const a1 = A1.id(); + auto const sleA1 = ac.view().peek(keylet::account(a1)); + if (!sleA1) + return false; + if (!BEAST_EXPECT(*sleA1->at(sfBalance) != beast::kZero)) + return false; + + ac.view().erase(sleA1); + + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}); + + doInvariantCheck( + {{"account deletion left behind a non-zero owner count"}}, + // NOLINTNEXTLINE(readability-identifier-naming) + [&](Account const& A1, Account const& A2, ApplyContext& ac) { + // Increment A1's owner count, then delete A1 + auto const a1 = A1.id(); + auto const sleA1 = ac.view().peek(keylet::account(a1)); + if (!sleA1) + return false; + // Clear the balance so the "account deletion left behind a + // non-zero balance" check doesn't trip earlier than the desired + // check. + sleA1->at(sfBalance) = beast::kZero; + BEAST_EXPECT(sleA1->at(sfOwnerCount) == 0); + increaseOwnerCount(ac.view(), sleA1, {}, 1, ac.journal); + + ac.view().erase(sleA1); + + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}); + + doInvariantCheck( + {{"account deletion left behind a sponsorship field"}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const sleA1 = ac.view().peek(keylet::account(a1.id())); + if (!sleA1) + return false; + sleA1->at(sfBalance) = beast::kZero; + sleA1->setFieldU32(sfSponsoredOwnerCount, 1); + + ac.view().erase(sleA1); + + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}); + + doInvariantCheck( + {{"account deletion left behind a sponsorship field"}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const sleA1 = ac.view().peek(keylet::account(a1.id())); + if (!sleA1) + return false; + sleA1->at(sfBalance) = beast::kZero; + sleA1->setFieldU32(sfSponsoringOwnerCount, 1); + + ac.view().erase(sleA1); + + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}); + + doInvariantCheck( + {{"account deletion left behind a sponsorship field"}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const a1Id = a1.id(); + auto const sleA1 = ac.view().peek(keylet::account(a1Id)); + if (!sleA1) + return false; + sleA1->at(sfBalance) = beast::kZero; + sleA1->setFieldU32(sfSponsoringAccountCount, 1); + + ac.view().erase(sleA1); + + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}); + + doInvariantCheck( + {{"account deletion left behind a sponsorship field"}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const sleA1 = ac.view().peek(keylet::account(a1.id())); + if (!sleA1) + return false; + sleA1->at(sfBalance) = beast::kZero; + sleA1->setAccountID(sfSponsor, a2.id()); + + ac.view().erase(sleA1); + + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}); + + doInvariantCheck( + Env{*this, FeatureBitset{featureSponsor}}, + {{"account deletion left behind a sponsorship field"}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const sleA1 = ac.view().peek(keylet::account(a1.id())); + if (!sleA1) + return false; + sleA1->at(sfBalance) = beast::kZero; + sleA1->setAccountID(sfSponsor, a2.id()); + + ac.view().erase(sleA1); + + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}); + + for (auto const& [keyletfunc, type, includeInTests] : kDirectAccountKeylets) + { + if (!includeInTests) + continue; + + using namespace std::string_literals; + + doInvariantCheck( + {{"account deletion left behind a "s + type.cStr() + " object"}}, + // NOLINTNEXTLINE(readability-identifier-naming) + [&](Account const& A1, Account const& A2, ApplyContext& ac) { + // Add an object to the ledger for account A1, then delete + // A1 + auto const a1 = A1.id(); + auto sleA1 = ac.view().peek(keylet::account(a1)); + if (!sleA1) + return false; + + auto const key = std::invoke(keyletfunc, a1); + auto const newSLE = std::make_shared(key); + ac.view().insert(newSLE); + // Clear the balance so the "account deletion left behind a + // non-zero balance" check doesn't trip earlier than the + // desired check. + sleA1->at(sfBalance) = beast::kZero; + ac.view().erase(sleA1); + + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}); + } + + // NFT special case + doInvariantCheck( + {{"account deletion left behind a NFTokenPage object"}}, + [&](Account const& a1, Account const&, ApplyContext& ac) { + // remove an account from the view + auto sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + // Clear the balance so the "account deletion left behind a + // non-zero balance" check doesn't trip earlier than the desired + // check. + sle->at(sfBalance) = beast::kZero; + sle->at(sfOwnerCount) = 0; + ac.view().erase(sle); + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_DELETE, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + [&](Account const& a1, Account const&, Env& env) { + // Preclose callback to mint the NFT which will be deleted in + // the Precheck callback above. + env(token::mint(a1)); + + return true; + }); + + // AMM special cases + AccountID ammAcctID; + uint256 ammKey; + Issue ammIssue; + doInvariantCheck( + {{"account deletion left behind a DirectoryNode object"}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + // Delete the AMM account without cleaning up the directory or + // deleting the AMM object + auto sle = ac.view().peek(keylet::account(ammAcctID)); + if (!sle) + return false; + + BEAST_EXPECT(sle->at(~sfAMMID)); + BEAST_EXPECT(sle->at(~sfAMMID) == ammKey); + + // Clear the balance so the "account deletion left behind a + // non-zero balance" check doesn't trip earlier than the desired + // check. + sle->at(sfBalance) = beast::kZero; + sle->at(sfOwnerCount) = 0; + ac.view().erase(sle); + + return true; + }, + XRPAmount{}, + STTx{ttAMM_WITHDRAW, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + // Preclose callback to create the AMM which will be partially + // deleted in the Precheck callback above. + AMM const amm(env, a1, XRP(100), a1["USD"](50)); + ammAcctID = amm.ammAccount(); + ammKey = amm.ammID(); + ammIssue = amm.lptIssue(); + return true; + }); + doInvariantCheck( + {{"account deletion left behind a AMM object"}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + // Delete all the AMM's trust lines, remove the AMM from the AMM + // account's directory (this deletes the directory), and delete + // the AMM account. Do not delete the AMM object. + auto sle = ac.view().peek(keylet::account(ammAcctID)); + if (!sle) + return false; + + BEAST_EXPECT(sle->at(~sfAMMID)); + BEAST_EXPECT(sle->at(~sfAMMID) == ammKey); + + for (auto const& trustKeylet : + {keylet::trustLine(ammAcctID, a1["USD"]), keylet::trustLine(a1, ammIssue)}) + { + auto const line = ac.view().peek(trustKeylet); + if (!line) + { + return false; + } + + STAmount const lowLimit = line->at(sfLowLimit); + STAmount const highLimit = line->at(sfHighLimit); + BEAST_EXPECT( + trustDelete( + ac.view(), + line, + lowLimit.getIssuer(), + highLimit.getIssuer(), + ac.journal) == tesSUCCESS); + } + + auto const ammSle = ac.view().peek(keylet::amm(ammKey)); + if (!BEAST_EXPECT(ammSle)) + return false; + auto const ownerDirKeylet = keylet::ownerDir(ammAcctID); + + BEAST_EXPECT( + ac.view().dirRemove(ownerDirKeylet, ammSle->at(sfOwnerNode), ammKey, false)); + BEAST_EXPECT( + !ac.view().exists(ownerDirKeylet) || ac.view().emptyDirDelete(ownerDirKeylet)); + + // Clear the balance so the "account deletion left behind a + // non-zero balance" check doesn't trip earlier than the desired + // check. + sle->at(sfBalance) = beast::kZero; + sle->at(sfOwnerCount) = 0; + ac.view().erase(sle); + + return true; + }, + XRPAmount{}, + STTx{ttAMM_WITHDRAW, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + // Preclose callback to create the AMM which will be partially + // deleted in the Precheck callback above. + AMM const amm(env, a1, XRP(100), a1["USD"](50)); + ammAcctID = amm.ammAccount(); + ammKey = amm.ammID(); + ammIssue = amm.lptIssue(); + return true; + }); + } + + void + testTypesMatch() + { + using namespace test::jtx; + testcase << "ledger entry types don't match"; + doInvariantCheck( + {{"ledger entry type mismatch"}, {"XRP net change of -1000000000 doesn't match fee 0"}}, + [](Account const& a1, Account const&, ApplyContext& ac) { + // replace an entry in the table with an SLE of a different type + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + auto const sleNew = std::make_shared(ltTICKET, sle->key()); + ac.rawView().rawReplace(sleNew); + return true; + }); + + doInvariantCheck( + {{"invalid ledger entry type added"}}, + [](Account const& a1, Account const&, ApplyContext& ac) { + // add an entry in the table with an SLE of an invalid type + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + + // make a dummy escrow ledger entry, then change the type to an + // unsupported value so that the valid type invariant check + // will fail. + auto const sleNew = std::make_shared( + keylet::escrow(a1, SeqProxy::rawSequence((*sle)[sfSequence] + 2))); + + // We don't use ltNICKNAME directly since it's marked deprecated + // to prevent accidental use elsewhere. + sleNew->type_ = static_cast('n'); + ac.view().insert(sleNew); + return true; + }); + } + + void + testXRPBalanceCheck() + { + using namespace test::jtx; + testcase << "XRP balance checks"; + + doInvariantCheck( + {{"Cannot return non-native STAmount as XRPAmount"}}, + [](Account const& a1, Account const& a2, ApplyContext& ac) { + // non-native balance + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + STAmount const nonNative(a2["USD"](51)); + sle->setFieldAmount(sfBalance, nonNative); + ac.view().update(sle); + return true; + }); + + doInvariantCheck( + {{"incorrect account XRP balance"}, {"XRP net change was positive: 99999999000000001"}}, + [this](Account const& a1, Account const&, ApplyContext& ac) { + // balance exceeds genesis amount + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + // Use `drops(1)` to bypass a call to STAmount::canonicalize + // with an invalid value + sle->setFieldAmount(sfBalance, kInitialXrp + drops(1)); + BEAST_EXPECT(!sle->getFieldAmount(sfBalance).negative()); + ac.view().update(sle); + return true; + }); + + doInvariantCheck( + {{"incorrect account XRP balance"}, + {"XRP net change of -1000000001 doesn't match fee 0"}}, + [this](Account const& a1, Account const&, ApplyContext& ac) { + // balance is negative + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + sle->setFieldAmount(sfBalance, STAmount{1, true}); + BEAST_EXPECT(sle->getFieldAmount(sfBalance).negative()); + ac.view().update(sle); + return true; + }); + } + + void + testTransactionFeeCheck() + { + using namespace test::jtx; + using namespace std::string_literals; + testcase << "Transaction fee checks"; + + doInvariantCheck( + {{"fee paid was negative: -1"}, {"XRP net change of 0 doesn't match fee -1"}}, + [](Account const&, Account const&, ApplyContext&) { return true; }, + XRPAmount{-1}); + + doInvariantCheck( + {{"fee paid exceeds system limit: "s + to_string(kInitialXrp)}, + {"XRP net change of 0 doesn't match fee "s + to_string(kInitialXrp)}}, + [](Account const&, Account const&, ApplyContext&) { return true; }, + XRPAmount{kInitialXrp}); + + doInvariantCheck( + {{"fee paid is 20 exceeds fee specified in transaction."}, + {"XRP net change of 0 doesn't match fee 20"}}, + [](Account const&, Account const&, ApplyContext&) { return true; }, + XRPAmount{20}, + STTx{ttACCOUNT_SET, [](STObject& tx) { tx.setFieldAmount(sfFee, XRPAmount{10}); }}); + } + + void + testNoBadOffers() + { + using namespace test::jtx; + testcase << "no bad offers"; + + doInvariantCheck( + {{"offer with a bad amount"}}, [](Account const& a1, Account const&, ApplyContext& ac) { + // offer with negative takerpays + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + auto sleNew = std::make_shared( + keylet::offer(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence]))); + sleNew->setAccountID(sfAccount, a1.id()); + sleNew->setFieldU32(sfSequence, (*sle)[sfSequence]); + sleNew->setFieldAmount(sfTakerPays, XRP(-1)); + ac.view().insert(sleNew); + return true; + }); + + doInvariantCheck( + {{"offer with a bad amount"}}, [](Account const& a1, Account const&, ApplyContext& ac) { + // offer with negative takergets + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + auto sleNew = std::make_shared( + keylet::offer(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence]))); + sleNew->setAccountID(sfAccount, a1.id()); + sleNew->setFieldU32(sfSequence, (*sle)[sfSequence]); + sleNew->setFieldAmount(sfTakerPays, a1["USD"](10)); + sleNew->setFieldAmount(sfTakerGets, XRP(-1)); + ac.view().insert(sleNew); + return true; + }); + + doInvariantCheck( + {{"offer with a bad amount"}}, [](Account const& a1, Account const&, ApplyContext& ac) { + // offer XRP to XRP + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + auto sleNew = std::make_shared( + keylet::offer(a1.id(), SeqProxy::rawSequence((*sle)[sfSequence]))); + sleNew->setAccountID(sfAccount, a1.id()); + sleNew->setFieldU32(sfSequence, (*sle)[sfSequence]); + sleNew->setFieldAmount(sfTakerPays, XRP(10)); + sleNew->setFieldAmount(sfTakerGets, XRP(11)); + ac.view().insert(sleNew); + return true; + }); + } + + void + testValidNewAccountRoot() + { + using namespace test::jtx; + testcase << "valid new account root"; + + doInvariantCheck( + {{"account root created illegally"}}, + [](Account const&, Account const&, ApplyContext& ac) { + // Insert a new account root created by a non-payment into + // the view. + Account const a3{"A3"}; + Keylet const acctKeylet = keylet::account(a3); + auto const sleNew = std::make_shared(acctKeylet); + ac.view().insert(sleNew); + return true; + }); + + doInvariantCheck( + {{"multiple accounts created in a single transaction"}}, + [](Account const&, Account const&, ApplyContext& ac) { + // Insert two new account roots into the view. + { + Account const a3{"A3"}; + Keylet const acctKeylet = keylet::account(a3); + auto const sleA3 = std::make_shared(acctKeylet); + ac.view().insert(sleA3); + } + { + Account const a4{"A4"}; + Keylet const acctKeylet = keylet::account(a4); + auto const sleA4 = std::make_shared(acctKeylet); + ac.view().insert(sleA4); + } + return true; + }); + + doInvariantCheck( + {{"account created with wrong starting sequence number"}}, + [](Account const&, Account const&, ApplyContext& ac) { + // Insert a new account root with the wrong starting sequence. + Account const a3{"A3"}; + Keylet const acctKeylet = keylet::account(a3); + auto const sleNew = std::make_shared(acctKeylet); + sleNew->setFieldU32(sfSequence, ac.view().seq() + 1); + ac.view().insert(sleNew); + return true; + }, + XRPAmount{}, + STTx{ttPAYMENT, [](STObject& tx) {}}); + + doInvariantCheck( + {{"pseudo-account created by a wrong transaction type"}}, + [](Account const&, Account const&, ApplyContext& ac) { + Account const a3{"A3"}; + Keylet const acctKeylet = keylet::account(a3); + auto const sleNew = std::make_shared(acctKeylet); + sleNew->setFieldU32(sfSequence, 0); + sleNew->setFieldH256(sfAMMID, uint256(1)); + sleNew->setFieldU32(sfFlags, lsfDisableMaster | lsfDefaultRipple); + ac.view().insert(sleNew); + return true; + }, + XRPAmount{}, + STTx{ttPAYMENT, [](STObject& tx) {}}); + + doInvariantCheck( + {{"account created with wrong starting sequence number"}}, + [](Account const&, Account const&, ApplyContext& ac) { + Account const a3{"A3"}; + Keylet const acctKeylet = keylet::account(a3); + auto const sleNew = std::make_shared(acctKeylet); + sleNew->setFieldU32(sfSequence, ac.view().seq()); + sleNew->setFieldH256(sfAMMID, uint256(1)); + sleNew->setFieldU32(sfFlags, lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth); + ac.view().insert(sleNew); + return true; + }, + XRPAmount{}, + STTx{ttAMM_CREATE, [](STObject& tx) {}}); + + doInvariantCheck( + {{"pseudo-account created with wrong flags"}}, + [](Account const&, Account const&, ApplyContext& ac) { + Account const a3{"A3"}; + Keylet const acctKeylet = keylet::account(a3); + auto const sleNew = std::make_shared(acctKeylet); + sleNew->setFieldU32(sfSequence, 0); + sleNew->setFieldH256(sfAMMID, uint256(1)); + sleNew->setFieldU32(sfFlags, lsfDisableMaster | lsfDefaultRipple); + ac.view().insert(sleNew); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_CREATE, [](STObject& tx) {}}); + + doInvariantCheck( + {{"pseudo-account created with wrong flags"}}, + [](Account const&, Account const&, ApplyContext& ac) { + Account const a3{"A3"}; + Keylet const acctKeylet = keylet::account(a3); + auto const sleNew = std::make_shared(acctKeylet); + sleNew->setFieldU32(sfSequence, 0); + sleNew->setFieldH256(sfAMMID, uint256(1)); + sleNew->setFieldU32( + sfFlags, + lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth | lsfRequireDestTag); + ac.view().insert(sleNew); + return true; + }, + XRPAmount{}, + STTx{ttAMM_CREATE, [](STObject& tx) {}}); + } + + void + testNoModifiedUnmodifiableFields() + { + testcase("no modified unmodifiable fields"); + using namespace jtx; + + // Initialize with a placeholder value because there's no default ctor + Keylet loanBrokerKeylet = keylet::amendments(); + Preclose const createLoanBroker = [&, this](Account const& a, Account const& b, Env& env) { + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + + loanBrokerKeylet = this->createLoanBroker(a, env, xrpAsset); + return BEAST_EXPECT(env.le(loanBrokerKeylet)); + }; + + { + auto const mods = std::to_array>({ + [](SLE::pointer& sle) { sle->at(sfSequence) += 1; }, + [](SLE::pointer& sle) { sle->at(sfOwnerNode) += 1; }, + [](SLE::pointer& sle) { sle->at(sfVaultNode) += 1; }, + [](SLE::pointer& sle) { sle->at(sfVaultID) = uint256(1u); }, + [](SLE::pointer& sle) { sle->at(sfAccount) = sle->at(sfOwner); }, + [](SLE::pointer& sle) { sle->at(sfOwner) = sle->at(sfAccount); }, + [](SLE::pointer& sle) { sle->at(sfManagementFeeRate) += 1; }, + [](SLE::pointer& sle) { sle->at(sfCoverRateMinimum) += 1; }, + [](SLE::pointer& sle) { sle->at(sfCoverRateLiquidation) += 1; }, + [](SLE::pointer& sle) { sle->at(sfLedgerEntryType) += 1; }, + [](SLE::pointer& sle) { sle->at(sfLedgerIndex) = sle->at(sfVaultID).value(); }, + }); + + for (auto const& mod : mods) + { + doInvariantCheck( + {{"changed an unchangeable field"}}, + [&](Account const& a1, Account const&, ApplyContext& ac) { + auto sle = ac.view().peek(loanBrokerKeylet); + if (!sle) + return false; + mod(sle); + ac.view().update(sle); + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + createLoanBroker); + } + } + + // TODO: Loan Object + + // VaultKind, SubscriptionDate and RedemptionDate are immutable once set at creation. + // Enforced by NoModifiedUnmodifiableFields on ltVAULT via kFieldChanged. + Keylet closedEndedVaultKeylet = keylet::amendments(); + Preclose const createClosedEndedVault = [&, this]( + Account const& a, Account const&, Env& env) { + auto const sub = env.now().time_since_epoch().count() + 60; + auto const red = sub + kMinInvestmentPeriod + 1'000'000; + Vault const vault{env}; + auto [tx, keylet] = vault.create( + {.owner = a, + .asset = xrpIssue(), + .vaultKind = std::to_underlying(VaultKind::ClosedEnded), + .subscriptionDate = sub, + .redemptionDate = red}); + env(tx); + closedEndedVaultKeylet = keylet; + return BEAST_EXPECT(env.le(closedEndedVaultKeylet)); + }; + + { + // Each mutation must keep the vault otherwise valid so that only the immutability check + // fires. Shifting both dates by the same offset preserves the gap; bumping sfVaultKind + // stays within the recognised range. + auto const mods = std::to_array>({ + [](SLE::pointer& sle) { sle->at(sfVaultKind) += 1; }, + [](SLE::pointer& sle) { sle->at(sfSubscriptionDate) += 1; }, + [](SLE::pointer& sle) { sle->at(sfRedemptionDate) += 1; }, + }); + + for (auto const& mod : mods) + { + doInvariantCheck( + {{"changed an unchangeable field"}}, + [&](Account const&, Account const&, ApplyContext& ac) { + auto sle = ac.view().peek(closedEndedVaultKeylet); + if (!sle) + return false; + mod(sle); + ac.view().update(sle); + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_SET, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + createClosedEndedVault); + } + } + + { + auto const mods = std::to_array>({ + [](SLE::pointer& sle) { sle->at(sfLedgerEntryType) += 1; }, + [](SLE::pointer& sle) { sle->at(sfLedgerIndex) = uint256(1u); }, + }); + + for (auto const& mod : mods) + { + doInvariantCheck( + {{"changed an unchangeable field"}}, + [&](Account const& a1, Account const&, ApplyContext& ac) { + auto sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + mod(sle); + ac.view().update(sle); + return true; + }); + } + } + } + + void + testInvariantOverwrite(FeatureBitset features) + { + using namespace test::jtx; + bool const fixEnabled = features[fixCleanup3_1_3]; + std::initializer_list const failTers = {tecINVARIANT_FAILED, tefINVARIANT_FAILED}; + std::initializer_list const passTers = {tesSUCCESS, tesSUCCESS}; + + // Insert two trust line SLEs in hash-sorted order, with the "bad" + // entry at the lower-sorting key so it is visited first by + // ApplyStateTable::visit(). The configurer callables receive the + // SLE and the Issue corresponding to that side's keylet currency. + auto const insertOrderedTrustLinePair = [](ApplyContext& ac, + Account const& a1, + Account const& a2, + Account const& a3, + auto const& badConfig, + auto const& goodConfig) { + char const* const c1 = "USD"; + char const* const c2 = "EUR"; + auto const k1 = keylet::trustLine(a1, a2, a1[c1].currency); + auto const k2 = keylet::trustLine(a1, a3, a1[c2].currency); + + bool const k1First = k1.key < k2.key; + auto const& badKey = k1First ? k1 : k2; + auto const& goodKey = k1First ? k2 : k1; + Issue const badIss{k1First ? a1[c1].currency : a1[c2].currency, a1.id()}; + Issue const goodIss{k1First ? a1[c2].currency : a1[c1].currency, a1.id()}; + + auto const sleBad = std::make_shared(badKey); + badConfig(*sleBad, badIss); + ac.view().insert(sleBad); + + auto const sleGood = std::make_shared(goodKey); + goodConfig(*sleGood, goodIss); + ac.view().insert(sleGood); + }; + + // Regression: bad XRP trust line followed by a valid trust line. + // With the fix, the invariant catches the violation. Without it, + // the valid entry overwrites the flag to false. The keylet + // currencies are non-XRP (the invariant inspects sfLowLimit / + // sfHighLimit issue, not the keylet currency). + testcase << "overwrite: NoXRPTrustLines" + std::string(fixEnabled ? " fix" : ""); + doInvariantCheck( + makeEnv(features), + fixEnabled ? std::vector{{"an XRP trust line was created"}} + : std::vector{}, + [&insertOrderedTrustLinePair](Account const& a1, Account const& a2, ApplyContext& ac) { + Account const a3{"A3"}; + insertOrderedTrustLinePair( + ac, + a1, + a2, + a3, + [](SLE& sle, Issue const& iss) { + // sfLowLimit has xrpIssue, making isXrp = true + sle.setFieldAmount(sfLowLimit, STAmount{xrpIssue(), 0}); + sle.setFieldAmount(sfHighLimit, STAmount{iss, 0}); + }, + [](SLE& sle, Issue const& iss) { + sle.setFieldAmount(sfLowLimit, STAmount{iss, 0}); + sle.setFieldAmount(sfHighLimit, STAmount{iss, 0}); + }); + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_SET, [](STObject&) {}}, + fixEnabled ? failTers : passTers); + + // Regression: bad deep-freeze trust line followed by a valid one. + testcase << "overwrite: NoDeepFreeze" + std::string(fixEnabled ? " fix" : ""); + doInvariantCheck( + makeEnv(features), + fixEnabled ? std::vector{{"a trust line with deep freeze flag without " + "normal freeze was created"}} + : std::vector{}, + [&insertOrderedTrustLinePair](Account const& a1, Account const& a2, ApplyContext& ac) { + Account const a3{"A3"}; + insertOrderedTrustLinePair( + ac, + a1, + a2, + a3, + [](SLE& sle, Issue const& iss) { + sle.setFieldAmount(sfLowLimit, STAmount{iss, 0}); + sle.setFieldAmount(sfHighLimit, STAmount{iss, 0}); + sle.setFieldU32(sfFlags, lsfLowDeepFreeze); + }, + [](SLE& sle, Issue const& iss) { + sle.setFieldAmount(sfLowLimit, STAmount{iss, 0}); + sle.setFieldAmount(sfHighLimit, STAmount{iss, 0}); + sle.setFieldU32(sfFlags, 0u); + }); + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_SET, [](STObject&) {}}, + fixEnabled ? failTers : passTers); + + // Regression: MPT OutstandingAmount exceeds max, but locked <= + // outstanding. Plain assignment would overwrite bad_ = true. + // With the fix, NoZeroEscrow catches it. + // Without the fix, NoZeroEscrow passes but ValidMPTIssuance + // still fires ("a MPT issuance was created"). + testcase << "overwrite: NoZeroEscrow MPT" + std::string(fixEnabled ? " fix" : ""); + doInvariantCheck( + makeEnv(features), + fixEnabled ? std::vector{{"escrow specifies invalid amount"}} + : std::vector{{"a MPT issuance was created"}}, + [](Account const& a1, Account const&, ApplyContext& ac) { + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + + MPTIssue const mpt{makeMptID(1, AccountID(0x4985601))}; + auto sleNew = std::make_shared(keylet::mptokenIssuance(mpt.getMptID())); + // outstanding exceeds kMaxMpTokenAmount -> checkAmount sets bad_ + sleNew->setFieldU64(sfOutstandingAmount, kMaxMpTokenAmount + 1); + // locked is valid and <= outstanding -> must NOT clear bad_ + sleNew->setFieldU64(sfLockedAmount, 10); + ac.view().insert(sleNew); + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_SET, [](STObject&) {}}, + failTers); + } + + void + testSponsorship() + { + using namespace test::jtx; + using namespace std::string_literals; + testcase("Sponsorship"); + { + auto const expectMessage = + "SponsoredOwnerCount does not equal SponsoringOwnerCount delta."; + + doInvariantCheck( + {{expectMessage}}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + sle->setFieldU32(sfSponsoredOwnerCount, 1); + ac.view().update(sle); + return true; + }); + + doInvariantCheck( + {{expectMessage}}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + sle->setFieldU32(sfSponsoringOwnerCount, 1); + ac.view().update(sle); + return true; + }); + } + + { + auto const expectMessage = + "OwnerCount must be greater than or equal to SponsoredOwnerCount."; + + doInvariantCheck( + {{expectMessage}}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + sle->setFieldU32(sfOwnerCount, 0); + sle->setFieldU32(sfSponsoredOwnerCount, 1); + ac.view().update(sle); + + auto const sle2 = ac.view().peek(keylet::account(a2.id())); + if (!sle2) + return false; + sle2->setFieldU32(sfSponsoringOwnerCount, 1); + ac.view().update(sle2); + return true; + }); + } + + { + auto const expectMessage = + "SponsoredObjectOwnerCount does not equal SponsoredOwnerCount delta."; + uint256 checkID; + + doInvariantCheck( + {{expectMessage}}, + [&](Account const&, Account const& a2, ApplyContext& ac) { + auto const check = ac.view().peek(keylet::check(checkID)); + if (!check) + return false; + check->setAccountID(sfSponsor, a2.id()); + ac.view().update(check); + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_SET, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + [&checkID](Account const& a1, Account const& a2, Env& env) { + checkID = keylet::check(a1.id(), SeqProxy::rawSequence(env.seq(a1))).key; + env(check::create(a1, a2, XRP(1))); + return true; + }); + } + + { + auto const expectMessage = + "Invariant failed: Net delta of SponsoringAccountCount does " + "not match net delta of sfSponsor presence."; + + doInvariantCheck( + {{expectMessage}}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + sle->setFieldU32(sfSponsoringAccountCount, 1); + ac.view().update(sle); + return true; + }); + + doInvariantCheck( + {{expectMessage}}, [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + sle->setAccountID(sfSponsor, a2.id()); + ac.view().update(sle); + return true; + }); + } + } + + void + testObjectHasPseudoAccount() + { + testcase << "object has pseudo-account"; + using namespace jtx; + + auto const amendments = all_ | fixCleanup3_3_0; + + // Vault: object deleted without its pseudo-account + { + Keylet vaultKeylet = keylet::amendments(); + doInvariantCheck( + Env{*this, amendments}, + {{"deleted Vault without deleting its pseudo-account"}}, + [&vaultKeylet](Account const&, Account const&, ApplyContext& ac) { + auto sle = ac.view().peek(vaultKeylet); + if (!sle) + return false; + ac.view().erase(sle); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_DELETE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + [&vaultKeylet](Account const& a1, Account const&, Env& env) { + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(tx); + vaultKeylet = keylet; + return true; + }); + } + + // AMM: object deleted without its pseudo-account + { + uint256 ammID{}; + Account const gw{"gw"}; + doInvariantCheck( + Env{*this, amendments}, + {{"deleted AMM without deleting its pseudo-account"}}, + [&ammID](Account const&, Account const&, ApplyContext& ac) { + auto sle = ac.view().peek(keylet::amm(ammID)); + if (!sle) + return false; + ac.view().erase(sle); + return true; + }, + XRPAmount{}, + STTx{ttAMM_DELETE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + [&ammID, &gw](Account const&, Account const&, Env& env) { + env.fund(XRP(1'000), gw); + AMM const amm(env, gw, XRP(100), gw["USD"](100)); + ammID = amm.ammID(); + return true; + }); + } + + // LoanBroker: object deleted without its pseudo-account + { + Keylet loanBrokerKeylet = keylet::amendments(); + doInvariantCheck( + Env{*this, amendments}, + {{"deleted LoanBroker without deleting its pseudo-account"}}, + [&loanBrokerKeylet](Account const&, Account const&, ApplyContext& ac) { + auto sle = ac.view().peek(loanBrokerKeylet); + if (!sle) + return false; + ac.view().erase(sle); + return true; + }, + XRPAmount{}, + STTx{ttLOAN_BROKER_DELETE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + [&loanBrokerKeylet, this](Account const& a1, Account const&, Env& env) { + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + loanBrokerKeylet = this->createLoanBroker(a1, env, xrpAsset); + return BEAST_EXPECT(env.le(loanBrokerKeylet)); + }); + } + + // Deleted object missing sfAccount field (defensive check). + // Manually construct the view to place a vault SLE without + // sfAccount into the base ledger, then erase it. + { + Env env{*this, amendments}; + Account const a1{"A1"}; + Account const a2{"A2"}; + env.fund(XRP(1000), a1, a2); + env.close(); + + OpenView ov{*env.current()}; + + auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ov.seq())); + auto sleVault = std::make_shared(vaultKeylet); + sleVault->makeFieldAbsent(sfAccount); + ov.rawInsert(sleVault); + + STTx const tx{ttVAULT_DELETE, [](STObject&) {}}; + test::StreamSink sink{beast::Severity::Warning}; + beast::Journal const jlog{sink}; + ApplyContext ac{ + env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog}; + CurrentTransactionRulesGuard const rulesGuard(ov.rules()); + + auto sle = ac.view().peek(vaultKeylet); + if (!BEAST_EXPECT(sle)) + return; + ac.view().erase(sle); + + auto transactor = makeTransactor(ac); + if (!BEAST_EXPECT(transactor)) + return; + TER const result = transactor->checkInvariants( + tesSUCCESS, XRPAmount{}, Transactor::InvariantScope::Full); + BEAST_EXPECT(result == tecINVARIANT_FAILED); + BEAST_EXPECT(sink.messages().str().contains("is missing pseudo-account field")); + } + } + + void + testTxCheckException() + { + testcase << "txCheck exception"; + using namespace jtx; + + // A TxInvariantCheck that throws from the requested hook, so we can + // exercise checkInvariantsHelper's catch block via the + // transaction-specific layer (as opposed to the protocol layer, + // which testObjectHasPseudoAccount's last case already covers via a + // real Transactor's finalizeInvariants). + enum class ThrowFrom { VisitEntry, Finalize }; + + struct ThrowingTxInvariantCheck : TxInvariantCheck + { + ThrowFrom const throwFrom; + + explicit ThrowingTxInvariantCheck(ThrowFrom throwFrom) : throwFrom(throwFrom) + { + } + + void + visitEntry(bool, SLE::const_ref, SLE::const_ref) override + { + if (throwFrom == ThrowFrom::VisitEntry) + throw std::runtime_error("test-injected visitEntry exception"); + } + + [[nodiscard]] bool + finalize(STTx const&, TER, XRPAmount, ReadView const&, beast::Journal const&) override + { + if (throwFrom == ThrowFrom::Finalize) + throw std::runtime_error("test-injected finalize exception"); + return true; + } + }; + + for (auto const throwFrom : {ThrowFrom::VisitEntry, ThrowFrom::Finalize}) + { + Env env{*this}; + Account const alice{"alice"}; + env.fund(XRP(1000), alice); + env.close(); + + OpenView ov{*env.current()}; + STTx const tx{ttACCOUNT_SET, [](STObject&) {}}; + test::StreamSink sink{beast::Severity::Warning}; + beast::Journal const jlog{sink}; + ApplyContext ac{ + env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog}; + CurrentTransactionRulesGuard const rulesGuard(ov.rules()); + + // visitEntry only runs for entries the transaction touched, so + // make a modification for the traversal to report. + auto sle = ac.view().peek(keylet::account(alice.id())); + if (!BEAST_EXPECT(sle)) + return; + sle->at(sfSequence) = sle->at(sfSequence) + 1; + ac.view().update(sle); + + ThrowingTxInvariantCheck throwing{throwFrom}; + TER terActual = tesSUCCESS; + for (TER const& terExpect : {TER(tecINVARIANT_FAILED), TER(tefINVARIANT_FAILED)}) + { + terActual = checkInvariants(ac, terActual, XRPAmount{}, throwing); + BEAST_EXPECT(terExpect == terActual); + BEAST_EXPECT(sink.messages().str().contains( + "Transaction caused an exception during invariant checks")); + } + } + } + + void + testTxCheckFinalizeFalse() + { + testcase << "txCheck finalize returns false"; + using namespace jtx; + + // A TxInvariantCheck whose finalize returns false, so we can exercise + // the "Transaction has failed one or more transaction invariants" + // log path in checkInvariantsHelper independently of any real + // transactor. This is the transaction-layer analogue of the + // protocol-layer coverage in testObjectHasPseudoAccount / others. + struct FailingTxInvariantCheck : TxInvariantCheck + { + void + visitEntry(bool, SLE::const_ref, SLE::const_ref) override + { + } + + [[nodiscard]] bool + finalize(STTx const&, TER, XRPAmount, ReadView const&, beast::Journal const&) override + { + return false; + } + }; + + Env env{*this}; + Account const alice{"alice"}; + env.fund(XRP(1000), alice); + env.close(); + + OpenView ov{*env.current()}; + STTx const tx{ttACCOUNT_SET, [](STObject&) {}}; + test::StreamSink sink{beast::Severity::Warning}; + beast::Journal const jlog{sink}; + ApplyContext ac{env.app(), ov, tx, tesSUCCESS, env.current()->fees().base, TapNone, jlog}; + CurrentTransactionRulesGuard const rulesGuard(ov.rules()); + + FailingTxInvariantCheck failing; + TER terActual = tesSUCCESS; + for (TER const& terExpect : {TER(tecINVARIANT_FAILED), TER(tefINVARIANT_FAILED)}) + { + terActual = checkInvariants(ac, terActual, XRPAmount{}, failing); + BEAST_EXPECT(terExpect == terActual); + BEAST_EXPECT(sink.messages().str().contains( + "Transaction has failed one or more transaction invariants")); + // The protocol-layer log must not appear: only the tx-layer + // finalize failed here. + BEAST_EXPECT(!sink.messages().str().contains( + "Transaction has failed one or more global invariants")); + } + } + + void + run() override + { + testXRPNotCreated(); + testAccountRootsNotRemoved(); + testAccountRootsDeletedClean(); + testTypesMatch(); + testXRPBalanceCheck(); + testTransactionFeeCheck(); + testNoBadOffers(); + testValidNewAccountRoot(); + testNoModifiedUnmodifiableFields(); + testInvariantOverwrite(all_); + testInvariantOverwrite(all_ - fixCleanup3_1_3); + testObjectHasPseudoAccount(); + testSponsorship(); + testTxCheckException(); + testTxCheckFinalizeFalse(); + } +}; + +BEAST_DEFINE_TESTSUITE(InvariantsMisc, app, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/invariants/InvariantsPermissioned_test.cpp b/src/test/app/invariants/InvariantsPermissioned_test.cpp new file mode 100644 index 0000000000..87349fb9e1 --- /dev/null +++ b/src/test/app/invariants/InvariantsPermissioned_test.cpp @@ -0,0 +1,957 @@ +#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 +#include +#include +#include +#include +#include +#include + +namespace xrpl::test { + +class InvariantsPermissioned_test : public InvariantsBase +{ + FeatureBitset const all_{test::jtx::testableAmendments()}; + + void + testPermissionedDomainInvariants(FeatureBitset features) + { + using namespace test::jtx; + + bool const fixEnabled = features[fixCleanup3_1_3]; + std::initializer_list const badTers = {tecINVARIANT_FAILED, tecINVARIANT_FAILED}; + std::initializer_list const failTers = {tecINVARIANT_FAILED, tefINVARIANT_FAILED}; + + testcase << "PermissionedDomain" + std::string(fixEnabled ? " fix" : ""); + + doInvariantCheck( + makeEnv(features), + {{"permissioned domain with no rules."}}, + [](Account const& a1, Account const& a2, ApplyContext& ac) { + return createPermissionedDomain(ac, a1, a2, 0).get(); + }, + XRPAmount{}, + STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}}, + fixEnabled ? failTers : badTers); + + testcase << "PermissionedDomain 2"; + + static constexpr auto kTooBig = kMaxPermissionedDomainCredentialsArraySize + 1; + doInvariantCheck( + makeEnv(features), + {{"permissioned domain bad credentials size " + std::to_string(kTooBig)}}, + [](Account const& a1, Account const& a2, ApplyContext& ac) { + return !!createPermissionedDomain(ac, a1, a2, kTooBig); + }, + XRPAmount{}, + STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}}, + fixEnabled ? failTers : badTers); + + testcase << "PermissionedDomain 3"; + doInvariantCheck( + makeEnv(features), + {{"permissioned domain credentials aren't sorted"}}, + [](Account const& a1, Account const& a2, ApplyContext& ac) { + auto slePd = createPermissionedDomain(ac, a1, a2, 0); + + STArray credentials(sfAcceptedCredentials, 2); + for (std::size_t n = 0; n < 2; ++n) + { + auto cred = STObject::makeInnerObject(sfCredential); + cred.setAccountID(sfIssuer, a2); + auto credType = std::string("cred_type") + std::to_string(9 - n); + cred.setFieldVL(sfCredentialType, Slice(credType.c_str(), credType.size())); + credentials.pushBack(std::move(cred)); + } + slePd->setFieldArray(sfAcceptedCredentials, credentials); + ac.view().update(slePd); + return true; + }, + XRPAmount{}, + STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}}, + fixEnabled ? failTers : badTers); + + testcase << "PermissionedDomain 4"; + doInvariantCheck( + makeEnv(features), + {{"permissioned domain credentials aren't unique"}}, + [](Account const& a1, Account const& a2, ApplyContext& ac) { + auto slePd = createPermissionedDomain(ac, a1, a2, 0); + + STArray credentials(sfAcceptedCredentials, 2); + for (std::size_t n = 0; n < 2; ++n) + { + auto cred = STObject::makeInnerObject(sfCredential); + cred.setAccountID(sfIssuer, a2); + cred.setFieldVL(sfCredentialType, Slice("cred_type", 9)); + credentials.pushBack(std::move(cred)); + } + slePd->setFieldArray(sfAcceptedCredentials, credentials); + ac.view().update(slePd); + return true; + }, + XRPAmount{}, + STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}}, + fixEnabled ? failTers : badTers); + + testcase << "PermissionedDomain Set 1"; + doInvariantCheck( + makeEnv(features), + {{"permissioned domain with no rules."}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + // create PD + auto slePd = createPermissionedDomain(ac, a1, a2); + + // update PD with empty rules + { + STArray const credentials(sfAcceptedCredentials, 2); + slePd->setFieldArray(sfAcceptedCredentials, credentials); + ac.view().update(slePd); + } + + return true; + }, + XRPAmount{}, + STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}}, + fixEnabled ? failTers : badTers); + + testcase << "PermissionedDomain Set 2"; + doInvariantCheck( + makeEnv(features), + {{"permissioned domain bad credentials size " + std::to_string(kTooBig)}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + // create PD + auto slePd = createPermissionedDomain(ac, a1, a2); + + // update PD + { + STArray credentials(sfAcceptedCredentials, kTooBig); + + for (std::size_t n = 0; n < kTooBig; ++n) + { + auto cred = STObject::makeInnerObject(sfCredential); + cred.setAccountID(sfIssuer, a2); + auto credType = "cred_type2" + std::to_string(n); + cred.setFieldVL(sfCredentialType, Slice(credType.c_str(), credType.size())); + credentials.pushBack(std::move(cred)); + } + + slePd->setFieldArray(sfAcceptedCredentials, credentials); + ac.view().update(slePd); + } + + return true; + }, + XRPAmount{}, + STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}}, + fixEnabled ? failTers : badTers); + + testcase << "PermissionedDomain Set 3"; + doInvariantCheck( + makeEnv(features), + {{"permissioned domain credentials aren't sorted"}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + // create PD + auto slePd = createPermissionedDomain(ac, a1, a2); + + // update PD + { + STArray credentials(sfAcceptedCredentials, 2); + for (std::size_t n = 0; n < 2; ++n) + { + auto cred = STObject::makeInnerObject(sfCredential); + cred.setAccountID(sfIssuer, a2); + auto credType = std::string("cred_type2") + std::to_string(9 - n); + cred.setFieldVL(sfCredentialType, Slice(credType.c_str(), credType.size())); + credentials.pushBack(std::move(cred)); + } + + slePd->setFieldArray(sfAcceptedCredentials, credentials); + ac.view().update(slePd); + } + + return true; + }, + XRPAmount{}, + STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}}, + fixEnabled ? failTers : badTers); + + testcase << "PermissionedDomain Set 4"; + doInvariantCheck( + makeEnv(features), + {{"permissioned domain credentials aren't unique"}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + // create PD + auto slePd = createPermissionedDomain(ac, a1, a2); + + // update PD + { + STArray credentials(sfAcceptedCredentials, 2); + for (std::size_t n = 0; n < 2; ++n) + { + auto cred = STObject::makeInnerObject(sfCredential); + cred.setAccountID(sfIssuer, a2); + cred.setFieldVL(sfCredentialType, Slice("cred_type", 9)); + credentials.pushBack(std::move(cred)); + } + slePd->setFieldArray(sfAcceptedCredentials, credentials); + ac.view().update(slePd); + } + + return true; + }, + XRPAmount{}, + STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}}, + fixEnabled ? failTers : badTers); + + std::initializer_list const goodTers = {tesSUCCESS, tesSUCCESS}; + + std::vector const badMoreThan1{ + {"transaction affected more than 1 permissioned domain entry."}}; + std::vector const emptyV; + std::vector const badNoDomains{{"no domain objects affected by"}}; + std::vector const badNotDeleted{ + {"domain object modified, but not deleted by "}}; + std::vector const badDeleted{{"domain object deleted by"}}; + std::vector const badTx{ + {"domain object(s) affected by an unauthorized transaction."}}; + + { + testcase << "PermissionedDomain set 2 domains "; + doInvariantCheck( + makeEnv(features), + fixEnabled ? badMoreThan1 : emptyV, + [](Account const& a1, Account const& a2, ApplyContext& ac) { + createPermissionedDomain(ac, a1, a2); + createPermissionedDomain(ac, a1, a2, 2, 11); + return true; + }, + XRPAmount{}, + STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}}, + fixEnabled ? failTers : goodTers); + } + + { + testcase << "PermissionedDomain del 2 domains"; + + Env env1(*this, features); + + Account const a1{"A1"}; + Account const a2{"A2"}; + env1.fund(XRP(1000), a1, a2); + env1.close(); + + [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2); + [[maybe_unused]] auto [seq2, pd2] = createPermissionedDomainEnv(env1, a1, a2); + env1.close(); + + doInvariantCheck( + std::move(env1), + a1, + a2, + fixEnabled ? badMoreThan1 : emptyV, + [&pd1, &pd2](Account const&, Account const&, ApplyContext& ac) { + auto sle1 = ac.view().peek({ltPERMISSIONED_DOMAIN, pd1}); + auto sle2 = ac.view().peek({ltPERMISSIONED_DOMAIN, pd2}); + ac.view().erase(sle1); + ac.view().erase(sle2); + return true; + }, + XRPAmount{}, + STTx{ttPERMISSIONED_DOMAIN_DELETE, [](STObject&) {}}, + fixEnabled ? failTers : goodTers); + } + + { + testcase << "PermissionedDomain set 0 domains "; + doInvariantCheck( + makeEnv(features), + fixEnabled ? badNoDomains : emptyV, + [](Account const&, Account const&, ApplyContext&) { return true; }, + XRPAmount{}, + STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}}, + fixEnabled ? badTers : goodTers); + } + + { + testcase << "PermissionedDomain del 0 domains"; + + Env env1(*this, features); + + Account const a1{"A1"}; + Account const a2{"A2"}; + env1.fund(XRP(1000), a1, a2); + env1.close(); + + [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2); + [[maybe_unused]] auto [seq2, pd2] = createPermissionedDomainEnv(env1, a1, a2); + env1.close(); + + doInvariantCheck( + std::move(env1), + a1, + a2, + fixEnabled ? badNoDomains : emptyV, + [](Account const&, Account const&, ApplyContext&) { return true; }, + XRPAmount{}, + STTx{ttPERMISSIONED_DOMAIN_DELETE, [](STObject&) {}}, + fixEnabled ? badTers : goodTers); + } + + { + testcase << "PermissionedDomain set, delete domain"; + + Env env1(*this, features); + + Account const a1{"A1"}; + Account const a2{"A2"}; + env1.fund(XRP(1000), a1, a2); + env1.close(); + + [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2); + env1.close(); + + doInvariantCheck( + std::move(env1), + a1, + a2, + fixEnabled ? badDeleted : emptyV, + [&pd1](Account const&, Account const&, ApplyContext& ac) { + auto sle1 = ac.view().peek({ltPERMISSIONED_DOMAIN, pd1}); + ac.view().erase(sle1); + return true; + }, + XRPAmount{}, + STTx{ttPERMISSIONED_DOMAIN_SET, [](STObject&) {}}, + fixEnabled ? failTers : goodTers); + } + + { + testcase << "PermissionedDomain del, create domain "; + doInvariantCheck( + makeEnv(features), + fixEnabled ? badNotDeleted : emptyV, + [](Account const& a1, Account const& a2, ApplyContext& ac) { + createPermissionedDomain(ac, a1, a2); + return true; + }, + XRPAmount{}, + STTx{ttPERMISSIONED_DOMAIN_DELETE, [](STObject&) {}}, + fixEnabled ? failTers : goodTers); + } + + { + testcase << "PermissionedDomain invalid tx"; + + doInvariantCheck( + fixEnabled ? badTx : emptyV, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + createPermissionedDomain(ac, a1, a2); + return true; + }, + XRPAmount{}, + STTx{ttPAYMENT, [](STObject&) {}}, + failTers); + } + } + + void + testPermissionedDEX(FeatureBitset features) + { + using namespace test::jtx; + + bool const fixEnabled = features[fixCleanup3_1_3]; + + testcase << "PermissionedDEX" + std::string(fixEnabled ? " fix" : ""); + + doInvariantCheck( + makeEnv(features), + {{"domain doesn't exist"}}, + [](Account const& a1, Account const&, ApplyContext& ac) { + Keylet const offerKey = keylet::offer(a1.id(), SeqProxy::rawSequence(10)); + auto sleOffer = std::make_shared(offerKey); + sleOffer->setAccountID(sfAccount, a1); + sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10)); + sleOffer->setFieldAmount(sfTakerGets, XRP(1)); + ac.view().insert(sleOffer); + return true; + }, + XRPAmount{}, + STTx{ + ttOFFER_CREATE, + [](STObject& tx) { + tx.setFieldH256( + sfDomainID, + uint256{"F10D0CC9A0F9A3CBF585B80BE09A186483668FDBDD39AA7E33" + "70F3649CE134E5"}); + Account const a1{"A1"}; + tx.setFieldAmount(sfTakerPays, a1["USD"](10)); + tx.setFieldAmount(sfTakerGets, XRP(1)); + }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); + + // missing domain ID in offer object + doInvariantCheck( + makeEnv(features), + {{"hybrid offer is malformed"}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10)); + auto sleOffer = std::make_shared(offerKey); + sleOffer->setAccountID(sfAccount, a2); + sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10)); + sleOffer->setFieldAmount(sfTakerGets, XRP(1)); + sleOffer->setFlag(lsfHybrid); + + STArray bookArr; + bookArr.pushBack(STObject::makeInnerObject(sfBook)); + sleOffer->setFieldArray(sfAdditionalBooks, bookArr); + ac.view().insert(sleOffer); + return true; + }, + XRPAmount{}, + STTx{ttOFFER_CREATE, [&](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); + + // more than one entry in sfAdditionalBooks + { + Env env1(*this, features); + + Account const a1{"A1"}; + Account const a2{"A2"}; + env1.fund(XRP(1000), a1, a2); + env1.close(); + + [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2); + env1.close(); + + doInvariantCheck( + std::move(env1), + a1, + a2, + {{"hybrid offer is malformed"}}, + [&pd1](Account const& a1, Account const& a2, ApplyContext& ac) { + Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10)); + auto sleOffer = std::make_shared(offerKey); + sleOffer->setAccountID(sfAccount, a2); + sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10)); + sleOffer->setFieldAmount(sfTakerGets, XRP(1)); + sleOffer->setFlag(lsfHybrid); + sleOffer->setFieldH256(sfDomainID, pd1); + + STArray bookArr; + bookArr.pushBack(STObject::makeInnerObject(sfBook)); + bookArr.pushBack(STObject::makeInnerObject(sfBook)); + sleOffer->setFieldArray(sfAdditionalBooks, bookArr); + ac.view().insert(sleOffer); + return true; + }, + XRPAmount{}, + STTx{ttOFFER_CREATE, [&](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); + } + + // empty sfAdditionalBooks (size 0) + { + Env env1(*this, features); + + Account const a1{"A1"}; + Account const a2{"A2"}; + env1.fund(XRP(1000), a1, a2); + env1.close(); + + [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2); + env1.close(); + + doInvariantCheck( + std::move(env1), + a1, + a2, + fixEnabled ? std::vector{{"hybrid offer is malformed"}} + : std::vector{}, + [&pd1](Account const& a1, Account const& a2, ApplyContext& ac) { + Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10)); + auto sleOffer = std::make_shared(offerKey); + sleOffer->setAccountID(sfAccount, a2); + sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10)); + sleOffer->setFieldAmount(sfTakerGets, XRP(1)); + sleOffer->setFlag(lsfHybrid); + sleOffer->setFieldH256(sfDomainID, pd1); + + STArray const bookArr; // empty array, size 0 + sleOffer->setFieldArray(sfAdditionalBooks, bookArr); + ac.view().insert(sleOffer); + return true; + }, + XRPAmount{}, + STTx{ttOFFER_CREATE, [&](STObject&) {}}, + fixEnabled ? std::initializer_list{tecINVARIANT_FAILED, tecINVARIANT_FAILED} + : std::initializer_list{tesSUCCESS, tesSUCCESS}); + } + + // hybrid offer missing sfAdditionalBooks + { + Env env1(*this, features); + + Account const a1{"A1"}; + Account const a2{"A2"}; + env1.fund(XRP(1000), a1, a2); + env1.close(); + + [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2); + env1.close(); + + doInvariantCheck( + std::move(env1), + a1, + a2, + {{"hybrid offer is malformed"}}, + [&pd1](Account const& a1, Account const& a2, ApplyContext& ac) { + Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10)); + auto sleOffer = std::make_shared(offerKey); + sleOffer->setAccountID(sfAccount, a2); + sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10)); + sleOffer->setFieldAmount(sfTakerGets, XRP(1)); + sleOffer->setFlag(lsfHybrid); + sleOffer->setFieldH256(sfDomainID, pd1); + ac.view().insert(sleOffer); + return true; + }, + XRPAmount{}, + STTx{ttOFFER_CREATE, [&](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); + } + + { + Env env1(*this, features); + + Account const a1{"A1"}; + Account const a2{"A2"}; + env1.fund(XRP(1000), a1, a2); + env1.close(); + + [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2); + [[maybe_unused]] auto [seq2, pd2] = createPermissionedDomainEnv(env1, a1, a2); + env1.close(); + + doInvariantCheck( + std::move(env1), + a1, + a2, + {{"transaction consumed wrong domains"}}, + [&pd1](Account const& a1, Account const& a2, ApplyContext& ac) { + Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10)); + auto sleOffer = std::make_shared(offerKey); + sleOffer->setAccountID(sfAccount, a2); + sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10)); + sleOffer->setFieldAmount(sfTakerGets, XRP(1)); + sleOffer->setFieldH256(sfDomainID, pd1); + ac.view().insert(sleOffer); + return true; + }, + XRPAmount{}, + STTx{ + ttOFFER_CREATE, + [&pd2, &a1](STObject& tx) { + tx.setFieldH256(sfDomainID, pd2); + tx.setFieldAmount(sfTakerPays, a1["USD"](10)); + tx.setFieldAmount(sfTakerGets, XRP(1)); + }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); + } + + { + Env env1(*this, features); + + Account const a1{"A1"}; + Account const a2{"A2"}; + env1.fund(XRP(1000), a1, a2); + env1.close(); + + [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env1, a1, a2); + env1.close(); + + doInvariantCheck( + std::move(env1), + a1, + a2, + {{"domain transaction affected regular offers"}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + Keylet const offerKey = keylet::offer(a2.id(), SeqProxy::rawSequence(10)); + auto sleOffer = std::make_shared(offerKey); + sleOffer->setAccountID(sfAccount, a2); + sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10)); + sleOffer->setFieldAmount(sfTakerGets, XRP(1)); + ac.view().insert(sleOffer); + return true; + }, + XRPAmount{}, + STTx{ + ttOFFER_CREATE, + [&](STObject& tx) { + Account const a1{"A1"}; + tx.setFieldH256(sfDomainID, pd1); + tx.setFieldAmount(sfTakerPays, a1["USD"](10)); + tx.setFieldAmount(sfTakerGets, XRP(1)); + }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); + } + } + + void + testPermissionedDEXDeletedOfferFallback() + { + using namespace test::jtx; + + testcase << "PermissionedDEX null after"; + + // Tx is OfferCreate on pd2. Tracking pd1 fails the invariant iff that + // domain lands in the set finalize consults. after == null is never + // tracked (pre-340: after-only; post-340: early return) — same result, + // both sides are coverage/regression that we do not fall back to before. + auto const check = [this]( + FeatureBitset features, + bool const afterIsNull, + bool const isDelete, + bool const expectInvariantFailure) { + Env env(*this, features); + + Account const a1{"A1"}; + Account const a2{"A2"}; + env.fund(XRP(1000), a1, a2); + env.close(); + + [[maybe_unused]] auto [seq1, pd1] = createPermissionedDomainEnv(env, a1, a2); + [[maybe_unused]] auto [seq2, pd2] = createPermissionedDomainEnv(env, a1, a2); + env.close(); + + auto sleOffer = + std::make_shared(keylet::offer(a2.id(), SeqProxy::rawSequence(10))); + sleOffer->setAccountID(sfAccount, a2); + sleOffer->setFieldAmount(sfTakerPays, a1["USD"](10)); + sleOffer->setFieldAmount(sfTakerGets, XRP(1)); + sleOffer->setFieldH256(sfDomainID, pd1); + + CurrentTransactionRulesGuard const rulesGuard(env.current()->rules()); + + ValidPermissionedDEX invariant; + if (afterIsNull) + { + // Defensive path: after is null. Must not fall back to before. + invariant.visitEntry(isDelete, sleOffer, nullptr); + } + else + { + // Normal / real-erase path: after is the offer on pd1. + invariant.visitEntry(isDelete, nullptr, sleOffer); + } + + STTx const tx{ttOFFER_CREATE, [&pd2, &a1](STObject& tx) { + tx.setFieldH256(sfDomainID, pd2); + tx.setFieldAmount(sfTakerPays, a1["USD"](10)); + tx.setFieldAmount(sfTakerGets, XRP(1)); + }}; + + test::StreamSink sink{beast::Severity::Warning}; + beast::Journal const jlog{sink}; + bool const passed = + invariant.finalize(tx, tesSUCCESS, XRPAmount{}, *env.current(), jlog); + BEAST_EXPECT(passed != expectInvariantFailure); + if (expectInvariantFailure) + { + BEAST_EXPECT(sink.messages().str().contains("transaction consumed wrong domains")); + } + else + { + BEAST_EXPECT(sink.messages().str().empty()); + } + }; + + auto const pre = all_ - fixCleanup3_4_0; + auto const post = all_; + + // after == null: not tracked + check(pre, true, true, false); + check(post, true, true, false); + + // after == offer on pd1 + // pre-340: domainsOld_ (delete still inserted) → fail + check(pre, false, true, true); + // post-340: isDelete → only domainsOld_ → pass; !isDelete → domains_ → fail + check(post, false, true, false); + check(post, false, false, true); + } + + void + testBookDirectoryExchangeRate() + { + using namespace test::jtx; + testcase << "book directory exchange rate"; + + auto const getBookRootKey = [](Account const& account, std::uint64_t quality) { + Book const book{xrpIssue(), account["USD"], std::nullopt}; + return keylet::quality(keylet::book(book), quality); + }; + + // Root book-directory pages carry exchange-rate metadata that must + // match the quality encoded in the directory key. + auto const makeRootPage = [](Keylet const& dir, std::uint64_t exchangeRate) { + auto sleDir = std::make_shared(dir); + sleDir->setFieldH256(sfRootIndex, dir.key); + STVector256 indexes; + indexes.pushBack(uint256{1}); + sleDir->setFieldV256(sfIndexes, indexes); + sleDir->setFieldU64(sfExchangeRate, exchangeRate); + return sleDir; + }; + + // Child pages do not carry quality metadata; they only point back to + // the root directory. + auto const makeChildPage = [](Keylet const& rootDir) { + auto sleDir = std::make_shared(keylet::page(rootDir, 1)); + sleDir->setFieldH256(sfRootIndex, rootDir.key); + STVector256 indexes; + indexes.pushBack(uint256{2}); + sleDir->setFieldV256(sfIndexes, indexes); + return sleDir; + }; + + auto const makeOfferCreateTx = [] { + return STTx{ttOFFER_CREATE, [](STObject& tx) { + Account const account{"A1"}; + tx.setFieldAmount(sfTakerPays, XRP(1)); + tx.setFieldAmount(sfTakerGets, account["USD"](1)); + }}; + }; + std::initializer_list const failTers = {tecINVARIANT_FAILED, tefINVARIANT_FAILED}; + + // Creating a root book directory with mismatched exchange-rate + // metadata violates the invariant. + doInvariantCheck( + {{"book directory exchange rate does not match directory quality"}}, + [&](Account const& a1, Account const&, ApplyContext& ac) { + auto const directoryQuality = STAmount::kURateOne; + auto const dir = getBookRootKey(a1, directoryQuality); + ac.view().insert(makeRootPage(dir, directoryQuality + 1)); + return true; + }, + XRPAmount{}, + makeOfferCreateTx(), + failTers); + + // A new child page must point to an existing root page. + doInvariantCheck( + {{"book directory root missing"}}, + [&](Account const& a1, Account const&, ApplyContext& ac) { + auto const directoryQuality = STAmount::kURateOne; + auto const rootDir = getBookRootKey(a1, directoryQuality); + // Insert only the child page. It points at rootDir, but the + // corresponding root page is intentionally missing. + ac.view().insert(makeChildPage(rootDir)); + return true; + }, + XRPAmount{}, + makeOfferCreateTx(), + failTers); + + // Legacy bad-root tolerance: + // - The view contains a pre-existing root page with bad sfExchangeRate + // metadata. + // - The simulated transaction only creates a child page pointing to + // that root. + // - The invariant must pass because this transaction did not create + // the bad root, only adding a child page. + { + Env env{*this, all_}; + Account const a1{"A1"}; + env.fund(XRP(1000), a1); + env.close(); + + OpenView view{*env.current()}; + auto const directoryQuality = STAmount::kURateOne; + auto const rootDir = getBookRootKey(a1, directoryQuality); + view.rawInsert(makeRootPage(rootDir, directoryQuality + 1)); + + ValidBookDirectory invariant; + invariant.visitEntry(false, nullptr, makeChildPage(rootDir)); + + test::StreamSink sink{beast::Severity::Warning}; + beast::Journal const jlog{sink}; + BEAST_EXPECT( + invariant.finalize(makeOfferCreateTx(), tesSUCCESS, XRPAmount{}, view, jlog)); + } + + // A bad root is rejected when added, ignored when a legacy bad root is + // modified without changing sfRootIndex or deleted, and checked when a + // modified directory changes sfRootIndex. + { + Env env{*this, all_}; + Account const a1{"A1"}; + env.fund(XRP(1000), a1); + env.close(); + + OpenView view{*env.current()}; + auto const directoryQuality = STAmount::kURateOne; + auto const rootDir = getBookRootKey(a1, directoryQuality); + auto const missingRootDir = getBookRootKey(a1, directoryQuality + 1); + auto const badRoot = makeRootPage(rootDir, directoryQuality + 1); + view.rawInsert(badRoot); + + test::StreamSink sink{beast::Severity::Warning}; + beast::Journal const jlog{sink}; + + { + // add + ValidBookDirectory invariant; + invariant.visitEntry(false, nullptr, badRoot); + + BEAST_EXPECT( + !invariant.finalize(makeOfferCreateTx(), tesSUCCESS, XRPAmount{}, view, jlog)); + } + { + // modify (without changing the sfRootIndex) + ValidBookDirectory invariant; + invariant.visitEntry(false, badRoot, badRoot); + + BEAST_EXPECT( + invariant.finalize(makeOfferCreateTx(), tesSUCCESS, XRPAmount{}, view, jlog)); + } + { + // modify (changing sfRootIndex to a missing root) + auto const childBefore = makeChildPage(rootDir); + auto const childAfter = std::make_shared(*childBefore, childBefore->key()); + childAfter->setFieldH256(sfRootIndex, missingRootDir.key); + + ValidBookDirectory invariant; + invariant.visitEntry(false, childBefore, childAfter); + + test::StreamSink missingRootSink{beast::Severity::Warning}; + beast::Journal const missingRootJlog{missingRootSink}; + BEAST_EXPECT(!invariant.finalize( + makeOfferCreateTx(), tesSUCCESS, XRPAmount{}, view, missingRootJlog)); + BEAST_EXPECT( + missingRootSink.messages().str().contains("book directory root missing")); + } + { + // delete + view.rawErase(badRoot); + BEAST_EXPECT(!view.exists(rootDir)); + + ValidBookDirectory invariant; + invariant.visitEntry(true, badRoot, badRoot); + BEAST_EXPECT( + invariant.finalize(makeOfferCreateTx(), tesSUCCESS, XRPAmount{}, view, jlog)); + } + } + } + + static SLE::pointer + createPermissionedDomain( + ApplyContext& ac, + test::jtx::Account const& a1, + test::jtx::Account const& a2, + std::uint32_t numCreds = 2, + std::uint32_t seq = 10) + { + Keylet const pdKeylet = keylet::permissionedDomain(a1.id(), SeqProxy::rawSequence(seq)); + auto sle = std::make_shared(pdKeylet); + + sle->setAccountID(sfOwner, a1); + sle->setFieldU32(sfSequence, seq); + + if (numCreds != 0u) + { + // This array is sorted naturally, but if you are going to change + // this behavior, don't forget to use credentials::makeSorted + STArray credentials(sfAcceptedCredentials, numCreds); + for (std::size_t n = 0; n < numCreds; ++n) + { + auto cred = STObject::makeInnerObject(sfCredential); + cred.setAccountID(sfIssuer, a2); + auto credType = "cred_type" + std::to_string(n); + cred.setFieldVL(sfCredentialType, Slice(credType.c_str(), credType.size())); + credentials.pushBack(std::move(cred)); + } + sle->setFieldArray(sfAcceptedCredentials, credentials); + } + + ac.view().insert(sle); + return sle; + } + + static std::pair + createPermissionedDomainEnv( + test::jtx::Env& env, + test::jtx::Account const& a1, + test::jtx::Account const& a2, + std::uint32_t numCreds = 2) + { + using namespace test::jtx; + + pdomain::Credentials credentials; + + for (std::size_t n = 0; n < numCreds; ++n) + { + auto credType = "cred_type" + std::to_string(n); + credentials.push_back({.issuer = a2, .credType = credType}); + } + + std::uint32_t const seq = env.seq(a1); + env(pdomain::setTx(a1, credentials)); + uint256 const key = pdomain::getNewDomain(env.meta()); + + return {seq, key}; + } + + void + run() override + { + testPermissionedDomainInvariants(all_); + testPermissionedDomainInvariants(all_ - fixCleanup3_1_3); + testPermissionedDEX(all_); + testPermissionedDEX(all_ - fixCleanup3_1_3); + testPermissionedDEXDeletedOfferFallback(); + testBookDirectoryExchangeRate(); + } +}; + +BEAST_DEFINE_TESTSUITE(InvariantsPermissioned, app, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/invariants/InvariantsPseudoAccount_test.cpp b/src/test/app/invariants/InvariantsPseudoAccount_test.cpp new file mode 100644 index 0000000000..c43e73aca8 --- /dev/null +++ b/src/test/app/invariants/InvariantsPseudoAccount_test.cpp @@ -0,0 +1,461 @@ +#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 { + +class InvariantsPseudoAccount_test : public InvariantsBase +{ + void + testValidPseudoAccounts() + { + testcase << "valid pseudo accounts"; + + using namespace jtx; + + AccountID pseudoAccountID; + Preclose const createPseudo = [&, this](Account const& a, Account const& b, Env& env) { + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + + // Create vault + Vault const vault{env}; + auto [tx, vKeylet] = vault.create({.owner = a, .asset = xrpAsset}); + env(tx); + env.close(); + if (auto const vSle = env.le(vKeylet); BEAST_EXPECT(vSle)) + { + pseudoAccountID = vSle->at(sfAccount); + } + + return BEAST_EXPECT(env.le(keylet::account(pseudoAccountID))); + }; + + /* Cases to check + "pseudo-account has 0 pseudo-account fields set" + "pseudo-account has 2 pseudo-account fields set" + "pseudo-account sequence changed" + "pseudo-account flags are not set" + "pseudo-account has a regular key" + "pseudo-account has a sponsorship field" + */ + struct Mod + { + std::string expectedFailure; + std::function func; + }; + auto const mods = std::to_array({ + { + .expectedFailure = "pseudo-account has 0 pseudo-account fields set", + .func = + [this](SLE::pointer& sle) { + BEAST_EXPECT(sle->at(~sfVaultID)); + sle->at(~sfVaultID) = std::nullopt; + }, + }, + { + .expectedFailure = "pseudo-account sequence changed", + .func = [](SLE::pointer& sle) { sle->at(sfSequence) = 12345; }, + }, + { + .expectedFailure = "pseudo-account flags are not set", + .func = [](SLE::pointer& sle) { sle->at(sfFlags) = lsfNoFreeze; }, + }, + { + .expectedFailure = "pseudo-account has a regular key", + .func = [](SLE::pointer& sle) { sle->at(sfRegularKey) = Account("regular").id(); }, + }, + { + .expectedFailure = "pseudo-account has a sponsorship field", + .func = [](SLE::pointer& sle) { sle->at(sfSponsoredOwnerCount) = 1; }, + }, + { + .expectedFailure = "pseudo-account has a sponsorship field", + .func = [](SLE::pointer& sle) { sle->at(sfSponsoringOwnerCount) = 1; }, + }, + { + .expectedFailure = "pseudo-account has a sponsorship field", + .func = [](SLE::pointer& sle) { sle->at(sfSponsoringAccountCount) = 1; }, + }, + { + .expectedFailure = "pseudo-account has a sponsorship field", + .func = [](SLE::pointer& sle) { sle->at(sfSponsor) = Account("sponsor").id(); }, + }, + }); + + for (auto const& mod : mods) + { + doInvariantCheck( + {{mod.expectedFailure}}, + [&](Account const& a1, Account const&, ApplyContext& ac) { + auto sle = ac.view().peek(keylet::account(pseudoAccountID)); + if (!sle) + return false; + mod.func(sle); + ac.view().update(sle); + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + createPseudo); + } + for (auto const pField : getPseudoAccountFields()) + { + // createPseudo creates a vault, so sfVaultID will be set, and + // setting it again will not cause an error + if (pField == &sfVaultID) + continue; + doInvariantCheck( + {{"pseudo-account has 2 pseudo-account fields set"}}, + [&](Account const& a1, Account const&, ApplyContext& ac) { + auto sle = ac.view().peek(keylet::account(pseudoAccountID)); + if (!sle) + return false; + + auto const vaultID = ~sle->at(~sfVaultID); + BEAST_EXPECT(vaultID && !sle->isFieldPresent(*pField)); + sle->setFieldH256(*pField, *vaultID); + + ac.view().update(sle); + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + createPseudo); + } + + // Take one of the regular accounts and set the sequence to 0, which + // will make it look like a pseudo-account + doInvariantCheck( + {{"pseudo-account has 0 pseudo-account fields set"}, + {"pseudo-account sequence changed"}, + {"pseudo-account flags are not set"}}, + [&](Account const& a1, Account const&, ApplyContext& ac) { + auto sle = ac.view().peek(keylet::account(a1.id())); + if (!sle) + return false; + sle->at(sfSequence) = 0; + ac.view().update(sle); + return true; + }); + } + + void + testValidLoanBroker() + { + testcase << "valid loan broker"; + + using namespace jtx; + + enum class Asset { XRP, IOU, MPT }; + auto const assetTypes = std::to_array({Asset::XRP, Asset::IOU, Asset::MPT}); + + for (auto const assetType : assetTypes) + { + // Initialize with a placeholder value because there's no default + // ctor + auto const setupAsset = + [&](Account const& alice, Account const& issuer, Env& env) -> PrettyAsset { + switch (assetType) + { + case Asset::IOU: { + PrettyAsset const iouAsset = issuer["IOU"]; + env(trust(alice, iouAsset(1000))); + env(pay(issuer, alice, iouAsset(1000))); + env.close(); + return iouAsset; + } + case Asset::MPT: { + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); + PrettyAsset const mptAsset = mptt.issuanceID(); + mptt.authorize({.account = alice}); + env(pay(issuer, alice, mptAsset(1000))); + env.close(); + return mptAsset; + } + case Asset::XRP: + default: + return PrettyAsset{xrpIssue(), 1'000'000}; + } + }; + + Keylet loanBrokerKeylet = keylet::amendments(); + Preclose const createLoanBroker = + [&, this](Account const& alice, Account const& issuer, Env& env) { + auto const asset = setupAsset(alice, issuer, env); + loanBrokerKeylet = this->createLoanBroker(alice, env, asset); + return BEAST_EXPECT(env.le(loanBrokerKeylet)); + }; + + // Ensure the test scenarios are set up completely. The test cases + // will need to recompute any of these values it needs for itself + // rather than trying to return a bunch of items + auto setupTest = [&, this](Account const& a1, Account const&, ApplyContext& ac) + -> std::optional> { + if (loanBrokerKeylet.type != ltLOAN_BROKER) + return {}; + auto sleBroker = ac.view().peek(loanBrokerKeylet); + if (!sleBroker) + return {}; + if (!BEAST_EXPECT(sleBroker->at(sfOwnerCount) == 0)) + return {}; + // Need to touch sleBroker so that it is included in the + // modified entries for the invariant to find + ac.view().update(sleBroker); + + // The pseudo-account holds the directory, so get it + auto const pseudoAccountID = sleBroker->at(sfAccount); + auto const pseudoAccountKeylet = keylet::account(pseudoAccountID); + // Strictly speaking, we don't need to load the + // ACCOUNT_ROOT, but check anyway + auto slePseudo = ac.view().peek(pseudoAccountKeylet); + if (!BEAST_EXPECT(slePseudo)) + return {}; + // Make sure the directory doesn't already exist + auto const dirKeylet = keylet::ownerDir(pseudoAccountID); + auto sleDir = ac.view().peek(dirKeylet); + auto const describe = describeOwnerDir(pseudoAccountID); + if (!sleDir) + { + // Create the directory + BEAST_EXPECT( + ::xrpl::directory::createRoot( + ac.view(), dirKeylet, loanBrokerKeylet.key, describe) == 0); + + sleDir = ac.view().peek(dirKeylet); + } + + return std::make_pair(slePseudo, sleDir); + }; + + doInvariantCheck( + {{"Loan Broker with zero OwnerCount has multiple directory " + "pages"}}, + [&setupTest, this](Account const& a1, Account const& a2, ApplyContext& ac) { + auto test = setupTest(a1, a2, ac); + if (!test || !test->first || !test->second) + return false; + + auto slePseudo = test->first; + auto sleDir = test->second; + auto const describe = describeOwnerDir(slePseudo->at(sfAccount)); + + BEAST_EXPECT( + ::xrpl::directory::insertPage( + ac.view(), + 0, + sleDir, + 0, + sleDir, + slePseudo->key(), + keylet::page(sleDir->key(), 0), + describe) == 1); + + return true; + }, + XRPAmount{}, + STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + createLoanBroker); + + doInvariantCheck( + {{"Loan Broker with zero OwnerCount has multiple indexes in " + "the Directory root"}}, + [&setupTest](Account const& a1, Account const& a2, ApplyContext& ac) { + auto test = setupTest(a1, a2, ac); + if (!test || !test->first || !test->second) + return false; + + auto slePseudo = test->first; + auto sleDir = test->second; + auto indexes = sleDir->getFieldV256(sfIndexes); + + // Put some extra garbage into the directory + for (auto const& key : {slePseudo->key(), sleDir->key()}) + { + ::xrpl::directory::insertKey(ac.view(), sleDir, 0, false, indexes, key); + } + + return true; + }, + XRPAmount{}, + STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + createLoanBroker); + + doInvariantCheck( + {{"Loan Broker directory corrupt"}}, + [&setupTest](Account const& a1, Account const& a2, ApplyContext& ac) { + auto test = setupTest(a1, a2, ac); + if (!test || !test->first || !test->second) + return false; + + auto slePseudo = test->first; + auto sleDir = test->second; + auto const describe = describeOwnerDir(slePseudo->at(sfAccount)); + // Empty vector will overwrite the existing entry for the + // holding, if any, avoiding the "has multiple indexes" + // failure. + STVector256 indexes; + + // Put one meaningless key into the directory + auto const key = keylet::account(Account("random").id()).key; + ::xrpl::directory::insertKey(ac.view(), sleDir, 0, false, indexes, key); + + return true; + }, + XRPAmount{}, + STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + createLoanBroker); + + doInvariantCheck( + {{"Loan Broker with zero OwnerCount has an unexpected entry in " + "the directory"}}, + [&setupTest](Account const& a1, Account const& a2, ApplyContext& ac) { + auto test = setupTest(a1, a2, ac); + if (!test || !test->first || !test->second) + return false; + + auto slePseudo = test->first; + auto sleDir = test->second; + // Empty vector will overwrite the existing entry for the + // holding, if any, avoiding the "has multiple indexes" + // failure. + STVector256 indexes; + + ::xrpl::directory::insertKey( + ac.view(), sleDir, 0, false, indexes, slePseudo->key()); + + return true; + }, + XRPAmount{}, + STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + createLoanBroker); + + doInvariantCheck( + {{"Loan Broker sequence number decreased"}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + if (loanBrokerKeylet.type != ltLOAN_BROKER) + return false; + auto sleBroker = ac.view().peek(loanBrokerKeylet); + if (!sleBroker) + return false; + if (!BEAST_EXPECT(sleBroker->at(sfLoanSequence) > 0)) + return false; + // Need to touch sleBroker so that it is included in the + // modified entries for the invariant to find + ac.view().update(sleBroker); + + sleBroker->at(sfLoanSequence) -= 1; + + return true; + }, + XRPAmount{}, + STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + createLoanBroker); + + // Test: cover available less than pseudo-account asset balance + { + Keylet brokerKeylet = keylet::amendments(); + Preclose const createBrokerWithCover = + [&, this](Account const& alice, Account const& issuer, Env& env) { + auto const asset = setupAsset(alice, issuer, env); + brokerKeylet = this->createLoanBroker(alice, env, asset); + if (!BEAST_EXPECT(env.le(brokerKeylet))) + return false; + env(loan_broker::coverDeposit(alice, brokerKeylet.key, asset(10))); + env.close(); + return BEAST_EXPECT(env.le(brokerKeylet)); + }; + + doInvariantCheck( + {{"Loan Broker cover available is less than pseudo-account asset balance"}}, + [&](Account const&, Account const&, ApplyContext& ac) { + auto sle = ac.view().peek(brokerKeylet); + if (!BEAST_EXPECT(sle)) + return false; + // Pseudo-account holds 10 units, set cover to 5 + sle->at(sfCoverAvailable) = Number(5); + ac.view().update(sle); + return true; + }, + XRPAmount{}, + STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + createBrokerWithCover); + } + + // Test: cover available greater than pseudo-account asset balance + // (requires fixCleanup3_1_3) + doInvariantCheck( + {{"Loan Broker cover available is greater than pseudo-account asset balance"}}, + [&](Account const&, Account const&, ApplyContext& ac) { + auto sle = ac.view().peek(loanBrokerKeylet); + if (!BEAST_EXPECT(sle)) + return false; + // Pseudo-account has no cover deposited; set cover + // higher than any incidental balance + sle->at(sfCoverAvailable) = Number(1'000'000); + ac.view().update(sle); + return true; + }, + XRPAmount{}, + STTx{ttLOAN_BROKER_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + createLoanBroker); + } + } + + void + run() override + { + testValidPseudoAccounts(); + testValidLoanBroker(); + } +}; + +BEAST_DEFINE_TESTSUITE(InvariantsPseudoAccount, app, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/invariants/InvariantsTrustLine_test.cpp b/src/test/app/invariants/InvariantsTrustLine_test.cpp new file mode 100644 index 0000000000..e0995fc431 --- /dev/null +++ b/src/test/app/invariants/InvariantsTrustLine_test.cpp @@ -0,0 +1,237 @@ +#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 { + +class InvariantsTrustLine_test : public InvariantsBase +{ + void + testNoXRPTrustLine() + { + using namespace test::jtx; + testcase << "trust lines with XRP not allowed"; + doInvariantCheck( + {{"an XRP trust line was created"}}, + [](Account const& a1, Account const& a2, ApplyContext& ac) { + // create simple trust SLE with xrp currency + auto const sleNew = + std::make_shared(keylet::trustLine(a1, a2, xrpIssue().currency)); + ac.view().insert(sleNew); + return true; + }); + } + + void + testNoDeepFreezeTrustLinesWithoutFreeze() + { + using namespace test::jtx; + testcase << "trust lines with deep freeze flag without freeze " + "not allowed"; + doInvariantCheck( + {{"a trust line with deep freeze flag without normal freeze was " + "created"}}, + [](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const sleNew = + std::make_shared(keylet::trustLine(a1, a2, a1["USD"].currency)); + sleNew->setFieldAmount(sfLowLimit, a1["USD"](0)); + sleNew->setFieldAmount(sfHighLimit, a1["USD"](0)); + + std::uint32_t uFlags = 0u; + uFlags |= lsfLowDeepFreeze; + sleNew->setFieldU32(sfFlags, uFlags); + ac.view().insert(sleNew); + return true; + }); + + doInvariantCheck( + {{"a trust line with deep freeze flag without normal freeze was " + "created"}}, + [](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const sleNew = + std::make_shared(keylet::trustLine(a1, a2, a1["USD"].currency)); + sleNew->setFieldAmount(sfLowLimit, a1["USD"](0)); + sleNew->setFieldAmount(sfHighLimit, a1["USD"](0)); + std::uint32_t uFlags = 0u; + uFlags |= lsfHighDeepFreeze; + sleNew->setFieldU32(sfFlags, uFlags); + ac.view().insert(sleNew); + return true; + }); + + doInvariantCheck( + {{"a trust line with deep freeze flag without normal freeze was " + "created"}}, + [](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const sleNew = + std::make_shared(keylet::trustLine(a1, a2, a1["USD"].currency)); + sleNew->setFieldAmount(sfLowLimit, a1["USD"](0)); + sleNew->setFieldAmount(sfHighLimit, a1["USD"](0)); + std::uint32_t uFlags = 0u; + uFlags |= lsfLowDeepFreeze | lsfHighDeepFreeze; + sleNew->setFieldU32(sfFlags, uFlags); + ac.view().insert(sleNew); + return true; + }); + + doInvariantCheck( + {{"a trust line with deep freeze flag without normal freeze was " + "created"}}, + [](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const sleNew = + std::make_shared(keylet::trustLine(a1, a2, a1["USD"].currency)); + sleNew->setFieldAmount(sfLowLimit, a1["USD"](0)); + sleNew->setFieldAmount(sfHighLimit, a1["USD"](0)); + std::uint32_t uFlags = 0u; + uFlags |= lsfLowDeepFreeze | lsfHighFreeze; + sleNew->setFieldU32(sfFlags, uFlags); + ac.view().insert(sleNew); + return true; + }); + + doInvariantCheck( + {{"a trust line with deep freeze flag without normal freeze was " + "created"}}, + [](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const sleNew = + std::make_shared(keylet::trustLine(a1, a2, a1["USD"].currency)); + sleNew->setFieldAmount(sfLowLimit, a1["USD"](0)); + sleNew->setFieldAmount(sfHighLimit, a1["USD"](0)); + std::uint32_t uFlags = 0u; + uFlags |= lsfLowFreeze | lsfHighDeepFreeze; + sleNew->setFieldU32(sfFlags, uFlags); + ac.view().insert(sleNew); + return true; + }); + } + + void + testTransfersNotFrozen() + { + using namespace test::jtx; + testcase << "transfers when frozen"; + + Account const g1{"G1"}; + // Helper function to establish the trustlines + auto const createTrustlines = [&](Account const& a1, Account const& a2, Env& env) { + // Preclose callback to establish trust lines with gateway + env.fund(XRP(1000), g1); + + env.trust(g1["USD"](10000), a1); + env.trust(g1["USD"](10000), a2); + env.close(); + + env(pay(g1, a1, g1["USD"](1000))); + env(pay(g1, a2, g1["USD"](1000))); + env.close(); + + return true; + }; + + auto const a1FrozenByIssuer = [&](Account const& a1, Account const& a2, Env& env) { + createTrustlines(a1, a2, env); + env(trust(g1, a1["USD"](10000), tfSetFreeze)); + env.close(); + + return true; + }; + + auto const a1DeepFrozenByIssuer = [&](Account const& a1, Account const& a2, Env& env) { + a1FrozenByIssuer(a1, a2, env); + env(trust(g1, a1["USD"](10000), tfSetDeepFreeze)); + env.close(); + + return true; + }; + + auto const changeBalances = [&](Account const& a1, + Account const& a2, + ApplyContext& ac, + int a1Balance, + int a2Balance) { + auto const sleA1 = ac.view().peek(keylet::trustLine(a1, g1["USD"])); + auto const sleA2 = ac.view().peek(keylet::trustLine(a2, g1["USD"])); + + sleA1->setFieldAmount(sfBalance, g1["USD"](a1Balance)); + sleA2->setFieldAmount(sfBalance, g1["USD"](a2Balance)); + + ac.view().update(sleA1); + ac.view().update(sleA2); + }; + + // test: imitating frozen A1 making a payment to A2. + doInvariantCheck( + {{"Attempting to move frozen funds"}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + changeBalances(a1, a2, ac, -900, -1100); + return true; + }, + XRPAmount{}, + STTx{ttPAYMENT, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + a1FrozenByIssuer); + + // test: imitating deep frozen A1 making a payment to A2. + doInvariantCheck( + {{"Attempting to move frozen funds"}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + changeBalances(a1, a2, ac, -900, -1100); + return true; + }, + XRPAmount{}, + STTx{ttPAYMENT, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + a1DeepFrozenByIssuer); + + // test: imitating A2 making a payment to deep frozen A1. + doInvariantCheck( + {{"Attempting to move frozen funds"}}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + changeBalances(a1, a2, ac, -1100, -900); + return true; + }, + XRPAmount{}, + STTx{ttPAYMENT, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + a1DeepFrozenByIssuer); + } + + void + run() override + { + testNoXRPTrustLine(); + testNoDeepFreezeTrustLinesWithoutFreeze(); + testTransfersNotFrozen(); + } +}; + +BEAST_DEFINE_TESTSUITE(InvariantsTrustLine, app, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/invariants/InvariantsVault_test.cpp b/src/test/app/invariants/InvariantsVault_test.cpp new file mode 100644 index 0000000000..abcbe343f5 --- /dev/null +++ b/src/test/app/invariants/InvariantsVault_test.cpp @@ -0,0 +1,2091 @@ +#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 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl::test { + +class InvariantsVault_test : public InvariantsBase +{ + FeatureBitset const all_{test::jtx::testableAmendments()}; + + void + testVault() // NOLINT(readability-function-size) + { + using namespace test::jtx; + + struct AccountAmount + { + AccountID account; + int amount; + }; + struct Adjustments + { + // NOLINTBEGIN(readability-redundant-member-init) + std::optional assetsTotal = std::nullopt; + std::optional assetsAvailable = std::nullopt; + std::optional lossUnrealized = std::nullopt; + std::optional assetsMaximum = std::nullopt; + std::optional sharesTotal = std::nullopt; + std::optional vaultAssets = std::nullopt; + std::optional accountAssets = std::nullopt; + std::optional accountShares = std::nullopt; + // NOLINTEND(readability-redundant-member-init) + }; + constexpr auto kAdjust = [&](ApplyView& ac, xrpl::Keylet keylet, Adjustments args) { + // Avoid uint64 + negative-int wrap (flagged by UBSan + // unsigned-integer-overflow) when adjusting UINT64 fields. + auto const addSigned = [](std::uint64_t current, int adj) -> std::uint64_t { + return adj >= 0 // + ? current + static_cast(adj) + : current - static_cast(-adj); + }; + auto sleVault = ac.peek(keylet); + if (!sleVault) + return false; + + auto const mptIssuanceID = (*sleVault)[sfShareMPTID]; + auto sleShares = ac.peek(keylet::mptokenIssuance(mptIssuanceID)); + if (!sleShares) + return false; + + // These two fields are adjusted in absolute terms + if (args.lossUnrealized) + (*sleVault)[sfLossUnrealized] = *args.lossUnrealized; + if (args.assetsMaximum) + (*sleVault)[sfAssetsMaximum] = *args.assetsMaximum; + + // Remaining fields are adjusted in terms of difference + if (args.assetsTotal) + (*sleVault)[sfAssetsTotal] = *(*sleVault)[sfAssetsTotal] + *args.assetsTotal; + if (args.assetsAvailable) + { + (*sleVault)[sfAssetsAvailable] = + *(*sleVault)[sfAssetsAvailable] + *args.assetsAvailable; + } + ac.update(sleVault); + + if (args.sharesTotal) + { + (*sleShares)[sfOutstandingAmount] = + addSigned(*(*sleShares)[sfOutstandingAmount], *args.sharesTotal); + ac.update(sleShares); + } + + auto const assets = *(*sleVault)[sfAsset]; + auto const pseudoId = *(*sleVault)[sfAccount]; + if (args.vaultAssets) + { + if (assets.native()) + { + auto slePseudoAccount = ac.peek(keylet::account(pseudoId)); + if (!slePseudoAccount) + return false; + (*slePseudoAccount)[sfBalance] = + *(*slePseudoAccount)[sfBalance] + *args.vaultAssets; + ac.update(slePseudoAccount); + } + else if (assets.holds()) + { + auto const mptId = assets.get().getMptID(); + auto sleMPToken = ac.peek(keylet::mptoken(mptId, pseudoId)); + if (!sleMPToken) + return false; + (*sleMPToken)[sfMPTAmount] = + addSigned(*(*sleMPToken)[sfMPTAmount], *args.vaultAssets); + ac.update(sleMPToken); + } + else + { + return false; // Not supporting testing with IOU + } + } + + if (args.accountAssets) + { + auto const& pair = *args.accountAssets; + if (assets.native()) + { + auto sleAccount = ac.peek(keylet::account(pair.account)); + if (!sleAccount) + return false; + (*sleAccount)[sfBalance] = *(*sleAccount)[sfBalance] + pair.amount; + ac.update(sleAccount); + } + else if (assets.holds()) + { + auto const mptID = assets.get().getMptID(); + auto sleMPToken = ac.peek(keylet::mptoken(mptID, pair.account)); + if (!sleMPToken) + return false; + (*sleMPToken)[sfMPTAmount] = + addSigned(*(*sleMPToken)[sfMPTAmount], pair.amount); + ac.update(sleMPToken); + } + else + { + return false; // Not supporting testing with IOU + } + } + + if (args.accountShares) + { + auto const& pair = *args.accountShares; + auto sleMPToken = ac.peek(keylet::mptoken(mptIssuanceID, pair.account)); + if (!sleMPToken) + return false; + (*sleMPToken)[sfMPTAmount] = addSigned(*(*sleMPToken)[sfMPTAmount], pair.amount); + ac.update(sleMPToken); + } + return true; + }; + + static constexpr auto kArgs = [](AccountID id, int adjustment, auto fn) -> Adjustments { + Adjustments sample = { + .assetsTotal = adjustment, + .assetsAvailable = adjustment, + .lossUnrealized = 0, + .sharesTotal = adjustment, + .vaultAssets = adjustment, + .accountAssets = // + AccountAmount{.account = id, .amount = -adjustment}, + .accountShares = // + AccountAmount{.account = id, .amount = adjustment}}; + fn(sample); + return sample; + }; + + Account const a3{"A3"}; + Account const a4{"A4"}; + auto const precloseXrp = [&](Account const& a1, Account const& a2, Env& env) -> bool { + env.fund(XRP(1000), a3, a4); + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(tx); + env(vault.deposit({.depositor = a1, .id = keylet.key, .amount = XRP(10)})); + env(vault.deposit({.depositor = a2, .id = keylet.key, .amount = XRP(10)})); + env(vault.deposit({.depositor = a3, .id = keylet.key, .amount = XRP(10)})); + return true; + }; + + testcase << "Vault general checks"; + doInvariantCheck( + {"vault deletion succeeded without deleting a vault"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + auto sleVault = ac.view().peek(keylet); + if (!sleVault) + return false; + ac.view().update(sleVault); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_DELETE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + Vault const vault{env}; + auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(tx); + return true; + }); + + doInvariantCheck( + {"vault updated by a wrong transaction type", + "deleted Vault without deleting its pseudo-account"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + auto sleVault = ac.view().peek(keylet); + if (!sleVault) + return false; + ac.view().erase(sleVault); + return true; + }, + XRPAmount{}, + STTx{ttPAYMENT, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + Vault const vault{env}; + auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(tx); + return true; + }); + + doInvariantCheck( + {"vault updated by a wrong transaction type"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + auto sleVault = ac.view().peek(keylet); + if (!sleVault) + return false; + ac.view().update(sleVault); + return true; + }, + XRPAmount{}, + STTx{ttPAYMENT, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + Vault const vault{env}; + auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(tx); + return true; + }); + + doInvariantCheck( + {"vault updated by a wrong transaction type"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const sequence = ac.view().seq(); + auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(sequence)); + auto sleVault = std::make_shared(vaultKeylet); + auto const vaultPage = ac.view().dirInsert( + keylet::ownerDir(a1.id()), sleVault->key(), describeOwnerDir(a1.id())); + sleVault->setFieldU64(sfOwnerNode, *vaultPage); + sleVault->setAccountID(sfAccount, a1.id()); + ac.view().insert(sleVault); + return true; + }, + XRPAmount{}, + STTx{ttPAYMENT, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); + + doInvariantCheck( + {"vault deleted by a wrong transaction type", + "deleted Vault without deleting its pseudo-account"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + auto sleVault = ac.view().peek(keylet); + if (!sleVault) + return false; + ac.view().erase(sleVault); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_SET, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + Vault const vault{env}; + auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(tx); + return true; + }); + + doInvariantCheck( + {"vault operation updated more than single vault", + "deleted Vault without deleting its pseudo-account"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + { + auto const keylet = + keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + auto sleVault = ac.view().peek(keylet); + if (!sleVault) + return false; + ac.view().erase(sleVault); + } + { + auto const keylet = + keylet::vault(a2.id(), SeqProxy::rawSequence(ac.view().seq())); + auto sleVault = ac.view().peek(keylet); + if (!sleVault) + return false; + ac.view().erase(sleVault); + } + return true; + }, + XRPAmount{}, + STTx{ttVAULT_DELETE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + Vault const vault{env}; + { + auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(tx); + } + { + auto [tx, _] = vault.create({.owner = a2, .asset = xrpIssue()}); + env(tx); + } + return true; + }); + + doInvariantCheck( + {"vault operation updated more than single vault"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const sequence = ac.view().seq(); + auto const insertVault = [&](Account const a) { + auto const vaultKeylet = keylet::vault(a.id(), SeqProxy::rawSequence(sequence)); + auto sleVault = std::make_shared(vaultKeylet); + auto const vaultPage = ac.view().dirInsert( + keylet::ownerDir(a.id()), sleVault->key(), describeOwnerDir(a.id())); + sleVault->setFieldU64(sfOwnerNode, *vaultPage); + sleVault->setAccountID(sfAccount, a.id()); + ac.view().insert(sleVault); + }; + insertVault(a1); + insertVault(a2); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_CREATE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); + + doInvariantCheck( + {"deleted vault must also delete shares", + "deleted Vault without deleting its pseudo-account"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + auto sleVault = ac.view().peek(keylet); + if (!sleVault) + return false; + ac.view().erase(sleVault); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_DELETE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + Vault const vault{env}; + auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(tx); + return true; + }); + + doInvariantCheck( + {"deleted vault must have no shares outstanding", + "deleted vault must have no assets outstanding", + "deleted vault must have no assets available"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + auto sleVault = ac.view().peek(keylet); + if (!sleVault) + return false; + auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID])); + if (!sleShares) + return false; + ac.view().erase(sleVault); + ac.view().erase(sleShares); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_DELETE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(tx); + env(vault.deposit({.depositor = a1, .id = keylet.key, .amount = XRP(10)})); + return true; + }); + + doInvariantCheck( + {"vault operation succeeded without modifying a vault"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + auto sleVault = ac.view().peek(keylet); + if (!sleVault) + return false; + auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID])); + if (!sleShares) + return false; + // Note, such an "orphaned" update of MPT issuance attached to a + // vault is invalid; ttVAULT_SET must also update Vault object. + sleShares->setFieldH256(sfDomainID, uint256(13)); + ac.view().update(sleShares); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + doInvariantCheck( + {"vault operation succeeded without modifying a vault"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { return true; }, + XRPAmount{}, + STTx{ttVAULT_CREATE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + Vault const vault{env}; + auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(tx); + return true; + }); + + doInvariantCheck( + {"vault operation succeeded without modifying a vault"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { return true; }, + XRPAmount{}, + STTx{ttVAULT_DEPOSIT, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + Vault const vault{env}; + auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(tx); + return true; + }); + + doInvariantCheck( + {"vault operation succeeded without modifying a vault"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { return true; }, + XRPAmount{}, + STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + Vault const vault{env}; + auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(tx); + return true; + }); + + doInvariantCheck( + {"vault operation succeeded without modifying a vault"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { return true; }, + XRPAmount{}, + STTx{ttVAULT_CLAWBACK, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + Vault const vault{env}; + auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(tx); + return true; + }); + + doInvariantCheck( + {"vault operation succeeded without modifying a vault"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { return true; }, + XRPAmount{}, + STTx{ttVAULT_DELETE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + Vault const vault{env}; + auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(tx); + return true; + }); + + doInvariantCheck( + {"updated vault must have shares"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + auto sleVault = ac.view().peek(keylet); + if (!sleVault) + return false; + (*sleVault)[sfAssetsMaximum] = 200; + ac.view().update(sleVault); + + auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID])); + if (!sleShares) + return false; + ac.view().erase(sleShares); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_SET, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + Vault const vault{env}; + auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(tx); + return true; + }); + + doInvariantCheck( + {"vault operation succeeded without updating shares", + "assets available must not be greater than assets outstanding"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + auto sleVault = ac.view().peek(keylet); + if (!sleVault) + return false; + (*sleVault)[sfAssetsTotal] = 9; + ac.view().update(sleVault); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(tx); + env(vault.deposit({.depositor = a1, .id = keylet.key, .amount = XRP(10)})); + return true; + }); + + doInvariantCheck( + {"set must not change assets outstanding", + "set must not change assets available", + "set must not change shares outstanding", + "set must not change vault balance", + "assets available must not be negative", + "assets available must not be greater than assets outstanding", + "assets outstanding must not be negative"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + auto sleVault = ac.view().peek(keylet); + if (!sleVault) + return false; + auto slePseudoAccount = ac.view().peek(keylet::account(*(*sleVault)[sfAccount])); + if (!slePseudoAccount) + return false; + (*slePseudoAccount)[sfBalance] = *(*slePseudoAccount)[sfBalance] - 10; + ac.view().update(slePseudoAccount); + + // Move 10 drops to A4 to enforce total XRP balance + auto sleA4 = ac.view().peek(keylet::account(a4.id())); + if (!sleA4) + return false; + (*sleA4)[sfBalance] = *(*sleA4)[sfBalance] + 10; + ac.view().update(sleA4); + + return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) { + sample.assetsAvailable = (kDropsPerXrp * -100).value(); + sample.assetsTotal = (kDropsPerXrp * -200).value(); + sample.sharesTotal = -1; + })); + }, + XRPAmount{}, + STTx{ttVAULT_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + doInvariantCheck( + {"violation of vault immutable data"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + auto sleVault = ac.view().peek(keylet); + if (!sleVault) + return false; + sleVault->setFieldIssue(sfAsset, STIssue{sfAsset, MPTIssue(MPTID(42))}); + ac.view().update(sleVault); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp); + + doInvariantCheck( + {"violation of vault immutable data"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + auto sleVault = ac.view().peek(keylet); + if (!sleVault) + return false; + sleVault->setAccountID(sfAccount, a2.id()); + ac.view().update(sleVault); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp); + + doInvariantCheck( + {"violation of vault immutable data"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + auto sleVault = ac.view().peek(keylet); + if (!sleVault) + return false; + (*sleVault)[sfShareMPTID] = MPTID(42); + ac.view().update(sleVault); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp); + + doInvariantCheck( + {"vault transaction must not change loss unrealized", + "set must not change assets outstanding"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) { + sample.lossUnrealized = 13; + sample.assetsTotal = 20; + })); + }, + XRPAmount{}, + STTx{ttVAULT_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + doInvariantCheck( + {"loss unrealized must not exceed the difference " + "between assets outstanding and available", + "vault transaction must not change loss unrealized"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + return kAdjust(ac.view(), keylet, kArgs(a2.id(), 100, [&](Adjustments& sample) { + sample.lossUnrealized = 13; + })); + }, + XRPAmount{}, + STTx{ + ttVAULT_DEPOSIT, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(200)); }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + // A negative loss unrealized must trip the invariant. ttLOAN_MANAGE is + // allowed to change loss unrealized, so it isolates this check from the + // "must not change loss unrealized" invariant. Gated behind + // fixCleanup3_4_0 (see below). + doInvariantCheck( + {"loss unrealized must not be negative"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) { + sample.lossUnrealized = -1; + })); + }, + XRPAmount{}, + STTx{ttLOAN_MANAGE, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + // Without fixCleanup3_4_0 the same state must NOT trip the invariant, + // preserving pre-amendment behavior (no fork risk). + doInvariantCheck( + makeEnv(all_ - fixCleanup3_4_0), + {}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) { + sample.lossUnrealized = -1; + })); + }, + XRPAmount{}, + STTx{ttLOAN_MANAGE, [](STObject& tx) {}}, + {tesSUCCESS, tesSUCCESS}, + precloseXrp, + TxAccount::A2); + + doInvariantCheck( + {"set assets outstanding must not exceed assets maximum"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) { + sample.assetsMaximum = 1; + })); + }, + XRPAmount{}, + STTx{ttVAULT_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + doInvariantCheck( + {"assets maximum must not be negative"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) { + sample.assetsMaximum = -1; + })); + }, + XRPAmount{}, + STTx{ttVAULT_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + doInvariantCheck( + {"set must not change shares outstanding", + "updated zero sized vault must have no assets outstanding", + "updated zero sized vault must have no assets available"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + auto sleVault = ac.view().peek(keylet); + if (!sleVault) + return false; + ac.view().update(sleVault); + auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID])); + if (!sleShares) + return false; + (*sleShares)[sfOutstandingAmount] = 0; + ac.view().update(sleShares); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_SET, [](STObject& tx) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + doInvariantCheck( + {"updated shares must not exceed maximum"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + auto sleVault = ac.view().peek(keylet); + if (!sleVault) + return false; + auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID])); + if (!sleShares) + return false; + (*sleShares)[sfMaximumAmount] = 10; + ac.view().update(sleShares); + + return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [](Adjustments&) {})); + }, + XRPAmount{}, + STTx{ttVAULT_DEPOSIT, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + doInvariantCheck( + {"updated shares must not exceed maximum"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [](Adjustments&) {})); + + auto sleVault = ac.view().peek(keylet); + if (!sleVault) + return false; + auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID])); + if (!sleShares) + return false; + (*sleShares)[sfOutstandingAmount] = kMaxMpTokenAmount + 1; + ac.view().update(sleShares); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_DEPOSIT, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + testcase << "Vault create"; + doInvariantCheck( + { + "created vault must be empty", + "updated zero sized vault must have no assets outstanding", + "create operation must not have updated a vault", + }, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + auto sleVault = ac.view().peek(keylet); + if (!sleVault) + return false; + (*sleVault)[sfAssetsTotal] = 9; + ac.view().update(sleVault); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_CREATE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(tx); + return true; + }); + + doInvariantCheck( + { + "created vault must be empty", + "updated zero sized vault must have no assets available", + "assets available must not be greater than assets outstanding", + "create operation must not have updated a vault", + }, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + auto sleVault = ac.view().peek(keylet); + if (!sleVault) + return false; + (*sleVault)[sfAssetsAvailable] = 9; + ac.view().update(sleVault); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_CREATE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(tx); + return true; + }); + + doInvariantCheck( + { + "created vault must be empty", + "loss unrealized must not exceed the difference between assets " + "outstanding and available", + "vault transaction must not change loss unrealized", + "create operation must not have updated a vault", + }, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + auto sleVault = ac.view().peek(keylet); + if (!sleVault) + return false; + (*sleVault)[sfLossUnrealized] = 1; + ac.view().update(sleVault); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_CREATE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(tx); + return true; + }); + + doInvariantCheck( + { + "created vault must be empty", + "create operation must not have updated a vault", + "invalid OutstandingAmount balance 0 9 0", + }, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + auto sleVault = ac.view().peek(keylet); + if (!sleVault) + return false; + auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID])); + if (!sleShares) + return false; + ac.view().update(sleVault); + (*sleShares)[sfOutstandingAmount] = 9; + ac.view().update(sleShares); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_CREATE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(tx); + return true; + }); + + doInvariantCheck( + { + "assets maximum must not be negative", + "create operation must not have updated a vault", + }, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + auto sleVault = ac.view().peek(keylet); + if (!sleVault) + return false; + (*sleVault)[sfAssetsMaximum] = Number(-1); + ac.view().update(sleVault); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_CREATE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(tx); + return true; + }); + + doInvariantCheck( + {"create operation must not have updated a vault", + "shares issuer and vault pseudo-account must be the same", + "shares issuer must be a pseudo-account", + "shares issuer pseudo-account must point back to the vault"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + auto sleVault = ac.view().peek(keylet); + if (!sleVault) + return false; + auto sleShares = ac.view().peek(keylet::mptokenIssuance((*sleVault)[sfShareMPTID])); + if (!sleShares) + return false; + ac.view().update(sleVault); + (*sleShares)[sfIssuer] = a1.id(); + ac.view().update(sleShares); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_CREATE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + [&](Account const& a1, Account const& a2, Env& env) { + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = a1, .asset = xrpIssue()}); + env(tx); + return true; + }); + + doInvariantCheck( + {"vault created by a wrong transaction type", "account root created illegally"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + // The code below will create a valid vault with (almost) all + // the invariants holding. Except one: it is created by the + // wrong transaction type. + auto const sequence = ac.view().seq(); + auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(sequence)); + auto sleVault = std::make_shared(vaultKeylet); + auto const vaultPage = ac.view().dirInsert( + keylet::ownerDir(a1.id()), sleVault->key(), describeOwnerDir(a1.id())); + sleVault->setFieldU64(sfOwnerNode, *vaultPage); + + auto pseudoId = pseudoAccountAddress(ac.view(), vaultKeylet.key); + // Create pseudo-account. + auto sleAccount = std::make_shared(keylet::account(pseudoId)); + sleAccount->setAccountID(sfAccount, pseudoId); + sleAccount->setFieldAmount(sfBalance, STAmount{}); + std::uint32_t const seqno = // + ac.view().rules().enabled(featureSingleAssetVault) // + ? 0 // + : sequence; + sleAccount->setFieldU32(sfSequence, seqno); + sleAccount->setFieldU32( + sfFlags, lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth); + sleAccount->setFieldH256(sfVaultID, vaultKeylet.key); + ac.view().insert(sleAccount); + + auto const sharesMptId = makeMptID(sequence, pseudoId); + auto const sharesKeylet = keylet::mptokenIssuance(sharesMptId); + auto sleShares = std::make_shared(sharesKeylet); + auto const sharesPage = ac.view().dirInsert( + keylet::ownerDir(pseudoId), sharesKeylet, describeOwnerDir(pseudoId)); + sleShares->setFieldU64(sfOwnerNode, *sharesPage); + + sleShares->at(sfFlags) = 0; + sleShares->at(sfIssuer) = pseudoId; + sleShares->at(sfOutstandingAmount) = 0; + sleShares->at(sfSequence) = sequence; + + sleVault->at(sfAccount) = pseudoId; + sleVault->at(sfFlags) = 0; + sleVault->at(sfSequence) = sequence; + sleVault->at(sfOwner) = a1.id(); + sleVault->at(sfAssetsTotal) = Number(0); + sleVault->at(sfAssetsAvailable) = Number(0); + sleVault->at(sfLossUnrealized) = Number(0); + sleVault->at(sfShareMPTID) = sharesMptId; + sleVault->at(sfWithdrawalPolicy) = kVaultStrategyFirstComeFirstServe; + + ac.view().insert(sleVault); + ac.view().insert(sleShares); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_SET, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + + doInvariantCheck( + {"shares issuer and vault pseudo-account must be the same", + "shares issuer pseudo-account must point back to the vault"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const sequence = ac.view().seq(); + auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(sequence)); + auto sleVault = std::make_shared(vaultKeylet); + auto const vaultPage = ac.view().dirInsert( + keylet::ownerDir(a1.id()), sleVault->key(), describeOwnerDir(a1.id())); + sleVault->setFieldU64(sfOwnerNode, *vaultPage); + + auto pseudoId = pseudoAccountAddress(ac.view(), vaultKeylet.key); + // Create pseudo-account. + auto sleAccount = std::make_shared(keylet::account(pseudoId)); + sleAccount->setAccountID(sfAccount, pseudoId); + sleAccount->setFieldAmount(sfBalance, STAmount{}); + std::uint32_t const seqno = // + ac.view().rules().enabled(featureSingleAssetVault) // + ? 0 // + : sequence; + sleAccount->setFieldU32(sfSequence, seqno); + sleAccount->setFieldU32( + sfFlags, lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth); + // sleAccount->setFieldH256(sfVaultID, vaultKeylet.key); + // Setting wrong vault key + sleAccount->setFieldH256(sfVaultID, uint256(42)); + ac.view().insert(sleAccount); + + auto const sharesMptId = makeMptID(sequence, pseudoId); + auto const sharesKeylet = keylet::mptokenIssuance(sharesMptId); + auto sleShares = std::make_shared(sharesKeylet); + auto const sharesPage = ac.view().dirInsert( + keylet::ownerDir(pseudoId), sharesKeylet, describeOwnerDir(pseudoId)); + sleShares->setFieldU64(sfOwnerNode, *sharesPage); + + sleShares->at(sfFlags) = 0; + sleShares->at(sfIssuer) = pseudoId; + sleShares->at(sfOutstandingAmount) = 0; + sleShares->at(sfSequence) = sequence; + + // sleVault->at(sfAccount) = pseudoId; + // Setting wrong pseudo account ID + sleVault->at(sfAccount) = a2.id(); + sleVault->at(sfFlags) = 0; + sleVault->at(sfSequence) = sequence; + sleVault->at(sfOwner) = a1.id(); + sleVault->at(sfAssetsTotal) = Number(0); + sleVault->at(sfAssetsAvailable) = Number(0); + sleVault->at(sfLossUnrealized) = Number(0); + sleVault->at(sfShareMPTID) = sharesMptId; + sleVault->at(sfWithdrawalPolicy) = kVaultStrategyFirstComeFirstServe; + + ac.view().insert(sleVault); + ac.view().insert(sleShares); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_CREATE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + + doInvariantCheck( + {"shares issuer and vault pseudo-account must be the same", "shares issuer must exist"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const sequence = ac.view().seq(); + auto const vaultKeylet = keylet::vault(a1.id(), SeqProxy::rawSequence(sequence)); + auto sleVault = std::make_shared(vaultKeylet); + auto const vaultPage = ac.view().dirInsert( + keylet::ownerDir(a1.id()), sleVault->key(), describeOwnerDir(a1.id())); + sleVault->setFieldU64(sfOwnerNode, *vaultPage); + + auto const sharesMptId = makeMptID(sequence, a2.id()); + auto const sharesKeylet = keylet::mptokenIssuance(sharesMptId); + auto sleShares = std::make_shared(sharesKeylet); + auto const sharesPage = ac.view().dirInsert( + keylet::ownerDir(a2.id()), sharesKeylet, describeOwnerDir(a2.id())); + sleShares->setFieldU64(sfOwnerNode, *sharesPage); + + sleShares->at(sfFlags) = 0; + // Setting wrong pseudo account ID + sleShares->at(sfIssuer) = AccountID(42); + sleShares->at(sfOutstandingAmount) = 0; + sleShares->at(sfSequence) = sequence; + + sleVault->at(sfAccount) = a2.id(); + sleVault->at(sfFlags) = 0; + sleVault->at(sfSequence) = sequence; + sleVault->at(sfOwner) = a1.id(); + sleVault->at(sfAssetsTotal) = Number(0); + sleVault->at(sfAssetsAvailable) = Number(0); + sleVault->at(sfLossUnrealized) = Number(0); + sleVault->at(sfShareMPTID) = sharesMptId; + sleVault->at(sfWithdrawalPolicy) = kVaultStrategyFirstComeFirstServe; + + ac.view().insert(sleVault); + ac.view().insert(sleShares); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_CREATE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + + testcase << "Vault deposit"; + doInvariantCheck( + {"deposit must change vault balance"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [](Adjustments& sample) { + sample.vaultAssets.reset(); + })); + }, + XRPAmount{}, + STTx{ttVAULT_DEPOSIT, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp); + + doInvariantCheck( + {"deposit assets outstanding must not exceed assets maximum"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + return kAdjust(ac.view(), keylet, kArgs(a2.id(), 200, [&](Adjustments& sample) { + sample.assetsMaximum = 1; + })); + }, + XRPAmount{}, + STTx{ + ttVAULT_DEPOSIT, [](STObject& tx) { tx.setFieldAmount(sfAmount, XRPAmount(200)); }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + // This really convoluted unit tests makes the zero balance on the + // depositor, by sending them the same amount as the transaction fee. + // The operation makes no sense, but the defensive check in + // ValidVault::finalize is otherwise impossible to trigger. + doInvariantCheck( + {"deposit must increase vault balance", "deposit must change depositor balance"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + + // Move 10 drops to A4 to enforce total XRP balance + auto sleA4 = ac.view().peek(keylet::account(a4.id())); + if (!sleA4) + return false; + (*sleA4)[sfBalance] = *(*sleA4)[sfBalance] + 10; + ac.view().update(sleA4); + + return kAdjust(ac.view(), keylet, kArgs(a3.id(), -10, [&](Adjustments& sample) { + sample.accountAssets->amount = -100; + })); + }, + XRPAmount{100}, + STTx{ + ttVAULT_DEPOSIT, + [&](STObject& tx) { + tx[sfFee] = XRPAmount(100); + tx[sfAccount] = a3.id(); + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp); + + doInvariantCheck( + {"deposit must increase vault balance", + "deposit must decrease depositor balance", + "deposit must change vault and depositor balance by equal amount", + "deposit and assets outstanding must add up", + "deposit and assets available must add up"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + + // Move 10 drops from A2 to A3 to enforce total XRP balance + auto sleA3 = ac.view().peek(keylet::account(a3.id())); + if (!sleA3) + return false; + (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] + 10; + ac.view().update(sleA3); + + return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) { + sample.vaultAssets = -20; + sample.accountAssets->amount = 10; + })); + }, + XRPAmount{}, + STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + doInvariantCheck( + {"deposit must change depositor balance"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + + // Move 10 drops from A3 to vault to enforce total XRP balance + auto sleA3 = ac.view().peek(keylet::account(a3.id())); + if (!sleA3) + return false; + (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] - 10; + ac.view().update(sleA3); + + return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) { + sample.accountAssets->amount = 0; + })); + }, + XRPAmount{}, + STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + doInvariantCheck( + {"deposit must change depositor shares"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) { + sample.accountShares.reset(); + })); + }, + XRPAmount{}, + STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + doInvariantCheck( + {"deposit must change vault shares"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + + return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [](Adjustments& sample) { + sample.sharesTotal = 0; + })); + }, + XRPAmount{}, + STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + doInvariantCheck( + {"deposit must increase depositor shares", + "deposit must change depositor and vault shares by equal amount", + "deposit must not change vault balance by more than deposited " + "amount"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) { + sample.accountShares->amount = -5; + sample.sharesTotal = -10; + })); + }, + XRPAmount{}, + STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(5); }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + doInvariantCheck( + {"deposit and assets outstanding must add up"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto sleA3 = ac.view().peek(keylet::account(a3.id())); + (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] - 2000; + ac.view().update(sleA3); + + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) { + sample.assetsTotal = 11; + })); + }, + XRPAmount{2000}, + STTx{ + ttVAULT_DEPOSIT, + [&](STObject& tx) { + tx[sfAmount] = XRPAmount(10); + tx[sfDelegate] = a3.id(); + tx[sfFee] = XRPAmount(2000); + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + doInvariantCheck( + {"deposit and assets outstanding must add up", + "deposit and assets available must add up"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + return kAdjust(ac.view(), keylet, kArgs(a2.id(), 10, [&](Adjustments& sample) { + sample.assetsTotal = 7; + sample.assetsAvailable = 7; + })); + }, + XRPAmount{}, + STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + testcase << "Vault withdrawal"; + doInvariantCheck( + {"withdrawal must change vault balance"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [](Adjustments& sample) { + sample.vaultAssets.reset(); + })); + }, + XRPAmount{}, + STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp); + + // Almost identical to the really convoluted test for deposit, where the + // depositor spends only the transaction fee. In case of withdrawal, + // this test is almost the same as normal withdrawal where the + // sfDestination would have been A4, but has been omitted. + doInvariantCheck( + {"withdrawal must change one destination balance"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + + // Move 10 drops to A4 to enforce total XRP balance + auto sleA4 = ac.view().peek(keylet::account(a4.id())); + if (!sleA4) + return false; + (*sleA4)[sfBalance] = *(*sleA4)[sfBalance] + 10; + ac.view().update(sleA4); + + return kAdjust(ac.view(), keylet, kArgs(a3.id(), -10, [&](Adjustments& sample) { + sample.accountAssets->amount = -100; + })); + }, + XRPAmount{100}, + STTx{ + ttVAULT_WITHDRAW, + [&](STObject& tx) { + tx[sfFee] = XRPAmount(100); + tx[sfAccount] = a3.id(); + // This commented out line causes the invariant violation. + // tx[sfDestination] = A4.id(); + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp); + + doInvariantCheck( + { + "withdrawal must change vault and destination balance by equal amount", + "withdrawal must decrease vault balance", + "withdrawal must increase destination balance", + "withdrawal and assets outstanding must add up", + "withdrawal and assets available must add up", + }, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + + // Move 10 drops from A2 to A3 to enforce total XRP balance + auto sleA3 = ac.view().peek(keylet::account(a3.id())); + if (!sleA3) + return false; + (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] + 10; + ac.view().update(sleA3); + + return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) { + sample.vaultAssets = 10; + sample.accountAssets->amount = -20; + })); + }, + XRPAmount{}, + STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + doInvariantCheck( + {"withdrawal must change one destination balance"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + if (!kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) { + *sample.vaultAssets -= 5; + }))) + return false; + auto sleA3 = ac.view().peek(keylet::account(a3.id())); + if (!sleA3) + return false; + (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] + 5; + ac.view().update(sleA3); + return true; + }, + XRPAmount{}, + STTx{ttVAULT_WITHDRAW, [&](STObject& tx) { tx.setAccountID(sfDestination, a3.id()); }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + doInvariantCheck( + {"withdrawal must change depositor shares"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) { + sample.accountShares.reset(); + })); + }, + XRPAmount{}, + STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + doInvariantCheck( + {"withdrawal must change vault shares"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [](Adjustments& sample) { + sample.sharesTotal = 0; + })); + }, + XRPAmount{}, + STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + doInvariantCheck( + {"withdrawal must decrease depositor shares", + "withdrawal must change depositor and vault shares by equal " + "amount"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) { + sample.accountShares->amount = 5; + sample.sharesTotal = 10; + })); + }, + XRPAmount{}, + STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + doInvariantCheck( + {"withdrawal and assets outstanding must add up", + "withdrawal and assets available must add up"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) { + sample.assetsTotal = -15; + sample.assetsAvailable = -15; + })); + }, + XRPAmount{}, + STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + doInvariantCheck( + {"withdrawal and assets outstanding must add up"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto sleA3 = ac.view().peek(keylet::account(a3.id())); + (*sleA3)[sfBalance] = *(*sleA3)[sfBalance] - 2000; + ac.view().update(sleA3); + + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) { + sample.assetsTotal = -7; + })); + }, + XRPAmount{2000}, + STTx{ + ttVAULT_WITHDRAW, + [&](STObject& tx) { + tx[sfAmount] = XRPAmount(10); + tx[sfDelegate] = a3.id(); + tx[sfFee] = XRPAmount(2000); + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseXrp, + TxAccount::A2); + + auto const precloseMpt = [&](Account const& a1, Account const& a2, Env& env) -> bool { + env.fund(XRP(1000), a3, a4); + + // Create MPT asset + { + json::Value jv; + jv[sfAccount] = a3.human(); + jv[sfTransactionType] = jss::MPTokenIssuanceCreate; + jv[sfFlags] = tfMPTCanTransfer; + env(jv); + env.close(); + } + + auto const mptID = makeMptID(env.seq(a3) - 1, a3); + Asset const asset = MPTIssue(mptID); + // Authorize A1 A2 A4 + { + json::Value jv; + jv[sfAccount] = a1.human(); + jv[sfTransactionType] = jss::MPTokenAuthorize; + jv[sfMPTokenIssuanceID] = to_string(mptID); + env(jv); + jv[sfAccount] = a2.human(); + env(jv); + jv[sfAccount] = a4.human(); + env(jv); + + env.close(); + } + // Send tokens to A1 A2 A4 + { + env(pay(a3, a1, asset(1000))); + env(pay(a3, a2, asset(1000))); + env(pay(a3, a4, asset(1000))); + env.close(); + } + + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = a1, .asset = asset}); + env(tx); + env(vault.deposit({.depositor = a1, .id = keylet.key, .amount = asset(10)})); + env(vault.deposit({.depositor = a2, .id = keylet.key, .amount = asset(10)})); + env(vault.deposit({.depositor = a4, .id = keylet.key, .amount = asset(10)})); + return true; + }; + + doInvariantCheck( + {"withdrawal must decrease depositor shares", + "withdrawal must change depositor and vault shares by equal " + "amount"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = + keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2)); + return kAdjust(ac.view(), keylet, kArgs(a2.id(), -10, [&](Adjustments& sample) { + sample.accountShares->amount = 5; + })); + }, + XRPAmount{}, + STTx{ttVAULT_WITHDRAW, [&](STObject& tx) { tx[sfAccount] = a3.id(); }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseMpt, + TxAccount::A2); + + testcase << "Vault clawback"; + doInvariantCheck( + {"clawback must change vault balance"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = + keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2)); + return kAdjust(ac.view(), keylet, kArgs(a2.id(), -1, [&](Adjustments& sample) { + sample.vaultAssets.reset(); + })); + }, + XRPAmount{}, + STTx{ttVAULT_CLAWBACK, [&](STObject& tx) { tx[sfAccount] = a3.id(); }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseMpt); + + // Not the same as below check: attempt to clawback XRP + doInvariantCheck( + {"clawback may only be performed by the asset issuer"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq())); + return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) {})); + }, + XRPAmount{}, + STTx{ttVAULT_CLAWBACK, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseXrp); + + // Not the same as above check: attempt to clawback MPT by bad account + doInvariantCheck( + {"clawback may only be performed by the asset issuer"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = + keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2)); + return kAdjust(ac.view(), keylet, kArgs(a2.id(), 0, [&](Adjustments& sample) {})); + }, + XRPAmount{}, + STTx{ttVAULT_CLAWBACK, [&](STObject& tx) { tx[sfAccount] = a4.id(); }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseMpt); + + doInvariantCheck( + {"clawback must decrease vault balance", + "clawback must decrease holder shares", + "clawback must change vault shares"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = + keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2)); + return kAdjust(ac.view(), keylet, kArgs(a4.id(), 10, [&](Adjustments& sample) { + sample.sharesTotal = 0; + })); + }, + XRPAmount{}, + STTx{ + ttVAULT_CLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = a3.id(); + tx[sfHolder] = a4.id(); + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseMpt); + + doInvariantCheck( + {"clawback must change holder shares"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = + keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2)); + return kAdjust(ac.view(), keylet, kArgs(a4.id(), -10, [&](Adjustments& sample) { + sample.accountShares.reset(); + })); + }, + XRPAmount{}, + STTx{ + ttVAULT_CLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = a3.id(); + tx[sfHolder] = a4.id(); + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseMpt); + + doInvariantCheck( + {"clawback must change holder and vault shares by equal amount", + "clawback and assets outstanding must add up", + "clawback and assets available must add up"}, + [&](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const keylet = + keylet::vault(a1.id(), SeqProxy::rawSequence(ac.view().seq() - 2)); + return kAdjust(ac.view(), keylet, kArgs(a4.id(), -10, [&](Adjustments& sample) { + sample.accountShares->amount = -8; + sample.assetsTotal = -7; + sample.assetsAvailable = -7; + })); + }, + XRPAmount{}, + STTx{ + ttVAULT_CLAWBACK, + [&](STObject& tx) { + tx[sfAccount] = a3.id(); + tx[sfHolder] = a4.id(); + }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseMpt); + + // ───────────────────────────────────────────────────────────── + // Closed-ended vault invariants added in ValidVault::finalize (create must supply both + // dates and satisfy the redemption-buffer gap), deposit only in Subscription / NoPhase, + // withdraw not in Investment, loan origination only in Investment. + + using d = NetClock::duration; + using tp = NetClock::time_point; + + auto const closedEnded = std::to_underlying(VaultKind::ClosedEnded); + + // Vault keylet captured by precloseClosedEnded so precheck does not have to rederive it + // from ac.view().seq(), which depends on how many env.close() calls preclose issued. + Keylet closedEndedKeylet = keylet::amendments(); + + // Preclose that creates a closed-ended vault (in Subscription), optionally seeds it with + // three deposits (so a1/a2/a3 hold a share MPToken that kAdjust can then adjust), and + // optionally advances parent close time past SubscriptionDate. A negative @p advanceBySub + // leaves the vault in Subscription. + auto const precloseClosedEnded = [&](std::int32_t advanceBySub, bool doDeposit) { + return [&, advanceBySub, doDeposit]( + Account const& a1, Account const& a2, Env& env) -> bool { + env.fund(XRP(1000), a3, a4); + auto const sub = env.now().time_since_epoch().count() + 60; + auto const red = sub + kMinInvestmentPeriod + 1'000'000; + Vault const vault{env}; + auto [tx, keylet] = vault.create( + {.owner = a1, + .asset = xrpIssue(), + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = red}); + env(tx); + closedEndedKeylet = keylet; + if (doDeposit) + { + env(vault.deposit({.depositor = a1, .id = keylet.key, .amount = XRP(10)})); + env(vault.deposit({.depositor = a2, .id = keylet.key, .amount = XRP(10)})); + env(vault.deposit({.depositor = a3, .id = keylet.key, .amount = XRP(10)})); + } + if (advanceBySub >= 0) + env.close(tp{d{sub + advanceBySub}}); + return true; + }; + }; + + // Manually insert a bare closed-ended vault (+ pseudo-account + share MPTokenIssuance) + // directly into the view, bypassing the transactor path. Used to synthesize ttVAULT_CREATE + // states no legitimate transactor would produce. + auto const insertBareClosedEndedVault = + [closedEnded]( + ApplyContext& ac, + Account const& owner, + std::optional subscriptionDate, + std::optional redemptionDate) -> bool { + auto const sequence = ac.view().seq(); + auto const vaultKeylet = keylet::vault(owner.id(), SeqProxy::rawSequence(sequence)); + auto sleVault = std::make_shared(vaultKeylet); + auto const vaultPage = ac.view().dirInsert( + keylet::ownerDir(owner.id()), sleVault->key(), describeOwnerDir(owner.id())); + if (!vaultPage) + return false; + sleVault->setFieldU64(sfOwnerNode, *vaultPage); + + auto const pseudoId = pseudoAccountAddress(ac.view(), vaultKeylet.key); + auto sleAccount = std::make_shared(keylet::account(pseudoId)); + sleAccount->setAccountID(sfAccount, pseudoId); + sleAccount->setFieldAmount(sfBalance, STAmount{}); + sleAccount->setFieldU32(sfSequence, 0); + sleAccount->setFieldU32(sfFlags, lsfDisableMaster | lsfDefaultRipple | lsfDepositAuth); + sleAccount->setFieldH256(sfVaultID, vaultKeylet.key); + ac.view().insert(sleAccount); + + auto const sharesMptId = makeMptID(sequence, pseudoId); + auto const sharesKeylet = keylet::mptokenIssuance(sharesMptId); + auto sleShares = std::make_shared(sharesKeylet); + auto const sharesPage = ac.view().dirInsert( + keylet::ownerDir(pseudoId), sharesKeylet, describeOwnerDir(pseudoId)); + if (!sharesPage) + return false; + sleShares->setFieldU64(sfOwnerNode, *sharesPage); + sleShares->at(sfFlags) = 0; + sleShares->at(sfIssuer) = pseudoId; + sleShares->at(sfOutstandingAmount) = 0; + sleShares->at(sfSequence) = sequence; + + sleVault->at(sfAccount) = pseudoId; + sleVault->at(sfFlags) = 0; + sleVault->at(sfSequence) = sequence; + sleVault->at(sfOwner) = owner.id(); + sleVault->setFieldIssue(sfAsset, STIssue{sfAsset, Asset{xrpIssue()}}); + sleVault->at(sfAssetsTotal) = Number(0); + sleVault->at(sfAssetsAvailable) = Number(0); + sleVault->at(sfLossUnrealized) = Number(0); + sleVault->at(sfShareMPTID) = sharesMptId; + sleVault->at(sfWithdrawalPolicy) = kVaultStrategyFirstComeFirstServe; + sleVault->at(sfVaultKind) = closedEnded; + if (subscriptionDate) + sleVault->at(sfSubscriptionDate) = *subscriptionDate; + if (redemptionDate) + sleVault->at(sfRedemptionDate) = *redemptionDate; + + ac.view().insert(sleVault); + ac.view().insert(sleShares); + return true; + }; + + testcase << "Vault create closed-ended"; + + // A fresh closed-ended vault must carry both SubscriptionDate and RedemptionDate. + doInvariantCheck( + {"closed-ended vault must have SubscriptionDate and RedemptionDate"}, + [&](Account const& a1, Account const&, ApplyContext& ac) { + return insertBareClosedEndedVault(ac, a1, std::nullopt, std::nullopt); + }, + XRPAmount{}, + STTx{ttVAULT_CREATE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + + // Gap smaller than MIN_INVESTMENT_PERIOD but with RedemptionDate > SubscriptionDate; + // exercises the sub-minimum branch of the gap check. + doInvariantCheck( + {"closed-ended vault RedemptionDate - SubscriptionDate must be " + "within [MIN_INVESTMENT_PERIOD, MAX_INVESTMENT_PERIOD)"}, + [&](Account const& a1, Account const&, ApplyContext& ac) { + std::uint32_t const sub = 1'000'000'000; + std::uint32_t const red = sub + kMinInvestmentPeriod - 1; + return insertBareClosedEndedVault(ac, a1, sub, red); + }, + XRPAmount{}, + STTx{ttVAULT_CREATE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + + // RedemptionDate strictly before SubscriptionDate; the signed int64 gap is negative and + // is caught by the sub-minimum branch of the gap check. + doInvariantCheck( + {"closed-ended vault RedemptionDate - SubscriptionDate must be " + "within [MIN_INVESTMENT_PERIOD, MAX_INVESTMENT_PERIOD)"}, + [&](Account const& a1, Account const&, ApplyContext& ac) { + std::uint32_t const sub = 1'000'000'000; + std::uint32_t const red = sub - 1; + return insertBareClosedEndedVault(ac, a1, sub, red); + }, + XRPAmount{}, + STTx{ttVAULT_CREATE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + + // Gap exactly MAX_INVESTMENT_PERIOD is out of range (bound is half-open on the right). + doInvariantCheck( + {"closed-ended vault RedemptionDate - SubscriptionDate must be " + "within [MIN_INVESTMENT_PERIOD, MAX_INVESTMENT_PERIOD)"}, + [&](Account const& a1, Account const&, ApplyContext& ac) { + std::uint32_t const sub = 1'000'000'000; + std::uint32_t const red = sub + kMaxInvestmentPeriod; + return insertBareClosedEndedVault(ac, a1, sub, red); + }, + XRPAmount{}, + STTx{ttVAULT_CREATE, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}); + + testcase << "Vault deposit closed-ended"; + + // A deposit into a closed-ended vault that has advanced past SubscriptionDate. kArgs + // simulates an otherwise valid deposit shape so only the phase invariant fires. + doInvariantCheck( + {"deposit only allowed in Subscription or NoPhase"}, + [&](Account const&, Account const& a2, ApplyContext& ac) { + return kAdjust( + ac.view(), closedEndedKeylet, kArgs(a2.id(), 10, [](Adjustments&) {})); + }, + XRPAmount{}, + STTx{ttVAULT_DEPOSIT, [](STObject& tx) { tx[sfAmount] = XRPAmount(10); }}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseClosedEnded(/*advanceBySub=*/1, /*doDeposit=*/true), + TxAccount::A2); + + testcase << "Vault withdrawal closed-ended"; + + // A withdrawal from a closed-ended vault in the Investment phase. + doInvariantCheck( + {"withdrawal not allowed during Investment phase"}, + [&](Account const&, Account const& a2, ApplyContext& ac) { + return kAdjust( + ac.view(), closedEndedKeylet, kArgs(a2.id(), -10, [](Adjustments&) {})); + }, + XRPAmount{}, + STTx{ttVAULT_WITHDRAW, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + precloseClosedEnded(/*advanceBySub=*/1, /*doDeposit=*/true), + TxAccount::A2); + + testcase << "Vault loan set"; + + // ttLOAN_SET against a closed-ended vault that is not in Investment. finalizeLoanSet fires + // on any vault mutation; touching the vault SLE with no field change is sufficient. + doInvariantCheck( + {"loan origination only allowed in Investment phase"}, + [&](Account const&, Account const&, ApplyContext& ac) { + auto sleVault = ac.view().peek(closedEndedKeylet); + if (!sleVault) + return false; + ac.view().update(sleVault); + return true; + }, + XRPAmount{}, + STTx{ttLOAN_SET, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + precloseClosedEnded(/*advanceBySub=*/-1, /*doDeposit=*/false)); + + testcase << "Vault loan set - closed-ended final payment past " + "RedemptionDate"; + + // A newly-created loan against a closed-ended vault must satisfy StartDate + + // PaymentInterval * PaymentRemaining < RedemptionDate. LoanSet::preclaim enforces the same + // bound; this test synthesises an invalid loan directly in the ApplyView so the invariant + // catches it even when preclaim is bypassed. + Keylet closedEndedBrokerKeylet = keylet::amendments(); + std::uint32_t closedEndedRed = 0; + doInvariantCheck( + {"closed-ended loan final payment must precede RedemptionDate"}, + [&](Account const& a1, Account const&, ApplyContext& ac) { + // Touch the vault so ValidVault::finalizeLoanSet sees an + // entry in afterVault_; the vault is in Investment, so + // finalizeLoanSet itself passes. + auto sleVault = ac.view().peek(closedEndedKeylet); + if (!sleVault) + return false; + ac.view().update(sleVault); + + // Read the broker's next loan sequence to build the loan + // keylet the same way LoanSet::doApply would. + auto sleBroker = ac.view().peek(closedEndedBrokerKeylet); + if (!sleBroker) + return false; + std::uint32_t const loanSeq = sleBroker->at(sfLoanSequence); + + // Synthesize a Loan whose final scheduled payment lands + // exactly at RedemptionDate: StartDate = red, interval = 60, + // remaining = 1 => red + 60 >= red. + auto sleLoan = std::make_shared( + keylet::loan(closedEndedBrokerKeylet.key, SeqProxy::rawSequence(loanSeq))); + sleLoan->at(sfLoanBrokerID) = closedEndedBrokerKeylet.key; + sleLoan->at(sfLoanSequence) = loanSeq; + sleLoan->at(sfBorrower) = a1.id(); + sleLoan->at(sfStartDate) = closedEndedRed; + sleLoan->at(sfPaymentInterval) = 60; + sleLoan->at(sfPaymentRemaining) = 1; + sleLoan->at(sfTotalValueOutstanding) = Number(100); + sleLoan->at(sfPeriodicPayment) = Number(1); + ac.view().insert(sleLoan); + return true; + }, + XRPAmount{}, + STTx{ttLOAN_SET, [](STObject&) {}}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + [&](Account const& a1, Account const&, Env& env) -> bool { + auto const sub = env.now().time_since_epoch().count() + 60; + auto const red = sub + kMinInvestmentPeriod + 1'000'000; + closedEndedRed = red; + + Vault const vault{env}; + auto [tx, keylet] = vault.create( + {.owner = a1, + .asset = xrpIssue(), + .vaultKind = closedEnded, + .subscriptionDate = sub, + .redemptionDate = red}); + env(tx); + closedEndedKeylet = keylet; + + // Create the loan broker; LoanBrokerSet has no phase gate. + closedEndedBrokerKeylet = + keylet::loanBroker(a1.id(), SeqProxy::rawSequence(env.seq(a1))); + env(loan_broker::set(a1, keylet.key)); + + // Advance parent close time into Investment so + // ValidVault::finalizeLoanSet is satisfied. + env.close(tp{d{sub + 1}}); + return true; + }); + } + + void + testVaultComputeCoarsestScale() + { + using namespace jtx; + + Account const issuer{"issuer"}; + PrettyAsset const vaultAsset = issuer["IOU"]; + + struct TestCase + { + std::string name; + std::int32_t expectedMinScale; + std::vector values; + }; + + for (auto const mantissaScale : MantissaRange::getAllScales()) + { + if (mantissaScale == MantissaRange::MantissaScale::Small) + continue; + NumberMantissaScaleGuard const g{mantissaScale}; + + auto makeDelta = [&vaultAsset](Number const& n) -> ValidVault::DeltaInfo { + return {.delta = n, .scale = scale(n, vaultAsset.raw())}; + }; + + auto const testCases = std::vector{ + { + .name = "No values", + .expectedMinScale = 0, + .values = {}, + }, + { + .name = "Mixed integer and Number values", + .expectedMinScale = -15, + .values = {makeDelta(1), makeDelta(-1), makeDelta(Number{10, -1})}, + }, + { + .name = "Mixed scales", + .expectedMinScale = -17, + .values = + {makeDelta(Number{1, -2}), + makeDelta(Number{5, -3}), + makeDelta(Number{3, -2})}, + }, + { + .name = "Equal scales", + .expectedMinScale = -16, + .values = + {makeDelta(Number{1, -1}), + makeDelta(Number{5, -1}), + makeDelta(Number{1, -1})}, + }, + { + .name = "Mixed mantissa sizes", + .expectedMinScale = -12, + .values = + {makeDelta(Number{1}), + makeDelta(Number{1234, -3}), + makeDelta(Number{12345, -6}), + makeDelta(Number{123, 1})}, + }, + }; + + for (auto const& tc : testCases) + { + testcase("vault computeCoarsestScale: " + tc.name); + + auto const actualScale = ValidVault::computeCoarsestScale(tc.values); + + BEAST_EXPECTS( + actualScale == tc.expectedMinScale, + "expected: " + std::to_string(tc.expectedMinScale) + + ", actual: " + std::to_string(actualScale)); + for (auto const& num : tc.values) + { + // None of these scales are far enough apart that rounding the + // values would lose information, so check that the rounded + // value matches the original. + auto const actualRounded = roundToAsset(vaultAsset, num.delta, actualScale); + BEAST_EXPECTS( + actualRounded == num.delta, + "number " + to_string(num.delta) + " rounded to scale " + + std::to_string(actualScale) + " is " + to_string(actualRounded)); + } + } + + auto const testCases2 = std::vector{ + { + .name = "False equivalence", + .expectedMinScale = -15, + .values = + { + makeDelta(Number{1234567890123456789, -18}), + makeDelta(Number{12345, -4}), + makeDelta(Number{1}), + }, + }, + }; + + // Unlike the first set of test cases, the values in these test could + // look equivalent if using the wrong scale. + for (auto const& tc : testCases2) + { + testcase("vault computeCoarsestScale: " + tc.name); + + auto const actualScale = ValidVault::computeCoarsestScale(tc.values); + + BEAST_EXPECTS( + actualScale == tc.expectedMinScale, + "expected: " + std::to_string(tc.expectedMinScale) + + ", actual: " + std::to_string(actualScale)); + std::optional first; + Number firstRounded; + for (auto const& num : tc.values) + { + if (!first) + { + first = num.delta; + firstRounded = roundToAsset(vaultAsset, num.delta, actualScale); + continue; + } + auto const numRounded = roundToAsset(vaultAsset, num.delta, actualScale); + BEAST_EXPECTS( + numRounded != firstRounded, + "at a scale of " + std::to_string(actualScale) + " " + + to_string(num.delta) + " == " + to_string(*first)); + } + } + } + } + + void + run() override + { + testVault(); + testVaultComputeCoarsestScale(); + } +}; + +BEAST_DEFINE_TESTSUITE(InvariantsVault, app, xrpl); + +} // namespace xrpl::test From 9d41b1bd1c53a3146ad44ba1b03da0a265693782 Mon Sep 17 00:00:00 2001 From: Timur Yalymov <36795566+tyalymov@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:23:43 +0000 Subject: [PATCH 16/32] fix: Exempt vault and loan broker accounts from IOU authorization (#8013) Co-authored-by: Cursor --- .../ledger/helpers/RippleStateHelpers.cpp | 12 +- .../tx/transactors/lending/LoanPay.cpp | 14 +- src/test/app/AMMExtended_test.cpp | 74 +++++++ src/test/app/lending/LoanPay_test.cpp | 198 ++++++++++++++++++ 4 files changed, 292 insertions(+), 6 deletions(-) diff --git a/src/libxrpl/ledger/helpers/RippleStateHelpers.cpp b/src/libxrpl/ledger/helpers/RippleStateHelpers.cpp index 868c9fb26d..706564db6f 100644 --- a/src/libxrpl/ledger/helpers/RippleStateHelpers.cpp +++ b/src/libxrpl/ledger/helpers/RippleStateHelpers.cpp @@ -584,9 +584,15 @@ requireAuth(ReadView const& view, Issue const& issue, AccountID const& account, { if (trustLine) { - return trustLine->isFlag((account > issue.account) ? lsfLowAuth : lsfHighAuth) - ? tesSUCCESS - : TER{tecNO_AUTH}; + if (trustLine->isFlag((account > issue.account) ? lsfLowAuth : lsfHighAuth)) + return tesSUCCESS; + + // A pseudo-account cannot submit transactions and only stores assets for the object + // that owns it, so it is implicitly authorized. + if (view.rules().enabled(fixCleanup3_4_0) && isPseudoAccount(view, account)) + return tesSUCCESS; + + return TER{tecNO_AUTH}; } return TER{tecNO_LINE}; } diff --git a/src/libxrpl/tx/transactors/lending/LoanPay.cpp b/src/libxrpl/tx/transactors/lending/LoanPay.cpp index c5bfd8e9ee..6e3487ec8e 100644 --- a/src/libxrpl/tx/transactors/lending/LoanPay.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanPay.cpp @@ -620,7 +620,12 @@ LoanPay::doApply() ? STAmount{asset, 0} : conservationBalance(view, brokerPayee, asset, j_); - if (totalPaidToVaultRounded != beast::kZero) + // Only ledgers without the rule below reach these payee checks. Once it is in force + // requireAuth can no longer reject a pseudo-account, so the whole block goes away with the + // gate. + bool const skipPayeeAuth = view.rules().enabled(fixCleanup3_4_0); + + if (!skipPayeeAuth && totalPaidToVaultRounded != beast::kZero) { if (auto const ter = requireAuth(view, asset, vaultPseudoAccount, AuthType::StrongAuth)) return ter; @@ -644,8 +649,11 @@ LoanPay::doApply() return ter; } } - if (auto const ter = requireAuth(view, asset, brokerPayee, AuthType::StrongAuth)) - return ter; + if (!skipPayeeAuth) + { + if (auto const ter = requireAuth(view, asset, brokerPayee, AuthType::StrongAuth)) + return ter; + } } if (auto const ter = accountSendMulti( diff --git a/src/test/app/AMMExtended_test.cpp b/src/test/app/AMMExtended_test.cpp index 83c848b7c4..971a540ff7 100644 --- a/src/test/app/AMMExtended_test.cpp +++ b/src/test/app/AMMExtended_test.cpp @@ -1303,6 +1303,78 @@ private: BEAST_EXPECT(expectHolding(env, bob_, USD(0))); } + // Same shape as testRequireAuth, except the issuer never authorizes the AMM's own trust line. + // An AMM holds the asset for its liquidity providers and cannot sign a TrustSet for itself, so + // once pseudo-accounts are implicitly authorized the pool keeps trading. Before that the offer + // stream drops it and the taker's offer stays on the book. + void + testPseudoAccountRequireAuth(FeatureBitset features) + { + testcase("lsfRequireAuth, unauthorized AMM pseudo-account"); + + using namespace jtx; + + bool const pseudoExempt = features[fixCleanup3_4_0]; + + Env env{*this, features}; + + auto const aliceUSD = alice_["USD"]; + auto const bobUSD = bob_["USD"]; + + env.fund(XRP(400'000), gw_, alice_, bob_); + env.close(); + + env(fset(gw_, asfRequireAuth)); + env.close(); + + env(trust(gw_, bobUSD(100)), Txflags(tfSetfAuth)); + env(trust(bob_, USD(100))); + env(trust(gw_, aliceUSD(100)), Txflags(tfSetfAuth)); + env(trust(alice_, USD(2'000))); + env(pay(gw_, alice_, USD(1'000))); + env.close(); + + AMM const ammAlice(env, alice_, USD(1'000), XRP(1'050)); + + // The pool's own line stays unauthorized: AMMCreate opens it without the flag, and the + // pseudo-account has no key to ask for one. + auto const ammLineAuthorized = [&]() -> bool { + auto const line = + env.le(keylet::trustLine(ammAlice.ammAccount(), USD.issue().account, USD.currency)); + if (!BEAST_EXPECT(line)) + return false; + return line->isFlag( + ammAlice.ammAccount() > USD.issue().account ? lsfLowAuth : lsfHighAuth); + }; + BEAST_EXPECT(!ammLineAuthorized()); + + env(pay(gw_, bob_, USD(50))); + env.close(); + BEAST_EXPECT(expectHolding(env, bob_, USD(50))); + + // Bob sells USD into the pool, so the pool is the side that has to be authorized to hold + // the asset. + env(offer(bob_, XRP(50), USD(50))); + env.close(); + + if (pseudoExempt) + { + BEAST_EXPECT(ammAlice.expectBalances(USD(1'050), XRP(1'000), ammAlice.tokens())); + BEAST_EXPECT(expectOffers(env, bob_, 0)); + BEAST_EXPECT(expectHolding(env, bob_, USD(0))); + } + else + { + // The pool is skipped, so nothing crosses and the offer rests on the book. + BEAST_EXPECT(ammAlice.expectBalances(USD(1'000), XRP(1'050), ammAlice.tokens())); + BEAST_EXPECT(expectOffers(env, bob_, 1)); + BEAST_EXPECT(expectHolding(env, bob_, USD(50))); + } + + // Either way the exemption skips the check rather than setting the flag. + BEAST_EXPECT(!ammLineAuthorized()); + } + void testMissingAuth(FeatureBitset features) { @@ -1400,6 +1472,8 @@ private: testDirectToDirectPath(all_); testDirectToDirectPath(all_ - fixAMMv1_1 - fixAMMv1_3); testRequireAuth(all_); + testPseudoAccountRequireAuth(all_); + testPseudoAccountRequireAuth(all_ - fixCleanup3_4_0); testMissingAuth(all_); } diff --git a/src/test/app/lending/LoanPay_test.cpp b/src/test/app/lending/LoanPay_test.cpp index 93d1671feb..ce08e71932 100644 --- a/src/test/app/lending/LoanPay_test.cpp +++ b/src/test/app/lending/LoanPay_test.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -15,9 +16,11 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -730,6 +733,200 @@ private: } } + // Which pseudo-account is left holding an unauthorized trust line when the + // repayment lands. + enum class UnauthorizedPayee { + // The vault's own line, as VaultCreate leaves it. + Vault, + // Same vault, but the issuer authorized the line by hand first. + VaultAuthorized, + // Vault line authorized, broker owner unable to take the fee, so the + // fee goes to the loan broker's pseudo-account instead. + Broker, + }; + + // A vault holding an IOU whose issuer requires authorization ends up with + // its own trust line unauthorized: VaultCreate opens the line without the + // auth flag, and the pseudo-account has no key to sign a TrustSet for + // itself. Neither deposits nor loan origination look at that line, so the + // vault appears to work right up to the first repayment, which is the only + // step that has to credit the vault back. + // + // The loan broker's pseudo-account has the same defect for the same reason, + // and LoanPay reaches it whenever the broker owner cannot take the fee. + // + // The issuer can still repair either line by hand, because TrustSet accepts + // a line that already exists even when its owner is a pseudo-account. + void + testRepayIntoUnauthorizedVault() + { + using namespace jtx; + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + auto runTestCases = [&](FeatureBitset features, UnauthorizedPayee payee) { + bool const pseudoExempt = features[fixCleanup3_4_0]; + // With the vault's line repaired by the issuer, the only remaining + // unauthorized payee is the broker's pseudo-account. + bool const expectSuccess = pseudoExempt || payee == UnauthorizedPayee::VaultAuthorized; + + auto const payeeLabel = [payee]() -> char const* { + switch (payee) + { + case UnauthorizedPayee::Vault: + return "vault"; + case UnauthorizedPayee::VaultAuthorized: + return "vault authorized by the issuer"; + case UnauthorizedPayee::Broker: + return "loan broker"; + } + return ""; // LCOV_EXCL_LINE + }(); + + testcase << "LoanPay crediting an unauthorized " << payeeLabel << ": pseudo-account " + << (pseudoExempt ? "exempt" : "not exempt"); + + Env env{*this, features}; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + + env(fset(issuer, asfRequireAuth)); + env.close(); + + PrettyAsset const asset = issuer[iouCurrency_]; + env(trust(lender, asset(100'000'000))); + env(trust(borrower, asset(100'000'000))); + env.close(); + + // Authorize the two participants. Nothing asks the issuer to also + // authorize the vault, which is the whole point of this test. + env(trust(issuer, asset(0), lender, tfSetfAuth)); + env(trust(issuer, asset(0), borrower, tfSetfAuth)); + env.close(); + + env(pay(issuer, lender, asset(10'000'000))); + env(pay(issuer, borrower, asset(10'000))); + env.close(); + + // Creating the vault and funding it with deposits succeeds even + // though the vault cannot be authorized to hold the asset. + BrokerInfo const broker{createVaultAndBroker(env, asset, lender)}; + + auto const vaultSle = env.le(broker.vaultKeylet()); + auto const brokerSle = env.le(broker.brokerKeylet()); + if (!BEAST_EXPECT(vaultSle && brokerSle)) + return; + + Account const vaultPseudo{"vault pseudo-account", vaultSle->at(sfAccount)}; + Account const brokerPseudo{"broker pseudo-account", brokerSle->at(sfAccount)}; + + auto const lineIsAuthorized = [&](Account const& holder) -> bool { + auto const line = env.le(keylet::trustLine(holder, asset.raw().get())); + if (!BEAST_EXPECT(line)) + return false; + return line->isFlag(holder.id() > issuer.id() ? lsfLowAuth : lsfHighAuth); + }; + + BEAST_EXPECT(!lineIsAuthorized(vaultPseudo)); + BEAST_EXPECT(!lineIsAuthorized(brokerPseudo)); + + if (payee != UnauthorizedPayee::Vault) + { + env(trust(issuer, asset(0), vaultPseudo, tfSetfAuth)); + env.close(); + BEAST_EXPECT(lineIsAuthorized(vaultPseudo)); + } + + using namespace loan; + + // The service fee guarantees the broker is owed something on the + // first payment, so the broker leg of the transfer is exercised. + Number const serviceFee = asset(2).value(); + auto const loanKeylet = nextLoanKeylet(env, broker); + env(set(borrower, broker.brokerID, asset(1'000).value()), + Sig(sfCounterpartySignature, lender), + kLoanServiceFee(serviceFee), + kInterestRate(percentageToTenthBips(12)), + kPaymentTotal(12), + kPaymentInterval(600), + Fee(env.current()->fees().base * 2)); + env.close(); + + // Paying the principal out of the vault never needed authorization. + BEAST_EXPECT(env.le(loanKeylet)); + + if (payee == UnauthorizedPayee::Broker) + { + // A deep-frozen owner cannot take the fee, so LoanPay pays it + // into the broker's pseudo-account instead. + env(trust(issuer, asset(0), lender, tfSetFreeze | tfSetDeepFreeze)); + env.close(); + } + + auto const state = getCurrentState(env, broker, loanKeylet); + STAmount const payment{ + broker.asset, + roundPeriodicPayment( + broker.asset, state.periodicPayment + serviceFee, state.loanScale)}; + + // Repayment turns an outstanding loan back into cash the vault can + // lend again, so AssetsAvailable is what moves. AssetsTotal already + // counted the loan. + auto const assetsAvailable = [&]() -> Number { + auto const sle = env.le(broker.vaultKeylet()); + if (!BEAST_EXPECT(sle)) + return Number{}; + return sle->at(sfAssetsAvailable); + }; + + auto const borrowerBefore = env.balance(borrower, asset).number(); + auto const vaultBefore = env.balance(vaultPseudo, asset).number(); + auto const brokerBefore = env.balance(brokerPseudo, asset).number(); + auto const assetsAvailableBefore = assetsAvailable(); + + env(pay(borrower, loanKeylet.key, payment), + Ter(expectSuccess ? TER{tesSUCCESS} : TER{tecNO_AUTH})); + env.close(); + + if (expectSuccess) + { + BEAST_EXPECT(env.balance(borrower, asset).number() < borrowerBefore); + BEAST_EXPECT(env.balance(vaultPseudo, asset).number() > vaultBefore); + BEAST_EXPECT(assetsAvailable() > assetsAvailableBefore); + // Confirms the broker variant really did route the fee to the + // pseudo-account rather than to the owner. + BEAST_EXPECT( + (env.balance(brokerPseudo, asset).number() > brokerBefore) == + (payee == UnauthorizedPayee::Broker)); + + // The payee is skipped by the check, not authorized by it: the line that just + // took the credit is still missing its auth flag. + if (payee == UnauthorizedPayee::Vault) + BEAST_EXPECT(!lineIsAuthorized(vaultPseudo)); + if (payee == UnauthorizedPayee::Broker) + BEAST_EXPECT(!lineIsAuthorized(brokerPseudo)); + } + else + { + // A rejected repayment must leave every balance untouched. + BEAST_EXPECT(env.balance(borrower, asset).number() == borrowerBefore); + BEAST_EXPECT(env.balance(vaultPseudo, asset).number() == vaultBefore); + BEAST_EXPECT(env.balance(brokerPseudo, asset).number() == brokerBefore); + BEAST_EXPECT(assetsAvailable() == assetsAvailableBefore); + } + }; + + for (auto const& features : {all_, all_ - fixCleanup3_4_0}) + { + runTestCases(features, UnauthorizedPayee::Vault); + runTestCases(features, UnauthorizedPayee::VaultAuthorized); + runTestCases(features, UnauthorizedPayee::Broker); + } + } + void testLoanPayFundsConservedPayeeBelowReserve(FeatureBitset features) { @@ -838,6 +1035,7 @@ private: runAmendmentIndependent() { testLoanSetNearZeroInterestRateSucceeds(); + testRepayIntoUnauthorizedVault(); } // Tests run under each entry in amendmentCombinations(). From 0fdaf69e2c2e92b447368e5cfc2872429dc4d934 Mon Sep 17 00:00:00 2001 From: Bart Date: Mon, 24 Aug 2026 20:41:58 +0000 Subject: [PATCH 17/32] chore: Bump version to 3.4.0-b1 (#8102) --- src/libxrpl/protocol/BuildInfo.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libxrpl/protocol/BuildInfo.cpp b/src/libxrpl/protocol/BuildInfo.cpp index ff4e5aa0ee..7f10630581 100644 --- a/src/libxrpl/protocol/BuildInfo.cpp +++ b/src/libxrpl/protocol/BuildInfo.cpp @@ -23,7 +23,7 @@ namespace { //------------------------------------------------------------------------------ // clang-format off // NOLINTNEXTLINE(readability-identifier-naming) -char const* const versionString = "3.4.0-b0" +char const* const versionString = "3.4.0-b1" // clang-format on ; From 473fe44a85c89fd779d956ff5a1ae13eaceb7f5f Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov Date: Tue, 25 Aug 2026 13:48:26 +0000 Subject: [PATCH 18/32] chore: Upgrade rust toolchain to 1.97.1 (#8105) --- nix/check-tools/macos.txt | 20 ++++++++++---------- rust-toolchain.toml | 4 ++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/nix/check-tools/macos.txt b/nix/check-tools/macos.txt index 8e99aa28e4..8edfeef311 100644 --- a/nix/check-tools/macos.txt +++ b/nix/check-tools/macos.txt @@ -114,8 +114,8 @@ Development tooling: Rust toolchain: ✅ cargo - cargo 1.95.0 (f2d3ce0bd 2026-03-21) - /nix/store/92vz1f4kislnj58j1pr1788l688py6f0-rust-minimal-1.95.0/bin/cargo + cargo 1.97.1 (c980f4866 2026-06-30) + /nix/store/bnfk1sl4s9angb0vj1cj9a5y5zvqinwy-rust-minimal-1.97.1/bin/cargo ✅ cargo-audit cargo-audit-audit 0.22.1 /nix/store/snwkga2f5gyf404h7mmp9wriwxb8v65f-cargo-audit-0.22.1/bin/cargo-audit @@ -126,17 +126,17 @@ Rust toolchain: cargo-nextest 0.9.137 /nix/store/ylz7m947mhkgsp6i7611id3s3gcd58nq-cargo-nextest-0.9.137/bin/cargo-nextest ✅ clippy-driver - clippy 0.1.95 (59807616e1 2026-04-14) - /nix/store/92vz1f4kislnj58j1pr1788l688py6f0-rust-minimal-1.95.0/bin/clippy-driver + clippy 0.1.97 (8bab26f4f6 2026-07-14) + /nix/store/bnfk1sl4s9angb0vj1cj9a5y5zvqinwy-rust-minimal-1.97.1/bin/clippy-driver ✅ rust-analyzer - rust-analyzer 1.95.0 (59807616 2026-04-14) - /nix/store/jqvjap2727r9cjpr25fkw5glv2kbxrdx-rust-analyzer-preview-1.95.0-aarch64-apple-darwin/bin/rust-analyzer + rust-analyzer 1.97.1 (8bab26f4 2026-07-14) + /nix/store/j6apc5pmd0giy15da9p650r8zklslmvi-rust-analyzer-preview-1.97.1-aarch64-apple-darwin/bin/rust-analyzer ✅ rustc - rustc 1.95.0 (59807616e 2026-04-14) - /nix/store/92vz1f4kislnj58j1pr1788l688py6f0-rust-minimal-1.95.0/bin/rustc + rustc 1.97.1 (8bab26f4f 2026-07-14) + /nix/store/bnfk1sl4s9angb0vj1cj9a5y5zvqinwy-rust-minimal-1.97.1/bin/rustc ✅ rustfmt - rustfmt 1.9.0-stable (59807616e1 2026-04-14) - /nix/store/03x750yj6fakl7shbhicpnkxiwqxjrrs-rustfmt-preview-1.95.0-aarch64-apple-darwin/bin/rustfmt + rustfmt 1.9.0-stable (8bab26f4f6 2026-07-14) + /nix/store/5ymwgr9jqjz7zzbmj0j5vqbwcd3kp0vm-rustfmt-preview-1.97.1-aarch64-apple-darwin/bin/rustfmt Skipping git-over-HTTPS check (CHECK_TOOLS_SKIP_CLONE is set). diff --git a/rust-toolchain.toml b/rust-toolchain.toml index a82b4734d8..dd5e1fe438 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "1.95" -components = ["rustfmt", "clippy", "rust-analyzer", "llvm-tools-preview"] +channel = "1.97.1" +components = ["rustfmt", "clippy", "rust-analyzer", "llvm-tools-preview", "rust-src"] profile = "minimal" From c5dc4085969f85c01d4deb155c5ecb68e24cc23f Mon Sep 17 00:00:00 2001 From: Jingchen Date: Tue, 25 Aug 2026 14:13:02 +0000 Subject: [PATCH 19/32] fix: Remove `explicit` from std/boost hash specialisation default constructors (#8100) --- include/xrpl/beast/net/IPAddress.h | 2 +- include/xrpl/protocol/Book.h | 8 ++++---- include/xrpl/protocol/MPTIssue.h | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/include/xrpl/beast/net/IPAddress.h b/include/xrpl/beast/net/IPAddress.h index 7422778ea2..e636a69ce7 100644 --- a/include/xrpl/beast/net/IPAddress.h +++ b/include/xrpl/beast/net/IPAddress.h @@ -103,7 +103,7 @@ namespace boost { template <> struct hash<::beast::ip::Address> { - explicit hash() = default; + hash() = default; std::size_t operator()(::beast::ip::Address const& addr) const diff --git a/include/xrpl/protocol/Book.h b/include/xrpl/protocol/Book.h index a83eb41b24..e6ed3729dd 100644 --- a/include/xrpl/protocol/Book.h +++ b/include/xrpl/protocol/Book.h @@ -133,7 +133,7 @@ private: using id_hash_type = boost::base_from_member, 0>; public: - explicit hash() = default; + hash() = default; using value_type = std::size_t; using argument_type = xrpl::MPTIssue; @@ -160,7 +160,7 @@ private: mptissue_hasher mMptissueHasher_; public: - explicit hash() = default; + hash() = default; value_type operator()(argument_type const& asset) const @@ -227,7 +227,7 @@ struct hash : std::hash template <> struct hash : std::hash { - explicit hash() = default; + hash() = default; using Base = std::hash; }; @@ -235,7 +235,7 @@ struct hash : std::hash template <> struct hash : std::hash { - explicit hash() = default; + hash() = default; using Base = std::hash; }; diff --git a/include/xrpl/protocol/MPTIssue.h b/include/xrpl/protocol/MPTIssue.h index 7f473da6a2..49c1fd63dc 100644 --- a/include/xrpl/protocol/MPTIssue.h +++ b/include/xrpl/protocol/MPTIssue.h @@ -151,7 +151,7 @@ namespace std { template <> struct hash : xrpl::MPTID::hasher { - explicit hash() = default; + hash() = default; }; } // namespace std From 45e4b8899df3b2e5d6e0bafb54dbf6e4300b38c4 Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Tue, 25 Aug 2026 14:34:41 +0000 Subject: [PATCH 20/32] build: Update packaging images; add Python (#8106) --- .github/workflows/build-packaging-images.yml | 8 +++++--- package/install-packaging-tools.sh | 18 +++++++++--------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/.github/workflows/build-packaging-images.yml b/.github/workflows/build-packaging-images.yml index c927942fca..fd04eae995 100644 --- a/.github/workflows/build-packaging-images.yml +++ b/.github/workflows/build-packaging-images.yml @@ -33,12 +33,14 @@ jobs: strategy: fail-fast: false matrix: + # Newest of each distro: these images only wrap pre-built binaries, so + # they set no floor for consumers. build_pkg.py pins the RPM dist tag. distro: - name: debian - base_image: debian:bookworm - # AlmaLinux rather than UBI9, which does not ship rpm-sign. + base_image: debian:trixie + # AlmaLinux rather than UBI, which does not ship rpm-sign. - name: rhel - base_image: almalinux:9 + base_image: almalinux:10 uses: XRPLF/actions/.github/workflows/build-multiarch-image.yml@65d5a0bd72be4ecea95cff0673a6e0672ab5243a with: image_name: xrpld/packaging-${{ matrix.distro.name }} diff --git a/package/install-packaging-tools.sh b/package/install-packaging-tools.sh index 2326d8f2ac..36557364ae 100755 --- a/package/install-packaging-tools.sh +++ b/package/install-packaging-tools.sh @@ -28,31 +28,31 @@ esac # - debhelper and dpkg-dev build the DEB # - rpm-build builds the RPM, with systemd-rpm-macros and redhat-rpm-config # supplying the systemd and find-debuginfo macros the spec uses -# - rpm-sign signs the built RPM -# - git gives build_pkg.sh a real history to read SOURCE_DATE_EPOCH from; -# without one the timestamp falls back to the wall clock -# - curl uploads the finished packages in publish_pkg.sh -# - ca-certificates lets curl and git verify TLS +# - rpm-sign and gnupg2 sign the built RPM +# - python3 runs the packaging scripts +# - git gives build_pkg.py the commit timestamp it stamps files with +# - ca-certificates lets git and the packaging scripts verify TLS function install() { case "${ID}" in debian | ubuntu) apt-get update -y apt-get install -y --no-install-recommends \ ca-certificates \ - curl \ debhelper \ debhelper-compat \ dpkg-dev \ - git + git \ + python3 ;; rhel | centos | rocky | almalinux) dnf install -y --setopt=install_weak_deps=False \ - curl-minimal \ git \ + gnupg2 \ + python3 \ + redhat-rpm-config \ rpm-build \ rpm-sign \ - redhat-rpm-config \ systemd-rpm-macros ;; esac From ec042fefeecadfc59305f150eea81cb867e59b9b Mon Sep 17 00:00:00 2001 From: Vito Tumas <5780819+Tapanito@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:07:58 +0000 Subject: [PATCH 21/32] fix: Absorb Vault invariant rounding noise (#8055) --- src/libxrpl/tx/invariants/VaultInvariant.cpp | 128 ++++- .../tx/transactors/vault/VaultClawback.cpp | 5 - .../tx/transactors/vault/VaultWithdraw.cpp | 3 - .../app/invariants/InvariantsVault_test.cpp | 179 +++++++ .../vault/VaultInvariantPrecision_test.cpp | 458 ++++++++++++++++++ src/test/app/vault/VaultPrecisionFixture.h | 242 +++++++++ 6 files changed, 990 insertions(+), 25 deletions(-) create mode 100644 src/test/app/vault/VaultInvariantPrecision_test.cpp create mode 100644 src/test/app/vault/VaultPrecisionFixture.h diff --git a/src/libxrpl/tx/invariants/VaultInvariant.cpp b/src/libxrpl/tx/invariants/VaultInvariant.cpp index 7ba42383ad..1bfb9d3d43 100644 --- a/src/libxrpl/tx/invariants/VaultInvariant.cpp +++ b/src/libxrpl/tx/invariants/VaultInvariant.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -305,6 +306,45 @@ ValidVault::finalizeLoanSet(ReadView const& view, beast::Journal const& j) const return true; } +namespace { + +// sfAssetsTotal, sfAssetsAvailable and sfLossUnrealized are STNumber fields +// with kSmdNeedsAsset, so IOU writes go through associateAsset -> roundToAsset +// -> STAmount quantization. Since assetsTotal is the largest number, it lands +// on the coarsest decimal grid, and strict equality on the deltas can fire on +// a single unit of quantization noise even when the underlying flow is +// correct. Absorb one unit at the coarsest scale. +// +// XRP and MPT are integer-domain assets (Asset::integral() is true) with no +// sub-ULP quantization; treating a whole drop / MPT unit as "noise" would +// hide real accounting bugs. Keep the strict comparison there. Note that +// gating on the sign of `scale` would be wrong: IOU amounts >= 1e15 have a +// non-negative STAmount exponent but still quantize. +[[nodiscard]] bool +agreesWithinOneUnit(Number const& lhs, Number const& rhs, Asset const& asset, std::int32_t scale) +{ + if (asset.integral()) + return lhs == rhs; + auto const diff = lhs - rhs; + Number const tolerance{1, scale}; + return (diff < beast::kZero ? -diff : diff) <= tolerance; +} + +// L, T and A are each independently quantized; the strict L <= T - A check +// can fire on residual noise even when the true relationship holds. Tolerate +// one unit at scale(assetsTotal) - the coarsest of the three grids. As with +// the delta check above, the tolerance is meaningful only for IOU +// (Asset::integral() is false); XRP and MPT keep the strict comparison. +[[nodiscard]] bool +lessOrEqualPlusOneUnit(Number const& lhs, Number const& rhs, Asset const& asset, std::int32_t scale) +{ + if (asset.integral()) + return lhs <= rhs; + return lhs <= rhs + Number{1, scale}; +} + +} // namespace + std::int32_t ValidVault::computeVaultMinScale(DeltaInfo const& vaultDelta, Rules const& rules) const { @@ -340,6 +380,7 @@ ValidVault::finalize( beast::Journal const& j) { bool const enforce = view.rules().enabled(featureSingleAssetVault); + bool const fixEnabled = view.rules().enabled(fixCleanup3_4_0); if (!isTesSuccess(ret)) return true; // Do not perform checks @@ -527,15 +568,32 @@ ValidVault::finalize( "not be greater than assets outstanding"; result = false; } - else if (afterVault.lossUnrealized > afterVault.assetsTotal - afterVault.assetsAvailable) + else { - JLOG(j.fatal()) // - << "Invariant failed: loss unrealized must not exceed " - "the difference between assets outstanding and available"; - result = false; + bool const gapExceeded = [&] { + if (!fixEnabled) + { + return afterVault.lossUnrealized > + afterVault.assetsTotal - afterVault.assetsAvailable; + } + + auto const s = scale(afterVault.assetsTotal, afterVault.asset); + return !lessOrEqualPlusOneUnit( + afterVault.lossUnrealized, + afterVault.assetsTotal - afterVault.assetsAvailable, + afterVault.asset, + s); + }(); + if (gapExceeded) + { + JLOG(j.fatal()) // + << "Invariant failed: loss unrealized must not exceed " + "the difference between assets outstanding and available"; + result = false; + } } - if (view.rules().enabled(fixCleanup3_4_0) && afterVault.lossUnrealized < kZero) + if (fixEnabled && afterVault.lossUnrealized < kZero) { JLOG(j.fatal()) << "Invariant failed: loss unrealized must not be negative"; result = false; @@ -821,7 +879,14 @@ ValidVault::finalize( result = false; } - if (localVaultDeltaAssets * -1 != accountDeltaAssets) + bool const acctVaultAddsUp = fixEnabled + ? agreesWithinOneUnit( + localVaultDeltaAssets * -1, + accountDeltaAssets, + vaultAsset, + localMinScale) + : localVaultDeltaAssets * -1 == accountDeltaAssets; + if (!acctVaultAddsUp) { JLOG(j.fatal()) << "Invariant failed: " << // "deposit must change vault and depositor balance by equal amount"; @@ -869,7 +934,10 @@ ValidVault::finalize( auto const assetTotalDelta = roundToAsset( vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale); - if (assetTotalDelta != vaultDeltaAssets) + bool const totalAddsUp = fixEnabled + ? agreesWithinOneUnit(assetTotalDelta, vaultDeltaAssets, vaultAsset, minScale) + : assetTotalDelta == vaultDeltaAssets; + if (!totalAddsUp) { JLOG(j.fatal()) << "Invariant failed: deposit and assets outstanding must add up"; @@ -878,7 +946,11 @@ ValidVault::finalize( auto const assetAvailableDelta = roundToAsset( vaultAsset, afterVault.assetsAvailable - beforeVault.assetsAvailable, minScale); - if (assetAvailableDelta != vaultDeltaAssets) + bool const availableAddsUp = fixEnabled + ? agreesWithinOneUnit( + assetAvailableDelta, vaultDeltaAssets, vaultAsset, minScale) + : assetAvailableDelta == vaultDeltaAssets; + if (!availableAddsUp) { JLOG(j.fatal()) << "Invariant failed: deposit and assets available must add up"; result = false; @@ -920,8 +992,8 @@ ValidVault::finalize( // value merely rounds down to zero, so a missing delta while // the pool still held positive effective value indicates a // real accounting bug, not this exception. - bool const zeroDeltaIsLegitimate = view.rules().enabled(fixCleanup3_4_0) && - !maybeVaultDeltaAssets && beforeVault.assetsTotal == beforeVault.lossUnrealized; + bool const zeroDeltaIsLegitimate = fixEnabled && !maybeVaultDeltaAssets && + beforeVault.assetsTotal == beforeVault.lossUnrealized; if (!maybeVaultDeltaAssets && !zeroDeltaIsLegitimate) { @@ -1027,8 +1099,14 @@ ValidVault::finalize( vaultDeltaAssets.delta * -1 - destinationDelta.delta, destinationScale, Number::RoundingMode::Downward) == kZero; - if (!destroyedIsSubUlp && - localPseudoDeltaAssets * -1 != roundedDestinationDelta) + bool const withdrawAddsUp = fixEnabled + ? agreesWithinOneUnit( + localPseudoDeltaAssets * -1, + roundedDestinationDelta, + vaultAsset, + localMinScale) + : localPseudoDeltaAssets * -1 == roundedDestinationDelta; + if (!destroyedIsSubUlp && !withdrawAddsUp) { JLOG(j.fatal()) << "Invariant failed: " << // "withdrawal must change vault and destination balance by equal " @@ -1071,7 +1149,11 @@ ValidVault::finalize( auto const assetTotalDelta = roundToAsset( vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale); // Note, vaultBalance is negative (see check above) - if (assetTotalDelta != vaultPseudoDeltaAssets) + bool const totalAddsUp = fixEnabled + ? agreesWithinOneUnit( + assetTotalDelta, vaultPseudoDeltaAssets, vaultAsset, minScale) + : assetTotalDelta == vaultPseudoDeltaAssets; + if (!totalAddsUp) { JLOG(j.fatal()) << "Invariant failed: withdrawal and assets outstanding must add up"; @@ -1081,7 +1163,11 @@ ValidVault::finalize( auto const assetAvailableDelta = roundToAsset( vaultAsset, afterVault.assetsAvailable - beforeVault.assetsAvailable, minScale); - if (assetAvailableDelta != vaultPseudoDeltaAssets) + bool const availableAddsUp = fixEnabled + ? agreesWithinOneUnit( + assetAvailableDelta, vaultPseudoDeltaAssets, vaultAsset, minScale) + : assetAvailableDelta == vaultPseudoDeltaAssets; + if (!availableAddsUp) { JLOG(j.fatal()) << "Invariant failed: withdrawal and assets available must add up"; @@ -1126,7 +1212,11 @@ ValidVault::finalize( auto const assetsTotalDelta = roundToAsset( vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale); - if (assetsTotalDelta != vaultDeltaAssets) + bool const totalAddsUp = fixEnabled + ? agreesWithinOneUnit( + assetsTotalDelta, vaultDeltaAssets, vaultAsset, minScale) + : assetsTotalDelta == vaultDeltaAssets; + if (!totalAddsUp) { JLOG(j.fatal()) << // "Invariant failed: clawback and assets outstanding must add up"; @@ -1137,7 +1227,11 @@ ValidVault::finalize( vaultAsset, afterVault.assetsAvailable - beforeVault.assetsAvailable, minScale); - if (assetAvailableDelta != vaultDeltaAssets) + bool const availableAddsUp = fixEnabled + ? agreesWithinOneUnit( + assetAvailableDelta, vaultDeltaAssets, vaultAsset, minScale) + : assetAvailableDelta == vaultDeltaAssets; + if (!availableAddsUp) { JLOG(j.fatal()) << // "Invariant failed: clawback and assets available must add up"; diff --git a/src/libxrpl/tx/transactors/vault/VaultClawback.cpp b/src/libxrpl/tx/transactors/vault/VaultClawback.cpp index d0eeaed071..b6dc9377c2 100644 --- a/src/libxrpl/tx/transactors/vault/VaultClawback.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultClawback.cpp @@ -353,11 +353,6 @@ VaultClawback::doApply() auto assetsAvailable = vault->at(sfAssetsAvailable); auto assetsTotal = vault->at(sfAssetsTotal); - [[maybe_unused]] auto const lossUnrealized = vault->at(sfLossUnrealized); - XRPL_ASSERT( - lossUnrealized <= (assetsTotal - assetsAvailable), - "xrpl::VaultClawback::doApply : loss and assets do balance"); - AccountID const holder = tx[sfHolder]; STAmount sharesDestroyed = {share}; STAmount assetsRecovered = {vault->at(sfAsset)}; diff --git a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp index ffefa51d05..cee03f3999 100644 --- a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp @@ -357,9 +357,6 @@ VaultWithdraw::doApply() auto assetsAvailable = vault->at(sfAssetsAvailable); auto assetsTotal = vault->at(sfAssetsTotal); auto const lossUnrealized = vault->at(sfLossUnrealized); - XRPL_ASSERT( - lossUnrealized <= (assetsTotal - assetsAvailable), - "xrpl::VaultWithdraw::doApply : loss and assets do balance"); if (view().rules().enabled(fixCleanup3_4_0) && !isFinalWithdrawal) { diff --git a/src/test/app/invariants/InvariantsVault_test.cpp b/src/test/app/invariants/InvariantsVault_test.cpp index abcbe343f5..56ccaa46fc 100644 --- a/src/test/app/invariants/InvariantsVault_test.cpp +++ b/src/test/app/invariants/InvariantsVault_test.cpp @@ -3,7 +3,10 @@ #include #include #include +#include #include +#include +#include #include #include @@ -1947,6 +1950,181 @@ class InvariantsVault_test : public InvariantsBase }); } + // Minimal impaired-loan setup for testVaultLossExceedsGap. Kept + // inline here so this file has no dependency on LoanTestBase. + Keylet + makeImpairedVault( + test::jtx::Account const& owner, + test::jtx::Account const& borrower, + test::jtx::Account const& issuer, + test::jtx::Env& env) + { + using namespace test::jtx; + + env.fund(XRP(1'000'000), issuer, borrower); + env.close(); + + PrettyAsset const usd = issuer["USD"]; + STAmount const trustLimit{usd.raw(), Number{9'999'999'999'999'999LL}}; + env(trust(owner, trustLimit)); + env(trust(borrower, trustLimit)); + env.close(); + + env(pay(issuer, owner, usd(100'000))); + env(pay(issuer, borrower, usd(1'000))); + env.close(); + + Vault const vault{env}; + auto [vaultTx, vaultKeylet] = vault.create({.owner = owner, .asset = usd}); + env(vaultTx); + env.close(); + + env(vault.deposit( + {.depositor = owner, .id = vaultKeylet.key, .amount = usd(1'000).value()})); + env.close(); + + auto const brokerKeylet = + keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); + + { + using namespace loan_broker; + env(set(owner, vaultKeylet.key), + kCoverRateMinimum(percentageToTenthBips(1)), + kCoverRateLiquidation(xrpl::lending::kMaxCoverRate), + Fee(env.current()->fees().base * 2)); + env.close(); + + env(coverDeposit(owner, brokerKeylet.key, usd(10'000).value()), + Fee(env.current()->fees().base * 2)); + env.close(); + } + + auto const brokerSle = env.le(brokerKeylet); + if (!BEAST_EXPECT(brokerSle)) + return vaultKeylet; + + auto const loanKeylet = + keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence))); + + { + using namespace loan; + env(set(borrower, brokerKeylet.key, usd(100).value()), + kCounterparty(owner), + kInterestRate(TenthBips32{1000}), + kPaymentTotal(120), + kPaymentInterval(86400u * 30u), + kGracePeriod(86400u * 30u), + Sig(sfCounterpartySignature, owner), + Fee(env.current()->fees().base * 200)); + env.close(); + + env(manage(owner, loanKeylet.key, tfLoanImpair)); + env.close(); + } + + return vaultKeylet; + } + + // Regression test for the loss-vs-gap invariant relaxation introduced + // by fixCleanup3_4_0. Even with the one-unit tolerance, a loss value + // exceeding (T - A) by more than one ULP must still fire. Two + // mutations exercise this: + // 1. L = (T - A) * 2 — fires under both amendment settings. + // 2. L = (T - A) + 2 * oneUnit — fires post-amendment, catching + // any accidental widening of the tolerance beyond one unit. + void + testVaultLossExceedsGap() + { + testcase("vault loss exceeds gap (fixCleanup3_4_0 tolerance)"); + using namespace test::jtx; + + auto const kExpectedLog = std::vector{ + "loss unrealized must not exceed the difference between assets " + "outstanding and available"}; + + for (auto const withFix : {false, true}) + { + FeatureBitset amendments = all_; + if (!withFix) + amendments = amendments - fixCleanup3_4_0; + + // Variant 1: L = (T - A) * 2. Fires under both settings. + { + Keylet vaultKeylet = keylet::vault(uint256{}); + Account const issuer{"issuer_loss_gap"}; + Account const borrower{"borrower_loss_gap"}; + + auto preclose = [&, this](Account const& owner, Account const&, Env& env) -> bool { + vaultKeylet = this->makeImpairedVault(owner, borrower, issuer, env); + return BEAST_EXPECT(env.le(vaultKeylet)); + }; + + doInvariantCheck( + makeEnv(amendments), + kExpectedLog, + [&vaultKeylet](Account const&, Account const&, ApplyContext& ac) -> bool { + auto sle = ac.view().peek(vaultKeylet); + if (!sle) + return false; + Number const total = sle->at(sfAssetsTotal); + Number const available = sle->at(sfAssetsAvailable); + (*sle)[sfLossUnrealized] = (total - available) * 2; + ac.view().update(sle); + return true; + }, + XRPAmount{}, + STTx{ + ttVAULT_DEPOSIT, + [&vaultKeylet](STObject& tx) { + tx.setFieldH256(sfVaultID, vaultKeylet.key); + }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + preclose, + TxAccount::A1); + } + + // Variant 2: L = (T - A) + 2 * oneUnit at scale(T). Must fire + // post-fix because the tolerance is exactly one unit. A + // regression that widened it to two units would silently accept + // this state. + { + Keylet vaultKeylet = keylet::vault(uint256{}); + Account const issuer{"issuer_loss_gap2"}; + Account const borrower{"borrower_loss_gap2"}; + + auto preclose = [&, this](Account const& owner, Account const&, Env& env) -> bool { + vaultKeylet = this->makeImpairedVault(owner, borrower, issuer, env); + return BEAST_EXPECT(env.le(vaultKeylet)); + }; + + doInvariantCheck( + makeEnv(amendments), + kExpectedLog, + [&vaultKeylet](Account const&, Account const&, ApplyContext& ac) -> bool { + auto sle = ac.view().peek(vaultKeylet); + if (!sle) + return false; + Number const total = sle->at(sfAssetsTotal); + Number const available = sle->at(sfAssetsAvailable); + Asset const asset = sle->at(sfAsset); + Number const oneUnit{1, scale(total, asset)}; + (*sle)[sfLossUnrealized] = (total - available) + oneUnit * 2; + ac.view().update(sle); + return true; + }, + XRPAmount{}, + STTx{ + ttVAULT_DEPOSIT, + [&vaultKeylet](STObject& tx) { + tx.setFieldH256(sfVaultID, vaultKeylet.key); + }}, + {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + preclose, + TxAccount::A1); + } + } + } + void testVaultComputeCoarsestScale() { @@ -2082,6 +2260,7 @@ class InvariantsVault_test : public InvariantsBase run() override { testVault(); + testVaultLossExceedsGap(); testVaultComputeCoarsestScale(); } }; diff --git a/src/test/app/vault/VaultInvariantPrecision_test.cpp b/src/test/app/vault/VaultInvariantPrecision_test.cpp new file mode 100644 index 0000000000..a7eeda34ae --- /dev/null +++ b/src/test/app/vault/VaultInvariantPrecision_test.cpp @@ -0,0 +1,458 @@ +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace xrpl::test { + +// With fixCleanup3_4_0 disabled the six delta invariants and the +// lossUnrealized > (assetsTotal - assetsAvailable) gap invariant spuriously +// fire on legitimate flows; with the amendment enabled the one-unit +// tolerance absorbs the sub-ULP drift and every one of these transactions +// must succeed. Exactness (assetsTotal delta == assetsAvailable delta +// exactly) is covered by VaultTransactorPrecision_test. +class VaultInvariantPrecision_test : public VaultPrecisionFixture +{ + // Deposit small integer amounts into an A-1 vault. Pre-amendment, + // deposits of 1, 7, and 10'000'000 land on assetsTotal/assetsAvailable + // grids that disagree by one ULP and the invariant fires. Post- + // amendment the tolerance-widened check accepts the same states. + void + testDepositBoundaryInvariant(FeatureBitset features) + { + using namespace jtx; + + bool const fixEnabled = features[fixCleanup3_4_0]; + testcase( + std::string("A-1 deposit boundary invariant") + + (fixEnabled ? " (fixCleanup3_4_0)" : " (pre-fix)")); + + std::array const kAmounts{1, 7, 10'000'000}; + + for (auto const amount : kAmounts) + { + Env env{*this, envconfig(), features, nullptr, beast::Severity::Disabled}; + auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/false); + if (!f.asset || !f.broker) + { + BEAST_EXPECT(f.asset && f.broker); + continue; + } + auto const& asset = *f.asset; + + auto const before = read(env, f); + + Vault const v{env}; + env(v.deposit( + {.depositor = f.depositor, + .id = f.vaultKeylet.key, + .amount = asset(amount).value()}), + Ter(std::ignore)); + env.close(); + + TER const actual = env.ter(); + + if (fixEnabled) + { + BEAST_EXPECTS( + actual == tesSUCCESS, + "amount=" + std::to_string(amount) + " expected tesSUCCESS, got " + + transToken(actual)); + + auto const after = read(env, f); + Number const tDelta = after.assetsTotal - before.assetsTotal; + Number const aDelta = after.assetsAvailable - before.assetsAvailable; + Number const requested = asset(amount).number(); + + BEAST_EXPECT(tDelta <= requested); + + Number const gap = tDelta > aDelta ? tDelta - aDelta : aDelta - tDelta; + BEAST_EXPECT(gap <= oneUnit(asset, after.assetsTotal)); + } + else + { + BEAST_EXPECTS( + actual == tecINVARIANT_FAILED, + "amount=" + std::to_string(amount) + " expected tecINVARIANT_FAILED, got " + + transToken(actual)); + } + } + } + + // Withdraw long-mantissa share counts from an A-1 vault. Pre-fix + // some counts trip the withdraw delta invariants; post-fix none does. + void + testWithdrawBoundaryInvariant(FeatureBitset features) + { + using namespace jtx; + + bool const fixEnabled = features[fixCleanup3_4_0]; + testcase( + std::string("A-1 withdraw boundary invariant") + + (fixEnabled ? " (fixCleanup3_4_0)" : " (pre-fix)")); + + std::array const kShareCounts{ + 99'999u, 100'001u, 333'333u, 1'234'567u, 142'857'142u, 333'333'333u}; + + // Fill the vault with enough shares that every count below is + // available to the depositor. + Env env{*this, envconfig(), features, nullptr, beast::Severity::Disabled}; + auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/false); + if (!f.asset || !f.broker) + { + BEAST_EXPECT(f.asset && f.broker); + return; + } + auto const& asset = *f.asset; + + Vault const v{env}; + // Deposit a large amount so we can afford every withdrawal below. + env(v.deposit( + {.depositor = f.depositor, + .id = f.vaultKeylet.key, + .amount = asset(1'000'000).value()}), + Ter(std::ignore)); + env.close(); + + for (auto const count : kShareCounts) + { + auto const before = read(env, f); + if (before.sharesTotal < count) + continue; + + STAmount const shareAmount{MPTIssue{f.share}, Number{static_cast(count)}}; + env(v.withdraw( + {.depositor = f.depositor, .id = f.vaultKeylet.key, .amount = shareAmount}), + Ter(std::ignore)); + env.close(); + + TER const actual = env.ter(); + + if (fixEnabled) + { + BEAST_EXPECTS( + actual != tecINVARIANT_FAILED, + "shares=" + std::to_string(count) + " unexpected invariant failure"); + + if (actual == tesSUCCESS) + { + auto const after = read(env, f); + Number const tDelta = before.assetsTotal - after.assetsTotal; + Number const pDelta = before.pseudo - after.pseudo; + Number const gap = tDelta > pDelta ? tDelta - pDelta : pDelta - tDelta; + // VaultTransactorPrecision_test tightens this to strict + // equality. + BEAST_EXPECT(gap <= oneUnit(asset, before.assetsTotal)); + } + } + // Pre-fix behaviour is fixture-dependent: some share counts may + // succeed even without the amendment. The important property is + // that post-fix no legitimate withdrawal is rejected by the + // widened invariant. + } + } + + // Clawback of small IOU amounts against a live-loan vault. Pre-fix + // some amounts trip the clawback delta invariants; post-fix none does. + // Also assert the owner force-burn path returns tecNO_PERMISSION + // under both amendment states (it never enters assetsToClawback). + void + testClawbackBoundaryInvariant(FeatureBitset features) + { + using namespace jtx; + + bool const fixEnabled = features[fixCleanup3_4_0]; + testcase( + std::string("A-1 clawback boundary invariant") + + (fixEnabled ? " (fixCleanup3_4_0)" : " (pre-fix)")); + + std::array const kAmounts{1, 7, 99, 333, 993, 2000}; + + Env env{*this, envconfig(), features, nullptr, beast::Severity::Disabled}; + auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/false, /*allowClawback=*/true); + if (!f.asset || !f.broker) + { + BEAST_EXPECT(f.asset && f.broker); + return; + } + auto const& asset = *f.asset; + + Vault const v{env}; + + // Give the depositor a stake so that the issuer has something to + // claw back. + env(v.deposit( + {.depositor = f.depositor, + .id = f.vaultKeylet.key, + .amount = asset(2'000).value()}), + Ter(std::ignore)); + env.close(); + + for (auto const amount : kAmounts) + { + auto const before = read(env, f); + if (before.sharesTotal == 0) + continue; + + env(v.clawback( + {.issuer = f.issuer, + .id = f.vaultKeylet.key, + .holder = f.depositor, + .amount = asset(amount).value()}), + Ter(std::ignore)); + env.close(); + + TER const actual = env.ter(); + + if (fixEnabled) + { + BEAST_EXPECTS( + actual != tecINVARIANT_FAILED, + "amount=" + std::to_string(amount) + " unexpected invariant failure"); + } + // Pre-fix behaviour is fixture-dependent: some clawback amounts + // may succeed even without the amendment. The important + // property is that post-fix no legitimate clawback is rejected + // by the widened invariant. + } + + // Owner force-burn only succeeds against an EMPTY vault (see + // VaultClawback::preclaim). Our fixture keeps a live loan, so + // this must return tecNO_PERMISSION regardless of the amendment. + env(v.clawback({.issuer = f.lender, .id = f.vaultKeylet.key, .holder = f.depositor}), + Ter(tecNO_PERMISSION)); + env.close(); + } + + // Deposit into an A-3 vault where the impaired-loan gap plus the + // interest earned from the sibling repayment lands L > (T - A) by + // sub-ULP. Pre-fix the loss invariant fires; post-fix it does not. + void + testLossInvariantA3(FeatureBitset features) + { + using namespace jtx; + + bool const fixEnabled = features[fixCleanup3_4_0]; + testcase( + std::string("A-3 loss invariant sweep") + + (fixEnabled ? " (fixCleanup3_4_0)" : " (pre-fix)")); + + std::array const kAmounts{1, 7, 10'000'000}; + + for (auto const amount : kAmounts) + { + Env env{*this, envconfig(), features, nullptr, beast::Severity::Disabled}; + auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/true); + if (!f.asset || !f.broker) + { + BEAST_EXPECT(f.asset && f.broker); + continue; + } + auto const& asset = *f.asset; + + Vault const v{env}; + env(v.deposit( + {.depositor = f.depositor, + .id = f.vaultKeylet.key, + .amount = asset(amount).value()}), + Ter(std::ignore)); + env.close(); + + TER const actual = env.ter(); + + if (fixEnabled) + { + BEAST_EXPECTS( + actual == tesSUCCESS, + "amount=" + std::to_string(amount) + " expected tesSUCCESS, got " + + transToken(actual)); + + auto const after = read(env, f); + BEAST_EXPECT( + after.lossUnrealized <= (after.assetsTotal - after.assetsAvailable) + + oneUnit(asset, after.assetsTotal)); + } + else + { + BEAST_EXPECTS( + actual == tecINVARIANT_FAILED, + "amount=" + std::to_string(amount) + " expected tecINVARIANT_FAILED, got " + + transToken(actual)); + } + } + } + + // Full 17-magnitude A-1 deposit sweep. Pre-fix {1, 7, 10'000'000} + // are the boundary amounts that fail; post-fix every amount succeeds. + void + testA1DepositMagnitudes(FeatureBitset features) + { + using namespace jtx; + + bool const fixEnabled = features[fixCleanup3_4_0]; + testcase( + std::string("A-1 deposit magnitude sweep") + + (fixEnabled ? " (fixCleanup3_4_0)" : " (pre-fix)")); + + std::array const kAmounts{ + 1, + 2, + 5, + 7, + 10, + 50, + 100, + 500, + 1'000, + 5'000, + 10'000, + 50'000, + 100'000, + 500'000, + 1'000'000, + 5'000'000, + 10'000'000}; + std::array const kPreFixFailures{1, 7, 10'000'000}; + + for (auto const amount : kAmounts) + { + Env env{*this, envconfig(), features, nullptr, beast::Severity::Disabled}; + auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/false); + if (!f.asset || !f.broker) + { + BEAST_EXPECT(f.asset && f.broker); + continue; + } + auto const& asset = *f.asset; + + Vault const v{env}; + env(v.deposit( + {.depositor = f.depositor, + .id = f.vaultKeylet.key, + .amount = asset(amount).value()}), + Ter(std::ignore)); + env.close(); + + TER const actual = env.ter(); + + if (fixEnabled) + { + BEAST_EXPECTS( + actual == tesSUCCESS, + "amount=" + std::to_string(amount) + " expected tesSUCCESS, got " + + transToken(actual)); + } + else + { + bool const shouldFail = + std::ranges::find(kPreFixFailures, amount) != kPreFixFailures.end(); + if (shouldFail) + { + BEAST_EXPECTS( + actual == tecINVARIANT_FAILED, + "pre-fix amount=" + std::to_string(amount) + + " expected tecINVARIANT_FAILED, got " + transToken(actual)); + } + // For other amounts pre-fix, we accept any outcome; the + // interesting property is only asserted for the known-failing + // ones. + } + } + } + + // A-3 deposit sweep. Pre-fix {1, 7, 10'000, 10'000'000} fail; post-fix + // every amount succeeds. 99'999 (delta tolerance) and 10'000'000 + // (loss tolerance) are the two boundary cases that motivate this PR. + void + testA3DepositMagnitudes(FeatureBitset features) + { + using namespace jtx; + + bool const fixEnabled = features[fixCleanup3_4_0]; + testcase( + std::string("A-3 deposit magnitude sweep") + + (fixEnabled ? " (fixCleanup3_4_0)" : " (pre-fix)")); + + std::array const kAmounts{ + 1, 7, 100, 1'000, 10'000, 100'000, 1'000'000, 10'000'000, 99'999}; + + std::array const kPreFixFailures{1, 7, 10'000, 10'000'000}; + + for (auto const amount : kAmounts) + { + Env env{*this, envconfig(), features, nullptr, beast::Severity::Disabled}; + auto f = setupSingleLoanVault(env, /*impairAndPaySibling=*/true); + if (!f.asset || !f.broker) + { + BEAST_EXPECT(f.asset && f.broker); + continue; + } + auto const& asset = *f.asset; + + Vault const v{env}; + env(v.deposit( + {.depositor = f.depositor, + .id = f.vaultKeylet.key, + .amount = asset(amount).value()}), + Ter(std::ignore)); + env.close(); + + TER const actual = env.ter(); + + if (fixEnabled) + { + BEAST_EXPECTS( + actual == tesSUCCESS, + "amount=" + std::to_string(amount) + " expected tesSUCCESS, got " + + transToken(actual)); + } + else + { + bool const shouldFail = + std::ranges::find(kPreFixFailures, amount) != kPreFixFailures.end(); + if (shouldFail) + { + BEAST_EXPECTS( + actual == tecINVARIANT_FAILED, + "pre-fix amount=" + std::to_string(amount) + + " expected tecINVARIANT_FAILED, got " + transToken(actual)); + } + } + } + } + +public: + void + run() override + { + for (auto const& features : {all_ - fixCleanup3_4_0, all_}) + { + testDepositBoundaryInvariant(features); + testWithdrawBoundaryInvariant(features); + testClawbackBoundaryInvariant(features); + testLossInvariantA3(features); + testA1DepositMagnitudes(features); + testA3DepositMagnitudes(features); + } + } +}; + +BEAST_DEFINE_TESTSUITE(VaultInvariantPrecision, app, xrpl); + +} // namespace xrpl::test diff --git a/src/test/app/vault/VaultPrecisionFixture.h b/src/test/app/vault/VaultPrecisionFixture.h new file mode 100644 index 0000000000..c324161347 --- /dev/null +++ b/src/test/app/vault/VaultPrecisionFixture.h @@ -0,0 +1,242 @@ +#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 + +namespace xrpl::test { + +// Shared fixture for VaultInvariantPrecision_test and +// VaultTransactorPrecision_test. +// +// Layout: +// - A-1 (impairAndPaySibling=false): 1000 USD vault + one ordinary loan. +// assetsTotal ~= 1000.353..., assetsAvailable == 993, lossUnrealized == 0. +// - A-3 (impairAndPaySibling=true): add a second loan of principal 11, +// impair the first loan, and pay off the second in full. This drives +// the vault to the lossUnrealized == (assetsTotal - assetsAvailable) +// boundary where the loss invariant used to spuriously fire. +class VaultPrecisionFixture : public LoanTestBase +{ +protected: + static constexpr std::uint32_t kFixturePaymentInterval = 86400u * 30u; + static constexpr std::uint32_t kFixtureGracePeriod = 86400u * 30u; + static constexpr std::uint32_t kFixturePaymentTotal = 120u; + // 10% APR, expressed in tenth-bips (1000 = 10.00 %). + static constexpr std::uint32_t kFixtureInterestTenthBips = 1000u; + + struct Fixture + { + // Every account is initialised with a placeholder name because + // jtx::Account has no default constructor; setupSingleLoanVault + // overwrites them. + jtx::Account issuer{"vp_issuer_placeholder"}; + jtx::Account lender{"vp_lender_placeholder"}; + jtx::Account borrower{"vp_borrower_placeholder"}; + // Distinct account used to deposit into the vault. Keeps share + // ownership independent of the initial vault seeding. + jtx::Account depositor{"vp_depositor_placeholder"}; + // Optional so callers can BEAST_EXPECT(f.asset && f.broker) + // after setup; both are populated in the happy path. + std::optional asset; + std::optional broker; + // Keylet has no default constructor. Fill with an obviously + // meaningless placeholder; setupSingleLoanVault overwrites the + // fields that matter. + Keylet vaultKeylet{ltACCOUNT_ROOT, uint256{}}; + Keylet loan1Keylet{ltACCOUNT_ROOT, uint256{}}; + // Only meaningful when impairAndPaySibling == true. + Keylet loan2Keylet{ltACCOUNT_ROOT, uint256{}}; + jtx::Account vaultAccount{"vp_vault_pseudo_placeholder"}; + MPTID share; + }; + + // Read-only snapshot of the vault + share issuance at a point in time. + // Uses Number for exact arithmetic (no re-quantization). + struct Numbers + { + Asset asset; + MPTIssue share; + Number assetsTotal{}; // sfAssetsTotal + Number assetsAvailable{}; // sfAssetsAvailable + Number lossUnrealized{}; // sfLossUnrealized + Number pseudo{}; // vault pseudo-account balance in the asset + Number sharesTotal{}; // sfOutstandingAmount on the share MPT + }; + + static Numbers + read(jtx::Env const& env, Fixture const& f) + { + Numbers n{.asset = f.asset ? f.asset->raw() : Asset{}, .share = MPTIssue{f.share}}; + if (auto const vaultSle = env.le(f.vaultKeylet)) + { + n.assetsTotal = vaultSle->at(sfAssetsTotal); + n.assetsAvailable = vaultSle->at(sfAssetsAvailable); + n.lossUnrealized = vaultSle->at(sfLossUnrealized); + } + if (auto const issuanceSle = env.le(keylet::mptokenIssuance(f.share))) + { + n.sharesTotal = issuanceSle->at(sfOutstandingAmount); + } + if (f.asset) + n.pseudo = env.balance(f.vaultAccount, *f.asset).number(); + return n; + } + + // One unit at the STAmount scale of `assetsTotalAfter`. Used as the + // tolerance in one-unit-band assertions. + static Number + oneUnit(Asset const& asset, Number const& assetsTotalAfter) + { + return Number{1, scale(assetsTotalAfter, asset)}; + } + + // Build the shared vault + loan(s) layout. The caller constructs + // `env` with whatever FeatureBitset they want to exercise; this helper + // just uses it. If `allowClawback` is true, the issuer's + // asfAllowTrustLineClawback flag is set BEFORE any trust line is + // established for that issuer. A separate env.close() runs so the + // flag lands in the ledger before the trust lines are set up. + static Fixture + setupSingleLoanVault(jtx::Env& env, bool impairAndPaySibling, bool allowClawback = false) + { + using namespace jtx; + using namespace jtx::loan; + using namespace jtx::loan_broker; + + Fixture f; + f.issuer = Account{"vp_issuer"}; + f.lender = Account{"vp_lender"}; + f.borrower = Account{"vp_borrower"}; + f.depositor = Account{"vp_depositor"}; + + env.fund(XRP(1'000'000), f.issuer, f.lender, f.borrower, f.depositor); + env.close(); + + // Must be set BEFORE any trust line to `issuer` is created. + if (allowClawback) + { + env(fset(f.issuer, asfAllowTrustLineClawback)); + env.close(); + } + + PrettyAsset const asset = f.issuer["USD"]; + f.asset = asset; + + env.trust(asset(1'000'000'000), f.lender); + env.trust(asset(1'000'000'000), f.borrower); + env.trust(asset(1'000'000'000), f.depositor); + env(pay(f.issuer, f.lender, asset(100'000'000))); + env(pay(f.issuer, f.borrower, asset(100'000'000))); + env(pay(f.issuer, f.depositor, asset(100'000'000))); + env.close(); + + BrokerParameters const brokerParams{ + .vaultDeposit = 1'000, + .debtMax = 0, + .coverRateMin = percentageToTenthBips(1), + .coverDeposit = 10'000, + .managementFeeRate = TenthBips16{100}, + .coverRateLiquidation = xrpl::lending::kMaxCoverRate}; + + // Build the vault + broker manually (rather than calling + // createVaultAndBroker) so we can seed only the lender/depositor + // trust lines we set up above, and skip the LoanTestBase auto + // funding that assumes an XRP asset. + Vault const vault{env}; + auto [createTx, vaultKeylet] = vault.create({.owner = f.lender, .asset = asset}); + env(createTx); + env.close(); + f.vaultKeylet = vaultKeylet; + + env(vault.deposit( + {.depositor = f.lender, + .id = vaultKeylet.key, + .amount = asset(brokerParams.vaultDeposit)})); + env.close(); + + auto const brokerKeylet = + keylet::loanBroker(f.lender.id(), SeqProxy::rawSequence(env.seq(f.lender))); + + env(set(f.lender, vaultKeylet.key, brokerParams.flags), + kManagementFeeRate(brokerParams.managementFeeRate), + kDebtMaximum(asset(brokerParams.debtMax).value()), + kCoverRateMinimum(brokerParams.coverRateMin), + kCoverRateLiquidation(TenthBips32(brokerParams.coverRateLiquidation))); + env(coverDeposit(f.lender, brokerKeylet.key, asset(brokerParams.coverDeposit).value())); + env.close(); + + f.broker = BrokerInfo{asset, brokerKeylet, vaultKeylet, brokerParams}; + + auto const vaultSle = env.le(vaultKeylet); + f.vaultAccount = Account{"vp_vault_pseudo", vaultSle->at(sfAccount)}; + f.share = vaultSle->at(sfShareMPTID); + + Fee const bigFee{env.current()->fees().base * 200}; + + auto const setLoan = [&](Number const& principal) -> Keylet { + auto const brokerSle = env.le(brokerKeylet); + auto const loanKeylet = keylet::loan( + brokerKeylet.key, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence))); + env(loan::set(f.borrower, brokerKeylet.key, asset(principal).number()), + Sig(sfCounterpartySignature, f.lender), + jtx::loan::kInterestRate(TenthBips32{kFixtureInterestTenthBips}), + jtx::loan::kPaymentTotal(kFixturePaymentTotal), + jtx::loan::kPaymentInterval(kFixturePaymentInterval), + jtx::loan::kGracePeriod(kFixtureGracePeriod), + bigFee); + env.close(); + return loanKeylet; + }; + + // Loan 1: principal 7, the one ordinary loan in both fixtures. + // With vault deposit 1000, this leaves A ≈ 993 (see plan). + f.loan1Keylet = setLoan(Number{7}); + + if (!impairAndPaySibling) + return f; + + // Loan 2: sibling loan of principal 11. + f.loan2Keylet = setLoan(Number{11}); + + // Impair loan 1 → drives sfLossUnrealized to loan 1's value. + env(jtx::loan::manage(f.lender, f.loan1Keylet.key, tfLoanImpair), bigFee); + env.close(); + + // Pay off loan 2 in full so its total value flows into the vault + // and pushes T-A upward, meeting the residual loss. Generous + // upper bound; the transactor takes only what is due. + auto const payoff = asset(Number{50}).value(); + env(pay(f.borrower, f.loan2Keylet.key, payoff, tfLoanFullPayment), bigFee); + env.close(); + + return f; + } +}; + +} // namespace xrpl::test From 9e2aaf6f60aaf43f70a660606b4404876474ce95 Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Tue, 25 Aug 2026 17:09:03 +0000 Subject: [PATCH 22/32] build: Add rust-toolchain.toml to .envrc (#8107) --- .envrc | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.envrc b/.envrc index ec38b75f5c..a3f6be96ea 100644 --- a/.envrc +++ b/.envrc @@ -1,5 +1,8 @@ watch_file nix/*.nix +# Pinned Rust toolchain, read by nix/packages.nix via fromRustupToolchainFile. +watch_file rust-toolchain.toml + # The dev shell derivation includes all of conan/ (see nix/devshell.nix), so any # change in there has to invalidate direnv's cached environment. watch_dir conan From 5e3d20b3ed7bc4086ff44298abd290a4fedce3af Mon Sep 17 00:00:00 2001 From: Vito Tumas <5780819+Tapanito@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:59:37 +0000 Subject: [PATCH 23/32] fix: Prevent vault clawback and withdraw overrun (#8075) --- .../tx/transactors/vault/VaultClawback.cpp | 9 +- .../tx/transactors/vault/VaultWithdraw.cpp | 13 +- src/test/app/vault/VaultBugs_test.cpp | 240 ++++++++++++++++++ src/test/app/vault/VaultScale_test.cpp | 105 ++++---- 4 files changed, 310 insertions(+), 57 deletions(-) diff --git a/src/libxrpl/tx/transactors/vault/VaultClawback.cpp b/src/libxrpl/tx/transactors/vault/VaultClawback.cpp index b6dc9377c2..7348e1734b 100644 --- a/src/libxrpl/tx/transactors/vault/VaultClawback.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultClawback.cpp @@ -271,8 +271,15 @@ VaultClawback::assetsToClawback( } else { + // Pre-fixCleanup3_4_0: shares were rounded to nearest, so the + // round-trip back to assets could exceed clawbackAmount. + // Post-amendment: truncate shares so assetsRecovered <= + // clawbackAmount by construction (matches the clamp branch + // below). + auto const truncate = ctx_.view().rules().enabled(fixCleanup3_4_0) ? TruncateShares::Yes + : TruncateShares::No; auto const maybeShares = - assetsToSharesWithdraw(vault, sleShareIssuance, clawbackAmount); + assetsToSharesWithdraw(vault, sleShareIssuance, clawbackAmount, truncate); if (!maybeShares) return std::unexpected(tecINTERNAL); // LCOV_EXCL_LINE sharesDestroyed = *maybeShares; diff --git a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp index cee03f3999..9e066304ed 100644 --- a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp @@ -305,9 +305,20 @@ VaultWithdraw::doApply() if (amount.asset() == vaultAsset) { // Fixed assets, variable shares. + // + // Pre-fixCleanup3_4_0: shares were rounded to nearest, so the + // round-trip back to assets could exceed the requested amount. + // That over-delivers to the depositor and can bypass the + // preclaim canWithdraw check on the destination, which was + // validated against the requested amount only. + // Post-amendment: truncate shares so assetsWithdrawn <= + // requested amount by construction. If truncation yields zero + // shares, the tecPRECISION_LOSS guard below fires. + auto const truncate = + view().rules().enabled(fixCleanup3_4_0) ? TruncateShares::Yes : TruncateShares::No; { auto const maybeShares = assetsToSharesWithdraw( - vault, sleIssuance, amount, TruncateShares::No, waiveUnrealizedLoss); + vault, sleIssuance, amount, truncate, waiveUnrealizedLoss); if (!maybeShares) return tecINTERNAL; // LCOV_EXCL_LINE sharesRedeemed = *maybeShares; diff --git a/src/test/app/vault/VaultBugs_test.cpp b/src/test/app/vault/VaultBugs_test.cpp index 0771d4a450..ad6fdcc8b8 100644 --- a/src/test/app/vault/VaultBugs_test.cpp +++ b/src/test/app/vault/VaultBugs_test.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -32,6 +33,7 @@ #include #include #include +#include #include #include #include @@ -974,6 +976,242 @@ private: } } + // Shared setup for testBugClawbackRoundTripOvershoot and + // testBugWithdrawRoundTripOvershoot, which both need a vault at + // assetsTotal=7, sharesTotal=5 and differ only in what they do once + // that state is reached. + // + // The (7, 5) state is reached through ordinary transactions: a 5 USD + // deposit mints 5 shares 1:1, then a loan broker on the vault issues a + // single-payment bullet loan for the full 5 USD at 40% interest. When + // the borrower repays a year later, LoanPay books the 2 USD of accrued + // interest into sfAssetsTotal without minting shares, leaving + // assetsTotal=7 against sharesTotal=5 (see + // testBugDepositShareTruncationSubUlp for the same technique in more + // detail). + struct RoundTripOvershootVault + { + test::jtx::Account issuer; + test::jtx::Account holder; + PrettyAsset usd; + test::jtx::Vault vault; + Keylet vaultKeylet; + Number initialAssetsTotal; + Number initialAssetsAvailable; + }; + + std::optional + makeRoundTripOvershootVault(test::jtx::Env& env) + { + using namespace test::jtx; + using namespace loan_broker; + using namespace loan; + + Account const issuer{"issuer"}; + Account const owner{"owner"}; + Account const holder{"holder"}; + Account const borrower{"borrower"}; + + env.fund(XRP(10'000), issuer, owner, holder, borrower); + env.close(); + + env(fset(issuer, asfAllowTrustLineClawback)); + env.close(); + + PrettyAsset const usd = issuer["USD"]; + env.trust(usd(1'000), owner); + env.trust(usd(1'000), holder); + env.trust(usd(1'000), borrower); + env.close(); + + env(pay(issuer, holder, usd(100))); + env(pay(issuer, borrower, usd(100))); + env.close(); + + Vault const vault{env}; + auto [vaultTx, vaultKeylet] = vault.create({.owner = owner, .asset = usd}); + vaultTx[sfScale] = 0; + env(vaultTx); + env.close(); + + // Holder deposits 5 USD, minting 5 shares 1:1. + env(vault.deposit({.depositor = holder, .id = vaultKeylet.key, .amount = usd(5)})); + env.close(); + + // A loan broker on the vault, then a single bullet loan for the + // entire deposit at 40% interest, one payment, one year out. + auto const brokerKeylet = + keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner))); + env(set(owner, vaultKeylet.key)); + env.close(); + + auto const loanKeylet = keylet::loan(brokerKeylet.key, SeqProxy::rawSequence(1)); + env(set(borrower, brokerKeylet.key, usd(5).value()), + loan::kInterestRate(percentageToTenthBips(40)), + kGracePeriod(60), + kPaymentInterval(365 * 24 * 60 * 60), + kPaymentTotal(1), + Sig(sfCounterpartySignature, owner), + Fee(env.current()->fees().base * 2), + Ter(tesSUCCESS)); + env.close(); + + // Advance to just before the single payment falls due and let the + // borrower repay principal plus interest. Share supply stays at 5, + // so assetsTotal/sharesTotal becomes 7/5. + env.close(std::chrono::seconds{(365 * 24 * 60 * 60) - 3600}); + env(pay(borrower, loanKeylet.key, usd(10).value()), Ter(tesSUCCESS)); + env.close(); + + auto const vaultSle = env.le(vaultKeylet); + if (!BEAST_EXPECT(vaultSle)) + return std::nullopt; + auto const mptIssuanceID = vaultSle->at(sfShareMPTID); + + Number const initialAssetsTotal = vaultSle->at(sfAssetsTotal); + Number const initialAssetsAvailable = vaultSle->at(sfAssetsAvailable); + BEAST_EXPECT(initialAssetsTotal == usd(7).number()); + BEAST_EXPECT(initialAssetsAvailable == usd(7).number()); + { + auto const sleIssuance = env.le(keylet::mptokenIssuance(mptIssuanceID)); + if (!BEAST_EXPECT(sleIssuance)) + return std::nullopt; + BEAST_EXPECT(sleIssuance->getFieldU64(sfOutstandingAmount) == 5); + } + + return RoundTripOvershootVault{ + .issuer = issuer, + .holder = holder, + .usd = usd, + .vault = vault, + .vaultKeylet = vaultKeylet, + .initialAssetsTotal = initialAssetsTotal, + .initialAssetsAvailable = initialAssetsAvailable}; + } + + // VaultClawback::assetsToClawback converts clawbackAmount to shares + // with round-to-nearest, then round-trips back to assets. When shares + // round up, assetsRecovered can exceed clawbackAmount. + // + // Repro: assetsTotal=7, sharesTotal=5, request 4: + // shares = round(20/7) = 3, assets = 7*3/5 = 4.2 > 4. + // + // Post-fixCleanup3_4_0: truncate shares so assetsRecovered <= + // clawbackAmount by construction. + void + testBugClawbackRoundTripOvershoot() + { + using namespace test::jtx; + + auto runScenario = [this](FeatureBitset features, bool withFix) { + Env env{*this, features}; + + auto const setup = makeRoundTripOvershootVault(env); + if (!BEAST_EXPECT(setup)) + return; + + auto const clawbackAmount = setup->usd(4); + env(setup->vault.clawback( + {.issuer = setup->issuer, + .id = setup->vaultKeylet.key, + .holder = setup->holder, + .amount = clawbackAmount.value()})); + + auto const vaultSleAfter = env.current()->read(setup->vaultKeylet); + if (!BEAST_EXPECT(vaultSleAfter)) + return; + Number const finalAssetsTotal = vaultSleAfter->at(sfAssetsTotal); + Number const assetsRecovered = setup->initialAssetsTotal - finalAssetsTotal; + Number const clawbackNum = clawbackAmount.number(); + + Number const expectedPost{28LL, -1}; + Number const expectedPre{42LL, -1}; + if (withFix) + { + BEAST_EXPECT(assetsRecovered <= clawbackNum); + BEAST_EXPECT(assetsRecovered == expectedPost); + } + else + { + BEAST_EXPECT(assetsRecovered > clawbackNum); + BEAST_EXPECT(assetsRecovered == expectedPre); + } + }; + + { + testcase( + "bug: VaultClawback round-trip overshoot lets issuer recover " + "more than requested (pre-fixCleanup3_4_0)"); + runScenario(testableAmendments() - fixCleanup3_4_0, false); + } + { + testcase( + "bug: VaultClawback round-trip overshoot is clamped so " + "assetsRecovered <= clawbackAmount (post-fixCleanup3_4_0)"); + runScenario(testableAmendments(), true); + } + } + + // Same root cause as testBugClawbackRoundTripOvershoot on the + // withdraw path. Also bypasses the preclaim canWithdraw check, which + // validates destination limits against the requested amount only. + // + // Repro: assetsTotal=7, sharesTotal=5, request 4: + // pre-fix : shares = round(20/7) = 3, assets = 7*3/5 = 4.2 > 4. + // post-fix: shares = floor(20/7) = 2, assets = 7*2/5 = 2.8 <= 4. + void + testBugWithdrawRoundTripOvershoot() + { + using namespace test::jtx; + + auto runScenario = [this](FeatureBitset features, bool withFix) { + Env env{*this, features}; + + auto const setup = makeRoundTripOvershootVault(env); + if (!BEAST_EXPECT(setup)) + return; + + auto const requested = setup->usd(4); + env(setup->vault.withdraw( + {.depositor = setup->holder, + .id = setup->vaultKeylet.key, + .amount = requested.value()})); + + auto const vaultSleAfter = env.current()->read(setup->vaultKeylet); + if (!BEAST_EXPECT(vaultSleAfter)) + return; + Number const finalAssetsTotal = vaultSleAfter->at(sfAssetsTotal); + Number const assetsWithdrawn = setup->initialAssetsTotal - finalAssetsTotal; + Number const requestedNum = requested.number(); + + Number const expectedPost{28LL, -1}; + Number const expectedPre{42LL, -1}; + if (withFix) + { + BEAST_EXPECT(assetsWithdrawn <= requestedNum); + BEAST_EXPECT(assetsWithdrawn == expectedPost); + } + else + { + BEAST_EXPECT(assetsWithdrawn > requestedNum); + BEAST_EXPECT(assetsWithdrawn == expectedPre); + } + }; + + { + testcase( + "bug: VaultWithdraw round-trip overshoot delivers more than " + "requested (pre-fixCleanup3_4_0)"); + runScenario(testableAmendments() - fixCleanup3_4_0, false); + } + { + testcase( + "bug: VaultWithdraw round-trip overshoot is clamped so " + "assetsWithdrawn <= requested (post-fixCleanup3_4_0)"); + runScenario(testableAmendments(), true); + } + } + void testCredentialPinsPseudoAccount() { @@ -1101,6 +1339,8 @@ public: testCredentialPinsPseudoAccount(); testCredentialPinOverflow(); testBug6LimitBypassWithShares(); + testBugClawbackRoundTripOvershoot(); + testBugWithdrawRoundTripOvershoot(); } }; diff --git a/src/test/app/vault/VaultScale_test.cpp b/src/test/app/vault/VaultScale_test.cpp index 94c594f674..28c9729d78 100644 --- a/src/test/app/vault/VaultScale_test.cpp +++ b/src/test/app/vault/VaultScale_test.cpp @@ -546,13 +546,13 @@ private: } { - testcase("Scale withdraw with rounding shares up"); - // assetsToSharesWithdraw: - // shares = sharesTotal * (assets / assetsTotal) - // shares = 875 * 3.75 / 87.5 = 875 * 0.042857... = 37.5 - // sharesToAssetsWithdraw: - // assets = assetsTotal * (shares / sharesTotal) - // assets = 87.5 * 38 / 875 = 87.5 * 0.043428... = 3.8 + testcase("Scale withdraw with rounding shares up (truncated post-fixCleanup3_4_0)"); + // Pre-fixCleanup3_4_0: + // shares = round(875 * 3.75 / 87.5) = 38 + // assets = 87.5 * 38 / 875 = 3.8 > 3.75 requested. + // Post-fixCleanup3_4_0: + // shares = floor(37.5) = 37 + // assets = 87.5 * 37 / 875 = 3.7 <= 3.75 requested. auto const start = env.balance(d.depositor, d.assets).number(); auto tx = d.vault.withdraw( @@ -561,26 +561,23 @@ private: .amount = STAmount(d.asset, Number(375, -2))}); env(tx); env.close(); - BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(875 - 38)); + BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(875 - 37)); BEAST_EXPECT( env.balance(d.depositor, d.assets) == - STAmount(d.asset, start + Number(38, -1))); + STAmount(d.asset, start + Number(37, -1))); BEAST_EXPECT( env.balance(d.vaultAccount, d.assets) == - STAmount(d.asset, Number(875 - 38, -1))); + STAmount(d.asset, Number(875 - 37, -1))); BEAST_EXPECT( env.balance(d.vaultAccount, d.shares) == - STAmount(d.share, -Number(875 - 38, 0))); + STAmount(d.share, -Number(875 - 37, 0))); } { testcase("Scale withdraw with rounding shares down"); - // assetsToSharesWithdraw: - // shares = sharesTotal * (assets / assetsTotal) - // shares = 837 * 3.72 / 83.7 = 837 * 0.04444... = 37.2 - // sharesToAssetsWithdraw: - // assets = assetsTotal * (shares / sharesTotal) - // assets = 83.7 * 37 / 837 = 83.7 * 0.044205... = 3.7 + // Chained state: 838 shares outstanding, 83.8 assets. + // shares = floor(838 * 3.72 / 83.8) = floor(37.199...) = 37 + // assets = 83.8 * 37 / 838 = 3.7 <= 3.72 requested. auto const start = env.balance(d.depositor, d.assets).number(); auto tx = d.vault.withdraw( @@ -589,37 +586,37 @@ private: .amount = STAmount(d.asset, Number(372, -2))}); env(tx); env.close(); - BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(837 - 37)); + BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(838 - 37)); BEAST_EXPECT( env.balance(d.depositor, d.assets) == STAmount(d.asset, start + Number(37, -1))); BEAST_EXPECT( env.balance(d.vaultAccount, d.assets) == - STAmount(d.asset, Number(837 - 37, -1))); + STAmount(d.asset, Number(838 - 37, -1))); BEAST_EXPECT( env.balance(d.vaultAccount, d.shares) == - STAmount(d.share, -Number(837 - 37, 0))); + STAmount(d.share, -Number(838 - 37, 0))); } { - testcase("Scale withdraw tiny amount"); + testcase("Scale withdraw tiny amount rejected post-fixCleanup3_4_0"); + // Chained state: 801 shares outstanding, 80.1 assets. + // shares = floor(801 * 0.09 / 80.1) = floor(0.9) = 0 + // Zero shares => tecPRECISION_LOSS. State is unchanged. auto const start = env.balance(d.depositor, d.assets).number(); auto tx = d.vault.withdraw( {.depositor = d.depositor, .id = d.keylet.key, .amount = STAmount(d.asset, Number(9, -2))}); - env(tx); + env(tx, Ter{tecPRECISION_LOSS}); env.close(); - BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(800 - 1)); + BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(801)); + BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start)); BEAST_EXPECT( - env.balance(d.depositor, d.assets) == STAmount(d.asset, start + Number(1, -1))); + env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(801, -1))); BEAST_EXPECT( - env.balance(d.vaultAccount, d.assets) == - STAmount(d.asset, Number(800 - 1, -1))); - BEAST_EXPECT( - env.balance(d.vaultAccount, d.shares) == - STAmount(d.share, -Number(800 - 1, 0))); + env.balance(d.vaultAccount, d.shares) == STAmount(d.share, -Number(801, 0))); } { @@ -738,13 +735,13 @@ private: } { - testcase("Scale clawback with rounding shares up"); - // assetsToSharesWithdraw: - // shares = sharesTotal * (assets / assetsTotal) - // shares = 875 * 3.75 / 87.5 = 875 * 0.042857... = 37.5 - // sharesToAssetsWithdraw: - // assets = assetsTotal * (shares / sharesTotal) - // assets = 87.5 * 38 / 875 = 87.5 * 0.043428... = 3.8 + testcase("Scale clawback with rounding shares up (truncated post-fixCleanup3_4_0)"); + // Pre-fixCleanup3_4_0: + // shares = round(875 * 3.75 / 87.5) = 38 + // assets = 87.5 * 38 / 875 = 3.8 > 3.75 requested. + // Post-fixCleanup3_4_0: + // shares = floor(37.5) = 37 + // assets = 87.5 * 37 / 875 = 3.7 <= 3.75 requested. auto const start = env.balance(d.depositor, d.assets).number(); auto tx = d.vault.clawback( @@ -754,24 +751,21 @@ private: .amount = STAmount(d.asset, Number(375, -2))}); env(tx); env.close(); - BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(875 - 38)); + BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(875 - 37)); BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start)); BEAST_EXPECT( env.balance(d.vaultAccount, d.assets) == - STAmount(d.asset, Number(875 - 38, -1))); + STAmount(d.asset, Number(875 - 37, -1))); BEAST_EXPECT( env.balance(d.vaultAccount, d.shares) == - STAmount(d.share, -Number(875 - 38, 0))); + STAmount(d.share, -Number(875 - 37, 0))); } { testcase("Scale clawback with rounding shares down"); - // assetsToSharesWithdraw: - // shares = sharesTotal * (assets / assetsTotal) - // shares = 837 * 3.72 / 83.7 = 837 * 0.04444... = 37.2 - // sharesToAssetsWithdraw: - // assets = assetsTotal * (shares / sharesTotal) - // assets = 83.7 * 37 / 837 = 83.7 * 0.044205... = 3.7 + // Chained state: 838 shares outstanding, 83.8 assets. + // shares = floor(838 * 3.72 / 83.8) = floor(37.199...) = 37 + // assets = 83.8 * 37 / 838 = 3.7 <= 3.72 requested. auto const start = env.balance(d.depositor, d.assets).number(); auto tx = d.vault.clawback( @@ -781,18 +775,21 @@ private: .amount = STAmount(d.asset, Number(372, -2))}); env(tx); env.close(); - BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(837 - 37)); + BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(838 - 37)); BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start)); BEAST_EXPECT( env.balance(d.vaultAccount, d.assets) == - STAmount(d.asset, Number(837 - 37, -1))); + STAmount(d.asset, Number(838 - 37, -1))); BEAST_EXPECT( env.balance(d.vaultAccount, d.shares) == - STAmount(d.share, -Number(837 - 37, 0))); + STAmount(d.share, -Number(838 - 37, 0))); } { - testcase("Scale clawback tiny amount"); + testcase("Scale clawback tiny amount rejected post-fixCleanup3_4_0"); + // Chained state: 801 shares outstanding, 80.1 assets. + // shares = floor(801 * 0.09 / 80.1) = floor(0.9) = 0 + // Zero shares => tecPRECISION_LOSS. State is unchanged. auto const start = env.balance(d.depositor, d.assets).number(); auto tx = d.vault.clawback( @@ -800,16 +797,14 @@ private: .id = d.keylet.key, .holder = d.depositor, .amount = STAmount(d.asset, Number(9, -2))}); - env(tx); + env(tx, Ter{tecPRECISION_LOSS}); env.close(); - BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(800 - 1)); + BEAST_EXPECT(env.balance(d.depositor, d.shares) == d.share(801)); BEAST_EXPECT(env.balance(d.depositor, d.assets) == STAmount(d.asset, start)); BEAST_EXPECT( - env.balance(d.vaultAccount, d.assets) == - STAmount(d.asset, Number(800 - 1, -1))); + env.balance(d.vaultAccount, d.assets) == STAmount(d.asset, Number(801, -1))); BEAST_EXPECT( - env.balance(d.vaultAccount, d.shares) == - STAmount(d.share, -Number(800 - 1, 0))); + env.balance(d.vaultAccount, d.shares) == STAmount(d.share, -Number(801, 0))); } { From fee4bfc22e9a4bf0423b4aab02d665c2fc82c46f Mon Sep 17 00:00:00 2001 From: Ayaz Salikhov Date: Tue, 25 Aug 2026 19:32:03 +0000 Subject: [PATCH 24/32] build: Implement packaging in Python (#8109) --- .github/scripts/strategy-matrix/generate.py | 8 +- .github/scripts/strategy-matrix/linux.json | 6 +- .github/workflows/reusable-package.yml | 24 +- .pre-commit-config.yaml | 16 ++ cmake/XrplPackaging.cmake | 18 +- package/README.md | 87 +++---- package/build_pkg.py | 263 ++++++++++++++++++++ package/build_pkg.sh | 252 ------------------- package/publish_pkg.py | 153 ++++++++++++ package/publish_pkg.sh | 109 -------- package/sign_rpm.py | 128 ++++++++++ package/sign_rpm.sh | 67 ----- 12 files changed, 640 insertions(+), 491 deletions(-) create mode 100755 package/build_pkg.py delete mode 100755 package/build_pkg.sh create mode 100755 package/publish_pkg.py delete mode 100755 package/publish_pkg.sh create mode 100755 package/sign_rpm.py delete mode 100755 package/sign_rpm.sh diff --git a/.github/scripts/strategy-matrix/generate.py b/.github/scripts/strategy-matrix/generate.py index 7fef6643ff..7a3b7a8cf5 100755 --- a/.github/scripts/strategy-matrix/generate.py +++ b/.github/scripts/strategy-matrix/generate.py @@ -57,7 +57,9 @@ class LinuxConfig: sanitizers: list[str] = dataclasses.field(default_factory=list) suffix: str = "" extra_cmake_args: str = "" - image: str = "" # only used by package_configs entries + # The two below are only used by package_configs entries. + image: str = "" + package_type: str = "" # "deb" or "rpm"; has to match what image provides @dataclasses.dataclass @@ -156,7 +158,7 @@ class PackagingEntry: xrpld_artifact_name: str validator_keys_artifact_name: str image: str - distro: str # e.g. "debian" or "rhel"; drives package-format-specific steps + package_type: str # "deb" or "rpm"; drives the format-specific steps # --------------------------------------------------------------------------- @@ -243,7 +245,7 @@ def expand_linux_packaging(linux: LinuxFile) -> list[PackagingEntry]: xrpld_artifact_name=f"xrpld-{config_name}", validator_keys_artifact_name=f"validator-keys-{config_name}", image=cfg.image, - distro=distro, + package_type=cfg.package_type, ) ) diff --git a/.github/scripts/strategy-matrix/linux.json b/.github/scripts/strategy-matrix/linux.json index e739a42d5a..8450c3079e 100644 --- a/.github/scripts/strategy-matrix/linux.json +++ b/.github/scripts/strategy-matrix/linux.json @@ -92,7 +92,8 @@ "build_type": ["Release"], "arch": ["amd64"], "minimal": false, - "image": "ghcr.io/xrplf/xrpld/packaging-debian:sha-a6983f8" + "image": "ghcr.io/xrplf/xrpld/packaging-debian:sha-45e4b88", + "package_type": "deb" } ], @@ -102,7 +103,8 @@ "build_type": ["Release"], "arch": ["amd64"], "minimal": false, - "image": "ghcr.io/xrplf/xrpld/packaging-rhel:sha-a6983f8" + "image": "ghcr.io/xrplf/xrpld/packaging-rhel:sha-45e4b88", + "package_type": "rpm" } ] } diff --git a/.github/workflows/reusable-package.yml b/.github/workflows/reusable-package.yml index cfae706ee1..4d1968b93c 100644 --- a/.github/workflows/reusable-package.yml +++ b/.github/workflows/reusable-package.yml @@ -1,9 +1,9 @@ # Build Linux packages from the pre-built xrpld and validator-keys artifacts: # # - one job per distro, taken from "package_configs" in linux.json -# - each job runs in that distro's container, which is what decides DEB or RPM +# - each entry names its container image and the format it builds there # - with 'publish: true' a job also uploads what it built -# (see package/publish_pkg.sh) +# (see package/publish_pkg.py) # # Only linux/amd64 is supported; the runner is hardcoded in the job below. name: Package @@ -97,17 +97,23 @@ jobs: - name: Build package env: + PACKAGE_TYPE: ${{ matrix.package_type }} PKG_RELEASE: ${{ steps.release_info.outputs.pkg_release }} - PKG_CHANNEL: ${{ steps.release_info.outputs.channel }} - run: ./package/build_pkg.sh + CHANNEL: ${{ steps.release_info.outputs.channel }} + run: | + ./package/build_pkg.py \ + --package-type "${PACKAGE_TYPE}" \ + --build-dir "${BUILD_DIR}" \ + --pkg-release "${PKG_RELEASE}" \ + --channel "${CHANNEL}" # Before the upload, so the artifact and the published package are the # same bytes. DEBs are not signed, so the key is never set on that job. - name: Sign RPM - if: ${{ inputs.publish && matrix.distro == 'rhel' }} + if: ${{ inputs.publish && matrix.package_type == 'rpm' }} env: PKG_SIGNING_KEY: ${{ secrets.signing_key }} - run: ./package/sign_rpm.sh "${BUILD_DIR}" + run: ./package/sign_rpm.py --package-dir "${BUILD_DIR}" - name: Upload package artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -126,4 +132,8 @@ jobs: NEXUS_URL: ${{ inputs.nexus_url }} NEXUS_USERNAME: ${{ secrets.remote_username }} NEXUS_PASSWORD: ${{ secrets.remote_password }} - run: ./package/publish_pkg.sh "${CHANNEL}" "${BUILD_DIR}" + run: | + ./package/publish_pkg.py \ + --channel "${CHANNEL}" \ + --package-dir "${BUILD_DIR}" \ + --nexus-url "${NEXUS_URL}" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e5e69759fd..f223ab1684 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -82,11 +82,27 @@ repos: - id: prettier args: [--end-of-line=auto] + # Scoped to package/: the rest of the repo's Python has pre-existing findings, + # so widening these is its own change. + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: 7c55798a78262d14b2074abf623d8a992ebb70d4 # frozen: v0.16.2 + hooks: + - id: ruff-check + args: [--fix] + files: ^package/.*\.py$ + - repo: https://github.com/psf/black-pre-commit-mirror rev: 4160603246a6b365d4a2af661c6d71b0a0f50478 # frozen: 26.5.1 hooks: - id: black + - repo: https://github.com/pre-commit/mirrors-mypy + rev: 41e691678310dfd3833f7ab4e180ddb014310356 # frozen: v2.3.0 + hooks: + - id: mypy + args: [--strict] + files: ^package/.*\.py$ + - repo: https://github.com/scop/pre-commit-shfmt rev: 05c1426671b9237fb5e1444dd63aa5731bec0dfb # frozen: v3.13.1-1 hooks: diff --git a/cmake/XrplPackaging.cmake b/cmake/XrplPackaging.cmake index bee7b15791..c454f487dc 100644 --- a/cmake/XrplPackaging.cmake +++ b/cmake/XrplPackaging.cmake @@ -1,7 +1,7 @@ #[===================================================================[ Linux packaging support: 'package' target. - The packaging script (package/build_pkg.sh) installs to FHS-standard + The packaging script (package/build_pkg.py) installs to FHS-standard paths (/usr/bin, /etc/xrpld, etc.) regardless of CMAKE_INSTALL_PREFIX, so no prefix guard is needed here. #]===================================================================] @@ -38,19 +38,19 @@ if(NOT TARGET validator-keys) return() endif() -set(package_env - SRC_DIR=${CMAKE_SOURCE_DIR} - BUILD_DIR=${CMAKE_BINARY_DIR} - PKG_RELEASE=${pkg_release} -) +if(DPKG_BUILDPACKAGE_EXECUTABLE) + set(pkg_type deb) +else() + set(pkg_type rpm) +endif() add_custom_target( package COMMAND - ${CMAKE_COMMAND} -E env ${package_env} - ${CMAKE_SOURCE_DIR}/package/build_pkg.sh + ${CMAKE_SOURCE_DIR}/package/build_pkg.py --package-type ${pkg_type} + --build-dir ${CMAKE_BINARY_DIR} --pkg-release ${pkg_release} WORKING_DIRECTORY ${CMAKE_BINARY_DIR} DEPENDS xrpld validator-keys - COMMENT "Building Linux package (deb/rpm inferred from host tooling)" + COMMENT "Building Linux ${pkg_type} package" VERBATIM ) diff --git a/package/README.md b/package/README.md index 54b1e57204..04f4db2ea5 100644 --- a/package/README.md +++ b/package/README.md @@ -8,9 +8,9 @@ a build configured with `-Dvalidator_keys=ON`. ``` package/ - build_pkg.sh Staging and build script (called by the CMake `package` target and CI) - sign_rpm.sh Signs the built RPMs (called by CI when publishing) - publish_pkg.sh Uploads built packages to the XRPLF Nexus repositories (called by CI) + build_pkg.py Staging and build script (called by the CMake `package` target and CI) + sign_rpm.py Signs the built RPMs (called by CI when publishing) + publish_pkg.py Uploads built packages to the XRPLF Nexus repositories (called by CI) rpm/ xrpld.spec RPM spec debian/ Debian control files (control, rules, copyright, xrpld.docs, xrpld.links, source/format) @@ -28,8 +28,9 @@ Packaging targets and their container images are declared in under `package_configs`, one entry per distro. Today only `linux/amd64` is emitted. Each entry pins its full container image in an `image` field; to move to a new image, edit that field and both CI and local builds pick it up. The -package format (deb or rpm) is inferred at build time from the container's -package manager (`apt-get` -> deb, `dnf`/`yum` -> rpm). +entry also declares the format that image builds in a `package_type` field, +which CI passes to `build_pkg.py` as `--package-type`; the two have to stay in +step. | Package type | Image (`package_configs.[].image` in `linux.json`) | Tools required | | ------------ | ---------------------------------------------------------- | --------------------------------------------------- | @@ -51,10 +52,10 @@ Caller workflows (`on-pr.yml`, `on-tag.yml`, `on-trigger.yml`) call `reusable-package.yml`. That workflow generates its own packaging matrix from `package_configs` in `linux.json` (via `generate.py --packaging`) and fans out one job per distro. Each job downloads the pre-built `xrpld` and `validator-keys` -binary artifacts and runs in that distro's container, so the package format -follows from the container's package manager. The packaging script derives the -package version from the downloaded binary's `xrpld --version` output; no CMake -configure or build step is needed inside the packaging job. +binary artifacts and runs in that distro's container, building the format its +`package_type` declares. The packaging script derives the package version from +the downloaded binary's `xrpld --version` output; no CMake configure or build +step is needed inside the packaging job. The binaries come from the `debian` and `rhel` build configurations in `linux.json`'s `configs` section, which pass `-Dvalidator_keys=ON` so that the @@ -75,9 +76,8 @@ The image tag is derived from `linux.json` so you don't need to hardcode a SHA. ```bash # From the repo root. Each distro's container image is the `image` field of its -# package_configs entry in linux.json; the package format is inferred from the -# container's package manager. Example for the rpm-producing image (use -# .package_configs.debian[0].image for the deb image): +# package_configs entry in linux.json. Example for the rpm-producing image (use +# .package_configs.debian[0].image and --package-type deb for the other one): IMAGE=$(jq -r '.package_configs.rhel[0].image' .github/scripts/strategy-matrix/linux.json) PKG_RELEASE=1 @@ -86,7 +86,7 @@ docker run --rm \ -v "$(pwd):/src" \ -w /src \ "${IMAGE}" \ - ./package/build_pkg.sh --pkg-release "${PKG_RELEASE}" + ./package/build_pkg.py --package-type rpm --pkg-release "${PKG_RELEASE}" # Output: # build/debbuild/*.deb (DEB + dbgsym; Debian names both .deb) @@ -113,12 +113,12 @@ cmake --build . --target package # deb on Debian/Ubuntu, rpm on RHEL The `cmake/XrplPackaging.cmake` module defines the `package` target only if at least one of `rpmbuild` / `dpkg-buildpackage` is present and both the `xrpld` and `validator-keys` targets exist (`-Dxrpld=ON -Dvalidator_keys=ON`); the target -builds both binaries before packaging. `build_pkg.sh` then infers the package -format from the host's package manager. The packaging script installs to +builds both binaries before packaging, passing `--package-type deb` when +`dpkg-buildpackage` is present and `rpm` otherwise. The packaging script installs to FHS-standard paths (`/usr/bin`, `/etc/xrpld`, etc.) regardless of `CMAKE_INSTALL_PREFIX`. -The package version is not a CMake input on this path: `build_pkg.sh` derives it +The package version is not a CMake input on this path: `build_pkg.py` derives it from the just-built `xrpld` binary's `xrpld --version` output. The package release defaults to 1 and is overridable with `-Dpkg_release=N`. @@ -126,7 +126,7 @@ release defaults to 1 and is overridable with `-Dpkg_release=N`. Packages are published to the XRPLF repositories on Sonatype Nexus at `https://packages.xrplf.org`. The `release-info` action decides the channel from -the event, and `publish_pkg.sh` maps that channel to its repositories: +the event, and `publish_pkg.py` maps that channel to its repositories: | Event | Version | Channel | DEB repository | RPM upload repository | | ------------------------ | ----------------- | -------------- | ------------------ | ------------------------- | @@ -162,7 +162,7 @@ Nexus owns the repository metadata; nothing here indexes anything. Worth knowing repository sits behind a `rpm-` yum group repository whose metadata Nexus signs. Uploads go to the hosted repository; clients point at the group and verify the metadata with `repo_gpgcheck=1`. Nexus never signs the RPMs - themselves, so `sign_rpm.sh` signs them before they are uploaded, and clients + themselves, so `sign_rpm.py` signs them before they are uploaded, and clients verify them with `gpgcheck=1`. - yum metadata is rebuilt asynchronously, so a successful publish is not immediately installable. @@ -172,20 +172,20 @@ Nexus owns the repository metadata; nothing here indexes anything. Worth knowing - The `develop` repositories gain a package per push, so they need a cleanup policy to stay bounded; tagged channels publish each version once. -## How `build_pkg.sh` works +## How `build_pkg.py` works -`build_pkg.sh` derives the `xrpld` software version from +`build_pkg.py` derives the `xrpld` software version from `${BUILD_DIR}/xrpld --version` in both package formats. The binary's version is already SemVer-validated by `BuildInfo`. -`build_pkg.sh` converts pre-release versions such as `3.2.0-b1` or +`build_pkg.py` converts pre-release versions such as `3.2.0-b1` or `3.2.0-rc1` from `-` to `~` for package metadata so pre-releases sort before the final release. If that normalized package version still contains `-`, packaging fails because RPM forbids `-` in `Version`, and Debian uses `-` as the upstream/revision separator. `pkg_version` is the normalized package metadata version derived inside -`build_pkg.sh` from the binary-reported `xrpld` version (`-` pre-release +`build_pkg.py` from the binary-reported `xrpld` version (`-` pre-release separator converted to `~`). It is not a separate user input. `PKG_RELEASE` is a different value: the package release iteration for that @@ -203,35 +203,39 @@ With `PKG_RELEASE=1`, the package metadata becomes: | `3.2.0-b1` | `3.2.0~b1-1%{?dist}` | `3.2.0~b1-1` | | `3.2.0-rc1` | `3.2.0~rc1-1%{?dist}` | `3.2.0~rc1-1` | -The Debian changelog entry carries the channel passed as `--channel` -(`PKG_CHANNEL`), defaulting to `unstable`. An unsupported pre-release, and build -metadata on a final release such as `3.2.0+abc123`, are both rejected. +`build_pkg.py` defines `dist` as `.el9` rather than letting rpmbuild take it +from the build host, so the RHEL image can track a newer release without +changing what the packages claim to target. + +The Debian changelog entry carries the channel passed as `--channel`, +defaulting to `unstable`. An unsupported pre-release, and build metadata on a +final release such as `3.2.0+abc123`, are both rejected. The RPM path intentionally uses `~` in `Version`, matching the Debian pre-release ordering convention, so RPM filenames/NVRs begin with forms like `xrpld-3.2.0~b1-...` and `xrpld-3.2.0~rc1-...` instead of encoding pre-releases with an older `0..` RPM `Release` value. -The package format (`deb` or `rpm`) is inferred from the host's package -manager (`apt-get` -> deb, `dnf`/`yum` -> rpm). Hosts without one of those -fail early. +The package format is `--package-type`, either `deb` or `rpm`. It is required, +so a job never silently builds the wrong format for the image it runs in; the +matching build tool still has to be on PATH. -Flags are for explicit invocation; environment variables are intended for -CMake/CI integration. The CI workflow and the CMake `package` target both invoke -`build_pkg.sh` with no flags; CMake supplies `SRC_DIR`, `BUILD_DIR`, and -`PKG_RELEASE` via env, while CI supplies `BUILD_DIR`, `PKG_RELEASE` and -`PKG_CHANNEL` via env and lets the script use defaults for the rest. +Every input is a named argument. CMake passes `--package-type`, `--build-dir` +and `--pkg-release`; CI adds `--channel`. The repository root is not an argument +at all: the script reads it from its own location. Only secrets stay in the +environment, so they never reach the process list -- `PKG_SIGNING_KEY` for +`sign_rpm.py`, and `NEXUS_USERNAME` / `NEXUS_PASSWORD` for `publish_pkg.py`. -Signing is not part of this script. `sign_rpm.sh` does it in a separate CI step +Signing is not part of this script. `sign_rpm.py` does it in a separate CI step that only runs when publishing, so a published RPM is always signed and a local build never needs a key. -It resolves `SRC_DIR` and `BUILD_DIR` to absolute paths, then calls +It resolves the build directory to an absolute path, then calls `stage_common()` to copy the `xrpld` and `validator-keys` binaries, config files, and shared support files into the staging area, and invokes the platform build -tool. Both binaries must be present in `BUILD_DIR` and must run in the packaging -environment; a missing or non-runnable one fails early. That runtime check is -what catches a binary still linked against the Nix store's ELF loader (see +tool. Both binaries must be present in the build directory and must run in the +packaging environment; a missing or non-runnable one fails early. That runtime +check is what catches a binary still linked against the Nix store's ELF loader (see `patch_nix_binary` in `cmake/PatchNixBinary.cmake`). ### RPM @@ -277,10 +281,9 @@ lintian -I debbuild/*.deb ## Reproducibility -`build_pkg.sh` already defaults `SOURCE_DATE_EPOCH` to the latest git commit -time, or the current time outside a git tree, and exports it (override with -`--source-date-epoch` / `SOURCE_DATE_EPOCH`); the RPM spec clamps file -modification times to it via `%build_mtime_policy`. The remaining variables +`build_pkg.py` sets `SOURCE_DATE_EPOCH` from the latest git commit time and +exports it; the RPM spec clamps file modification times to it via +`%build_mtime_policy`. The remaining variables below further improve reproducibility but are _not_ set by the script — export them yourself if needed: diff --git a/package/build_pkg.py b/package/build_pkg.py new file mode 100755 index 0000000000..28835d1ccd --- /dev/null +++ b/package/build_pkg.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +"""Build an RPM or Debian package from the pre-built xrpld and validator-keys binaries. + +The build tool for the chosen format has to be on PATH, so this runs in the +vanilla distro image that matches it. +""" + +from __future__ import annotations + +import argparse +import os +import re +import shutil +import subprocess +import textwrap +from datetime import datetime, timezone +from pathlib import Path + +# This script lives in the repository it packages. +SRC_DIR = Path(__file__).resolve().parents[1] + +PRE_RELEASE = re.compile(r"^(b0|b[1-9][0-9]*|rc[0-9]+)(\+.*)?$") + +# Files both packaging systems consume, staged under the same names. +STAGED_FROM_BUILD = ("xrpld", "validator-keys", "validator-keys-LICENSE") +STAGED_FROM_SRC = { + "cfg/xrpld-example.cfg": "xrpld.cfg", + "cfg/validators-example.txt": "validators.txt", + "LICENSE.md": "LICENSE.md", + "README.md": "README.md", +} +STAGED_UNITS = ("xrpld.service", "xrpld.sysusers", "xrpld.tmpfiles", "xrpld.logrotate") + + +def run(*command: object, cwd: Path | None = None) -> None: + """Echo a command and run it.""" + argv = [str(part) for part in command] + print("+ " + " ".join(argv), flush=True) + subprocess.run(argv, check=True, cwd=cwd) + + +def capture(*command: object) -> str: + """Run a command and return its stdout, stripped.""" + argv = [str(part) for part in command] + # stderr is left alone so a failing command explains itself. + return subprocess.run( + argv, stdout=subprocess.PIPE, text=True, check=True + ).stdout.strip() + + +def package_version(reported: str) -> str: + """Normalise a reported version into one the package formats accept. + + A pre-release switches to '~' (3.2.0-b1 -> 3.2.0~b1), which also sorts before + the final 3.2.0; a no-op for a final release. + """ + base, _, pre_release = reported.partition("-") + version = f"{base}~{pre_release}" if pre_release else base + + # BuildInfo already SemVer-validates the version. Packaging adds one narrower + # constraint: after normalisation the version must not contain '-', because + # RPM forbids it in Version and Debian reads it as the revision separator. + assert "-" not in version, ( + f"unsupported version {reported!r}: {version!r} cannot contain '-'. " + "Use a single-token pre-release like 3.2.0-b1 or 3.2.0-rc2." + ) + assert pre_release or "+" not in reported, ( + f"unsupported version {reported!r}: " + "build metadata is only supported on bN/rcN pre-releases." + ) + assert not pre_release or PRE_RELEASE.match(pre_release), ( + f"unsupported pre-release {pre_release!r}: use bN or rcN, " + "e.g. 3.2.0-b1 or 3.2.0-rc2." + ) + return version + + +def read_version(xrpld: Path) -> str: + """Read the version from the binary that is about to be packaged.""" + fields = capture(xrpld, "--version").partition("\n")[0].split() + assert len(fields) >= 3, f"cannot read a version from {xrpld} --version" + return fields[2] + + +def check_binaries(build_dir: Path) -> None: + """Fail unless the binaries and their notices are present and runnable.""" + missing = [ + name + for name in ("xrpld", "validator-keys") + if not os.access(build_dir / name, os.X_OK) + ] + assert not missing, ( + f"missing or not executable in {build_dir}: {' '.join(missing)}. " + "Both binaries come from a single CMake build directory configured with " + "-Dxrpld=ON -Dvalidator_keys=ON." + ) + + # No package goes out without the attribution. + notice = build_dir / "validator-keys-LICENSE" + assert notice.is_file(), ( + f"missing {notice}. cmake/XrplValidatorKeys.cmake copies it out of the " + "fetched validator-keys-tool source, so reconfigure with -Dvalidator_keys=ON." + ) + + # Catches a binary still pointing at the Nix store's ELF loader, since + # packaging runs in a vanilla distro container. + capture(build_dir / "validator-keys", "--version") + + +def source_date_epoch() -> int: + """The last commit's timestamp.""" + # git refuses to read a checkout owned by another user, which is what a CI + # container or a bind mount hands it. + return int( + capture( + "git", + "-c", + f"safe.directory={SRC_DIR}", + "-C", + SRC_DIR, + "log", + "-1", + "--format=%ct", + ) + ) + + +def stage_common(build_dir: Path, dest: Path) -> None: + """Copy everything both packaging systems consume into dest.""" + dest.mkdir(parents=True, exist_ok=True) + + for name in STAGED_FROM_BUILD: + shutil.copy2(build_dir / name, dest / name) + for source, name in STAGED_FROM_SRC.items(): + shutil.copy2(SRC_DIR / source, dest / name) + for name in STAGED_UNITS: + shutil.copy2(SRC_DIR / "package" / "shared" / name, dest / name) + + +def build_rpm(build_dir: Path, *, version: str, pkg_release: str) -> None: + """Stage the spec and its sources, then build the binary RPMs.""" + topdir = build_dir / "rpmbuild" + for name in ("BUILD", "BUILDROOT", "RPMS", "SOURCES", "SPECS", "SRPMS"): + (topdir / name).mkdir(parents=True, exist_ok=True) + + spec = topdir / "SPECS" / "xrpld.spec" + shutil.copy2(SRC_DIR / "package" / "rpm" / "xrpld.spec", spec) + stage_common(build_dir, topdir / "SOURCES") + + run( + "rpmbuild", + "-bb", + "--define", + f"_topdir {topdir}", + "--define", + f"pkg_version {version}", + "--define", + f"pkg_release {pkg_release}", + # The image tracks the newest distro, but the packages target el9. + "--define", + "dist .el9", + spec, + ) + + +def build_deb( + build_dir: Path, + *, + version: str, + reported: str, + pkg_release: str, + channel: str, + epoch: int, +) -> None: + """Stage the debian directory and its sources, then build the binary DEBs.""" + staging = build_dir / "debbuild" / "source" + stage_common(build_dir, staging) + shutil.copytree(SRC_DIR / "package" / "debian", staging / "debian") + + # debhelper picks these up from debian/ automatically. + for name in STAGED_UNITS: + shutil.copy2(staging / name, staging / "debian" / name) + + date = datetime.fromtimestamp(epoch, timezone.utc).strftime( + "%a, %d %b %Y %H:%M:%S %z" + ) + # The leading spaces are significant to dpkg. + changelog = textwrap.dedent(f"""\ + xrpld ({version}-{pkg_release}) {channel}; urgency=medium + * Release {reported}. + + -- XRPL Foundation {date} + """) + (staging / "debian" / "changelog").write_text(changelog) + + (staging / "debian" / "rules").chmod(0o755) + + run("dpkg-buildpackage", "-b", "--no-sign", "-d", cwd=staging) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--package-type", + required=True, + choices=("deb", "rpm"), + help="the package format to build", + ) + parser.add_argument( + "--build-dir", + type=Path, + default=Path("build"), + help="directory holding the xrpld and validator-keys binaries (default: %(default)s)", + ) + parser.add_argument( + "--pkg-release", + default="1", + help="package release iteration (default: %(default)s)", + ) + parser.add_argument( + "--channel", + default="unstable", + help="release channel, written to debian/changelog (default: %(default)s)", + ) + args = parser.parse_args() + package_type: str = args.package_type + build_dir: Path = args.build_dir.resolve() + pkg_release: str = args.pkg_release + channel: str = args.channel + + assert build_dir.is_dir(), ( + f"build directory not found: {build_dir}. Build the binaries before " + "packaging, or point --build-dir at the directory holding them." + ) + + check_binaries(build_dir) + reported = read_version(build_dir / "xrpld") + version = package_version(reported) + epoch = source_date_epoch() + + # rpmbuild and dpkg-buildpackage both honour this for file timestamps. + os.environ["SOURCE_DATE_EPOCH"] = str(epoch) + + # Remove both build trees, because a package left from an earlier build would + # otherwise be picked up and published alongside this one. + for tree in ("debbuild", "rpmbuild"): + shutil.rmtree(build_dir / tree, ignore_errors=True) + + if package_type == "deb": + build_deb( + build_dir, + version=version, + reported=reported, + pkg_release=pkg_release, + channel=channel, + epoch=epoch, + ) + else: + build_rpm(build_dir, version=version, pkg_release=pkg_release) + + +if __name__ == "__main__": + main() diff --git a/package/build_pkg.sh b/package/build_pkg.sh deleted file mode 100755 index cca3be7248..0000000000 --- a/package/build_pkg.sh +++ /dev/null @@ -1,252 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Build an RPM or Debian package from the pre-built xrpld and validator-keys -# binaries. -# -# Flags override env vars; env vars override defaults. - -usage() { - cat <<'EOF' -Usage: build_pkg.sh [options] - -Options (each can also be set via the env var shown): - --src-dir DIR repo root [SRC_DIR; default: ${PWD}] - --build-dir DIR directory holding the - xrpld and validator-keys - binaries [BUILD_DIR; default: ${PWD}/build] - --pkg-release N package release iteration [PKG_RELEASE; default: 1] - --channel NAME release channel, written - to debian/changelog [PKG_CHANNEL; default: unstable] - --source-date-epoch SECS reproducibility timestamp [SOURCE_DATE_EPOCH; latest git ctime; fallback: current time] - -h, --help show this help and exit -EOF -} - -need_arg() { - if [[ $# -lt 2 || "$2" == --* ]]; then - echo "Missing value for $1" >&2 - exit 2 - fi -} - -# Seed from env. CLI parsing below overrides these directly. -SRC_DIR="${SRC_DIR:-}" -BUILD_DIR="${BUILD_DIR:-}" -PKG_RELEASE="${PKG_RELEASE:-1}" -PKG_CHANNEL="${PKG_CHANNEL:-unstable}" -SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-}" - -while [[ $# -gt 0 ]]; do - case "$1" in - --src-dir) - need_arg "$@" - SRC_DIR="$2" - shift 2 - ;; - --build-dir) - need_arg "$@" - BUILD_DIR="$2" - shift 2 - ;; - --pkg-release) - need_arg "$@" - PKG_RELEASE="$2" - shift 2 - ;; - --channel) - need_arg "$@" - PKG_CHANNEL="$2" - shift 2 - ;; - --source-date-epoch) - need_arg "$@" - SOURCE_DATE_EPOCH="$2" - shift 2 - ;; - -h | --help) - usage - exit 0 - ;; - *) - echo "Unknown argument: $1" >&2 - usage >&2 - exit 2 - ;; - esac -done - -SRC_DIR="$(cd "${SRC_DIR:-${PWD}}" && pwd)" -BUILD_DIR="${BUILD_DIR:-${PWD}/build}" -if [[ ! -d "${BUILD_DIR}" ]]; then - echo "build_pkg.sh: build directory not found: ${BUILD_DIR}" >&2 - echo "Build the binaries before packaging, or set BUILD_DIR to the directory containing them." >&2 - exit 1 -fi -BUILD_DIR="$(cd "${BUILD_DIR}" && pwd)" - -xrpld_binary="${BUILD_DIR}/xrpld" -validator_keys_binary="${BUILD_DIR}/validator-keys" - -# Report both binaries at once: they share a single BUILD_DIR, so telling the -# reader to point it at one of them in isolation is advice they cannot follow. -missing=() -[[ -x "${xrpld_binary}" ]] || missing+=(xrpld) -[[ -x "${validator_keys_binary}" ]] || missing+=(validator-keys) - -if [[ ${#missing[@]} -gt 0 ]]; then - echo "build_pkg.sh: missing or not executable in ${BUILD_DIR}: ${missing[*]}" >&2 - echo "Both binaries come from a single CMake build directory configured with" >&2 - echo "-Dxrpld=ON -Dvalidator_keys=ON. Build them, then point BUILD_DIR at that" >&2 - echo "directory." >&2 - exit 1 -fi - -# Shipping validator-keys means shipping its notice, so treat it as required -# rather than letting a package go out without the attribution. -validator_keys_license="${BUILD_DIR}/validator-keys-LICENSE" -if [[ ! -f "${validator_keys_license}" ]]; then - echo "build_pkg.sh: missing ${validator_keys_license}." >&2 - echo "cmake/XrplValidatorKeys.cmake copies it out of the fetched" >&2 - echo "validator-keys-tool source, so reconfigure with -Dvalidator_keys=ON." >&2 - exit 1 -fi - -# The binary must also *run* here. Packaging happens in a vanilla distro -# container, so this is what catches a binary still pointing at the Nix store's -# ELF loader (see patch_nix_binary in cmake/PatchNixBinary.cmake); xrpld is -# covered implicitly by the version query below. -if ! "${validator_keys_binary}" --version >/dev/null; then - echo "build_pkg.sh: ${validator_keys_binary} exists but does not run here." >&2 - exit 1 -fi - -xrpld_version="$("${xrpld_binary}" --version | awk 'NR == 1 { print $3 }')" - -if [[ -z "${xrpld_version}" ]]; then - echo "build_pkg.sh: unable to derive xrpld version from ${xrpld_binary} --version." >&2 - exit 1 -fi - -# The version as the package formats consume it: identical to xrpld_version -# except a pre-release uses '~' (3.2.0-b1 -> 3.2.0~b1), which also sorts before -# the final 3.2.0; a no-op for a final release. Lowercase = derived internally, -# not an input (cf. pkg_type). -pkg_version="${xrpld_version}" -pre_release="" -if [[ "${xrpld_version}" == *-* ]]; then - pre_release="${xrpld_version#*-}" - pkg_version="${xrpld_version%%-*}~${pre_release}" -fi - -# BuildInfo already SemVer-validates the binary's version. Packaging adds one -# narrower constraint: after pre-release normalization, the package version must -# not contain '-' because RPM forbids it in Version and Debian uses it as the -# upstream/revision separator. -if [[ "${pkg_version}" == *-* ]]; then - echo "build_pkg.sh: unsupported xrpld version '${xrpld_version}'." >&2 - echo "Package version '${pkg_version}' cannot contain '-'." >&2 - echo "Use a single-token pre-release like 3.2.0-b1 or 3.2.0-rc2." >&2 - exit 1 -fi - -if [[ -z "${pre_release}" && "${xrpld_version}" == *+* ]]; then - echo "build_pkg.sh: unsupported xrpld version '${xrpld_version}'." >&2 - echo "Build metadata is only supported on bN/rcN pre-releases." >&2 - exit 1 -fi - -if [[ -n "${pre_release}" && ! "${pre_release}" =~ ^(b0|b[1-9][0-9]*|rc[0-9]+)(\+.*)?$ ]]; then - echo "build_pkg.sh: unsupported xrpld pre-release '${pre_release}'." >&2 - echo "Use bN or rcN, e.g. 3.2.0-b1 or 3.2.0-rc2." >&2 - exit 1 -fi - -if command -v apt-get >/dev/null 2>&1; then - pkg_type=deb -elif command -v dnf >/dev/null 2>&1 || command -v yum >/dev/null 2>&1; then - pkg_type=rpm -else - echo "Cannot infer pkg_type: no apt-get, dnf, or yum on PATH." >&2 - exit 1 -fi - -if [[ -z "${SOURCE_DATE_EPOCH}" ]]; then - if git -C "${SRC_DIR}" rev-parse --is-inside-work-tree >/dev/null 2>&1; then - SOURCE_DATE_EPOCH="$(git -C "${SRC_DIR}" log -1 --format=%ct)" - else - SOURCE_DATE_EPOCH="$(date +%s)" - fi -fi - -export SOURCE_DATE_EPOCH -CHANGELOG_DATE="$(date -u -R -d "@${SOURCE_DATE_EPOCH}")" - -SHARED="${SRC_DIR}/package/shared" -DEBIAN_DIR="${SRC_DIR}/package/debian" - -# Stage files that both packaging systems consume using the same filenames. -stage_common() { - local dest="$1" - mkdir -p "${dest}" - - cp "${xrpld_binary}" "${dest}/xrpld" - cp "${validator_keys_binary}" "${dest}/validator-keys" - cp "${validator_keys_license}" "${dest}/validator-keys-LICENSE" - cp "${SRC_DIR}/cfg/xrpld-example.cfg" "${dest}/xrpld.cfg" - cp "${SRC_DIR}/cfg/validators-example.txt" "${dest}/validators.txt" - cp "${SRC_DIR}/LICENSE.md" "${dest}/LICENSE.md" - cp "${SRC_DIR}/README.md" "${dest}/README.md" - - cp "${SHARED}/xrpld.service" "${dest}/xrpld.service" - cp "${SHARED}/xrpld.sysusers" "${dest}/xrpld.sysusers" - cp "${SHARED}/xrpld.tmpfiles" "${dest}/xrpld.tmpfiles" - cp "${SHARED}/xrpld.logrotate" "${dest}/xrpld.logrotate" -} - -build_rpm() { - local topdir="${BUILD_DIR}/rpmbuild" - mkdir -p "${topdir}"/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS} - - cp "${SRC_DIR}/package/rpm/xrpld.spec" "${topdir}/SPECS/xrpld.spec" - stage_common "${topdir}/SOURCES" - - set -x - rpmbuild -bb \ - --define "_topdir ${topdir}" \ - --define "pkg_version ${pkg_version}" \ - --define "pkg_release ${PKG_RELEASE}" \ - "${topdir}/SPECS/xrpld.spec" -} - -build_deb() { - local staging="${BUILD_DIR}/debbuild/source" - mkdir -p "${staging}" - - stage_common "${staging}" - cp -r "${DEBIAN_DIR}" "${staging}/debian" - - cp "${staging}/xrpld.service" "${staging}/debian/xrpld.service" - cp "${staging}/xrpld.sysusers" "${staging}/debian/xrpld.sysusers" - cp "${staging}/xrpld.tmpfiles" "${staging}/debian/xrpld.tmpfiles" - cp "${staging}/xrpld.logrotate" "${staging}/debian/xrpld.logrotate" - - # Debian version is [~
]-.
-    cat >"${staging}/debian/changelog" <  ${CHANGELOG_DATE}
-EOF
-
-    chmod +x "${staging}/debian/rules"
-
-    set -x
-    (cd "${staging}" && dpkg-buildpackage -b --no-sign -d)
-}
-
-# Remove both build directories, because a package left from an earlier build
-# would otherwise be picked up and published alongside this one.
-rm -rf "${BUILD_DIR}/debbuild" "${BUILD_DIR}/rpmbuild"
-
-"build_${pkg_type}"
diff --git a/package/publish_pkg.py b/package/publish_pkg.py
new file mode 100755
index 0000000000..2c320a595a
--- /dev/null
+++ b/package/publish_pkg.py
@@ -0,0 +1,153 @@
+#!/usr/bin/env python3
+"""Publish the packages built by build_pkg.py to the XRPLF repositories on Nexus.
+
+RPMs are uploaded to the hosted repository, but yum clients install from the
+'rpm-' group repository in front of it, which serves signed metadata.
+
+NEXUS_USERNAME and NEXUS_PASSWORD are read from the environment, so the
+credentials never reach the process list.
+"""
+
+import argparse
+import base64
+import os
+import time
+import urllib.error
+import urllib.request
+from pathlib import Path
+
+SUFFIXES = (".deb", ".ddeb", ".rpm")
+
+# No progress for this long ends an attempt. urlopen applies the timeout per
+# socket operation, so a stalled transfer fails while a merely slow one carries
+# on -- the debuginfo package is large enough for that distinction to matter.
+STALL_TIMEOUT = 300
+
+ATTEMPTS = 4
+RETRY_DELAY = 5
+
+
+def build_opener() -> urllib.request.OpenerDirector:
+    """An opener with no redirect handler, so a 3xx raises instead of being followed.
+
+    A redirected upload is silently downgraded to a GET, turning it into a no-op
+    that still answers 200.
+    """
+    opener = urllib.request.OpenerDirector()
+    opener.add_handler(urllib.request.HTTPHandler())
+    opener.add_handler(urllib.request.HTTPSHandler())
+    opener.add_handler(urllib.request.HTTPErrorProcessor())
+    opener.add_handler(urllib.request.HTTPDefaultErrorHandler())
+    return opener
+
+
+def upload(url: str, method: str, headers: dict[str, str], package: Path) -> None:
+    """Send one package, retrying only what is worth retrying.
+
+    A 4xx is a deterministic rejection, so it is reported at once rather than
+    re-sending the whole body three more times. Nexus explains what it rejected
+    in the response body, so that body is always surfaced.
+    """
+    opener = build_opener()
+
+    for attempt in range(1, ATTEMPTS + 1):
+        try:
+            with package.open("rb") as body:
+                request = urllib.request.Request(
+                    url,
+                    data=body,
+                    method=method,
+                    headers={**headers, "Content-Length": str(package.stat().st_size)},
+                )
+                opener.open(request, timeout=STALL_TIMEOUT)
+            return
+        except urllib.error.HTTPError as error:
+            detail = error.read().decode(errors="replace").strip()
+            reason = f"HTTP {error.code}: {detail}"
+            retryable = error.code >= 500
+        except (urllib.error.URLError, OSError) as error:
+            reason = str(error)
+            retryable = True
+
+        assert (
+            retryable and attempt < ATTEMPTS
+        ), f"upload of {package.name} failed: {reason}"
+        print(f"    attempt {attempt} failed ({reason}), retrying")
+        time.sleep(RETRY_DELAY)
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument(
+        "--channel",
+        required=True,
+        help="release channel, selecting the deb- and rpm--hosted repositories",
+    )
+    parser.add_argument(
+        "--package-dir",
+        type=Path,
+        default=Path("build"),
+        help=f"searched recursively for {', '.join(SUFFIXES)} (default: %(default)s)",
+    )
+    parser.add_argument(
+        "--nexus-url",
+        default="https://packages.xrplf.org",
+        help="the Nexus instance to publish to (default: %(default)s)",
+    )
+    parser.add_argument(
+        "--dry-run",
+        action="store_true",
+        help="list the uploads without performing them",
+    )
+    args = parser.parse_args()
+    channel: str = args.channel
+    package_dir: Path = args.package_dir
+    nexus_url: str = args.nexus_url
+    dry_run: bool = args.dry_run
+
+    nexus = nexus_url.rstrip("/")
+    deb_repo = f"deb-{channel}"
+    rpm_repo = f"rpm-{channel}-hosted"
+
+    auth: dict[str, str] = {}
+    if not dry_run:
+        username = os.environ.get("NEXUS_USERNAME")
+        password = os.environ.get("NEXUS_PASSWORD")
+        assert username and password, "NEXUS_USERNAME and NEXUS_PASSWORD are required"
+        token = base64.b64encode(f"{username}:{password}".encode()).decode()
+        auth = {"Authorization": f"Basic {token}"}
+
+    packages = sorted(
+        path
+        for path in package_dir.rglob("*")
+        if path.is_file() and path.suffix in SUFFIXES
+    )
+    # Uploading nothing would otherwise look like a successful publish.
+    assert packages, f"no packages found in {package_dir}"
+
+    print(f"Publishing {package_dir} to {deb_repo} and {rpm_repo} on {nexus}:")
+    for package in packages:
+        if package.suffix == ".rpm":
+            # yum repositories are addressed by path, and the arch comes from
+            # the name, e.g. xrpld-3.4.0-1.el9.x86_64.rpm.
+            destination = f"{rpm_repo}/{package.stem.rsplit('.', 1)[-1]}"
+            url = f"{nexus}/repository/{destination}/{package.name}"
+            method, content_type = "PUT", "application/octet-stream"
+        else:
+            # A raw body with a multipart Content-Type, POSTed to the repository
+            # root, is the documented upload for a hosted apt repository:
+            # https://help.sonatype.com/en/apt-repositories.html#deploying-packages-to-hosted-apt-repositories
+            destination = deb_repo
+            url = f"{nexus}/repository/{destination}/"
+            method, content_type = "POST", "multipart/form-data"
+
+        print(f"  {package.name} -> {destination}")
+        if not dry_run:
+            upload(url, method, {"Content-Type": content_type, **auth}, package)
+
+    verb = "would be published" if dry_run else "published"
+    print(f"{len(packages)} package(s) {verb}.")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/package/publish_pkg.sh b/package/publish_pkg.sh
deleted file mode 100755
index 8ea9b189f4..0000000000
--- a/package/publish_pkg.sh
+++ /dev/null
@@ -1,109 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-# Publish the DEB and RPM packages built by build_pkg.sh to the XRPLF package
-# repositories on Sonatype Nexus.
-#
-# Usage: publish_pkg.sh  [package-dir]
-#
-#   channel      release channel, selecting the 'deb-' and
-#                'rpm--hosted' repositories
-#   package-dir  searched recursively for *.deb, *.ddeb and *.rpm ('build' by
-#                default)
-#
-# RPMs are uploaded to the hosted repository, but yum clients install from the
-# 'rpm-' group repository in front of it, which serves signed metadata.
-#
-# NEXUS_USERNAME and NEXUS_PASSWORD are required. NEXUS_URL overrides the target
-# instance, and DRY_RUN=1 lists the uploads without performing them.
-
-channel="${1:-}"
-pkg_dir="${2:-build}"
-nexus_url="${NEXUS_URL:-https://packages.xrplf.org}"
-
-if [[ -z "${channel}" ]]; then
-    echo "usage: publish_pkg.sh  [package-dir]" >&2
-    exit 2
-fi
-
-deb_repo="deb-${channel}"
-rpm_repo="rpm-${channel}-hosted"
-
-if [[ -z "${DRY_RUN:-}" ]]; then
-    : "${NEXUS_USERNAME:?is required}" "${NEXUS_PASSWORD:?is required}"
-fi
-
-# Deliberate curl choices:
-#
-#   - no --fail, which would hide the response body where Nexus explains what it
-#     rejected
-#   - no --location, since curl downgrades a redirected POST to GET and turns an
-#     upload into a no-op that still answers 200
-#   - credentials on stdin, to keep them out of the process list
-upload() {
-    local url="$1"
-    shift
-    [[ -z "${DRY_RUN:-}" ]] || return 0
-
-    local body code status=0
-    body="$(mktemp)"
-    code="$(
-        printf 'user = %s:%s\n' "${NEXUS_USERNAME}" "${NEXUS_PASSWORD}" |
-            curl \
-                --config - \
-                --silent \
-                --show-error \
-                --retry 3 \
-                --retry-delay 5 \
-                --retry-all-errors \
-                --output "${body}" \
-                --write-out '%{http_code}' \
-                "$@" \
-                "${url}"
-    )" || status=$?
-
-    if [[ ${status} -ne 0 || ! "${code}" =~ ^2[0-9][0-9]$ ]]; then
-        echo "publish_pkg.sh: upload failed (curl ${status}, HTTP ${code}): ${url}" >&2
-        cat "${body}" >&2
-        echo >&2
-        rm -f "${body}"
-        exit 1
-    fi
-
-    rm -f "${body}"
-}
-
-echo "Publishing ${pkg_dir} to ${deb_repo} and ${rpm_repo} on ${nexus_url}:"
-
-count=0
-while IFS= read -r -d '' file; do
-    name="${file##*/}"
-    case "${name}" in
-        # A raw body with a multipart Content-Type, POSTed to the repository root,
-        # is the documented upload for a hosted apt repository:
-        # https://help.sonatype.com/en/apt-repositories.html#deploying-packages-to-hosted-apt-repositories
-        *.deb | *.ddeb)
-            echo "  ${name} -> ${deb_repo}"
-            upload "${nexus_url}/repository/${deb_repo}/" \
-                --header 'Content-Type: multipart/form-data' \
-                --data-binary "@${file}"
-            ;;
-        # yum repositories are addressed by path; the arch comes from the name.
-        *.rpm)
-            arch="${name%.rpm}"
-            arch="${arch##*.}"
-            echo "  ${name} -> ${rpm_repo}/${arch}"
-            upload "${nexus_url}/repository/${rpm_repo}/${arch}/${name}" \
-                --upload-file "${file}"
-            ;;
-    esac
-    count=$((count + 1))
-done < <(find "${pkg_dir}" -type f \( -name '*.deb' -o -name '*.ddeb' -o -name '*.rpm' \) -print0)
-
-# Uploading nothing would otherwise look like a successful publish.
-if [[ ${count} -eq 0 ]]; then
-    echo "publish_pkg.sh: no packages found in ${pkg_dir}." >&2
-    exit 1
-fi
-
-echo "${count} package(s) ${DRY_RUN:+would be }published."
diff --git a/package/sign_rpm.py b/package/sign_rpm.py
new file mode 100755
index 0000000000..05c719b710
--- /dev/null
+++ b/package/sign_rpm.py
@@ -0,0 +1,128 @@
+#!/usr/bin/env python3
+"""Sign the RPMs built by build_pkg.py.
+
+Nexus signs the yum repository metadata (via the 'rpm-' group
+repository), but never the packages themselves, so they carry their own
+signature. Clients verify the packages with gpgcheck=1 and the metadata with
+repo_gpgcheck=1.
+
+The DEBs are deliberately not signed: embedded DEB signatures exist (debsigs),
+but apt does not verify them by default and trusts the repository metadata,
+which Nexus signs, instead.
+
+PKG_SIGNING_KEY is read from the environment, so the key never reaches the
+process list.
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+import subprocess
+import tempfile
+from pathlib import Path
+
+# An RSA signature lands in the RSAHEADER tag, a DSA or EdDSA one in DSAHEADER,
+# so both are queried; checking only the first would reject a signed package.
+SIGNATURE_QUERY = "%{RSAHEADER:pgpsig}%{DSAHEADER:pgpsig}"
+UNSIGNED = "(none)(none)"
+
+
+def gpg(gnupghome: Path, *args: str, stdin: str | None = None) -> str:
+    """Run gpg against a throwaway keyring and return its stdout."""
+    return subprocess.run(
+        ["gpg", "--batch", "--quiet", *args],
+        input=stdin,
+        # stderr is left alone so a failing gpg explains itself.
+        stdout=subprocess.PIPE,
+        text=True,
+        check=True,
+        env={**os.environ, "GNUPGHOME": str(gnupghome)},
+    ).stdout
+
+
+def import_key(gnupghome: Path, key: str) -> str:
+    """Import the armoured private key and return its fingerprint."""
+    gpg(gnupghome, "--import", stdin=key)
+
+    records = [
+        line.split(":")
+        for line in gpg(gnupghome, "--list-secret-keys", "--with-colons").splitlines()
+    ]
+    # Exactly one, so the fingerprint picked below is not a guess.
+    secrets = [record for record in records if record[0] == "sec"]
+    assert (
+        len(secrets) == 1
+    ), f"PKG_SIGNING_KEY must hold exactly one secret key, found {len(secrets)}"
+
+    # The first fingerprint belongs to the primary key; subkeys follow.
+    fingerprints = [record[9] for record in records if record[0] == "fpr"]
+    assert fingerprints, "PKG_SIGNING_KEY holds a secret key with no fingerprint"
+    return fingerprints[0]
+
+
+def sign(gnupghome: Path, rpms: list[Path], fingerprint: str) -> None:
+    """Attach a signature to every RPM in one rpmsign invocation."""
+    subprocess.run(
+        [
+            "rpmsign",
+            "--define",
+            f"_gpg_name {fingerprint}",
+            # Loopback pinentry: the key is unattended, so there is no tty to
+            # prompt on.
+            "--define",
+            "_gpg_sign_cmd_extra_args --pinentry-mode loopback --batch --yes",
+            "--addsign",
+            *(str(rpm) for rpm in rpms),
+        ],
+        check=True,
+        env={**os.environ, "GNUPGHOME": str(gnupghome)},
+    )
+
+
+def verify(rpms: list[Path]) -> None:
+    """Fail unless every RPM now carries a signature.
+
+    rpmsign can exit 0 having attached nothing, and an unsigned package is only
+    rejected later, on the installing machine.
+    """
+    for rpm in rpms:
+        signature = subprocess.run(
+            ["rpm", "--query", "--queryformat", SIGNATURE_QUERY, "--package", str(rpm)],
+            stdout=subprocess.PIPE,
+            text=True,
+            check=True,
+        ).stdout.strip()
+        assert signature != UNSIGNED, f"{rpm} is unsigned after rpmsign"
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument(
+        "--package-dir",
+        type=Path,
+        default=Path("build"),
+        help="searched recursively for *.rpm (default: %(default)s)",
+    )
+    args = parser.parse_args()
+    package_dir: Path = args.package_dir
+
+    rpms = sorted(path for path in package_dir.rglob("*.rpm") if path.is_file())
+    # Signing nothing would otherwise look like a successful signing.
+    assert rpms, f"no RPMs found in {package_dir}"
+
+    key = os.environ.get("PKG_SIGNING_KEY")
+    assert key, "PKG_SIGNING_KEY is required"
+
+    # The keyring holds an unencrypted private key, so it goes even if signing
+    # fails.
+    with tempfile.TemporaryDirectory() as tmp:
+        gnupghome = Path(tmp)
+        fingerprint = import_key(gnupghome, key)
+        print(f"Signing {len(rpms)} RPM(s) with {fingerprint}.")
+        sign(gnupghome, rpms, fingerprint)
+        verify(rpms)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/package/sign_rpm.sh b/package/sign_rpm.sh
deleted file mode 100755
index 250e806dd7..0000000000
--- a/package/sign_rpm.sh
+++ /dev/null
@@ -1,67 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-# Sign the RPMs built by build_pkg.sh. Nexus signs the yum repository metadata
-# (via the 'rpm-' group repository), but never the packages themselves,
-# so they carry their own signature. Clients verify the packages with gpgcheck=1
-# and the metadata with repo_gpgcheck=1.
-#
-# Usage: sign_rpm.sh [package-dir]
-#
-#   package-dir  searched recursively for *.rpm ('build' by default)
-#
-# PKG_SIGNING_KEY must hold an armoured PGP private key. It has no flag, to keep
-# the key out of the process list.
-#
-# The DEBs are deliberately not signed: embedded DEB signatures exist (debsigs),
-# but apt does not verify them by default and trusts the repository metadata,
-# which Nexus signs, instead.
-
-pkg_dir="${1:-build}"
-
-mapfile -d '' rpms < <(find "${pkg_dir}" -type f -name '*.rpm' -print0)
-
-# Signing nothing would otherwise look like a successful signing.
-if [[ ${#rpms[@]} -eq 0 ]]; then
-    echo "sign_rpm.sh: no RPMs found in ${pkg_dir}." >&2
-    exit 1
-fi
-
-: "${PKG_SIGNING_KEY:?is required}"
-
-# Global, and expanded by the trap when it fires: the keyring holds an
-# unencrypted private key, so it must go even if signing fails.
-signing_home="$(mktemp -d)"
-trap 'rm -rf "${signing_home}"' EXIT
-export GNUPGHOME="${signing_home}"
-
-printf '%s' "${PKG_SIGNING_KEY}" | gpg --batch --quiet --import
-
-# Exactly one secret key, so that picking the first below is not a guess between
-# several.
-secrets="$(gpg --list-secret-keys --with-colons | grep -c '^sec:' || true)"
-if [[ "${secrets}" -ne 1 ]]; then
-    echo "sign_rpm.sh: PKG_SIGNING_KEY must hold exactly one secret key, found ${secrets}." >&2
-    exit 1
-fi
-
-key="$(gpg --list-secret-keys --with-colons | awk -F: '/^fpr:/ { print $10; exit }')"
-echo "Signing ${#rpms[@]} RPM(s) with ${key}."
-
-# Loopback pinentry: the key is unattended, so there is no tty to prompt on.
-rpmsign \
-    --define "_gpg_name ${key}" \
-    --define "_gpg_sign_cmd_extra_args --pinentry-mode loopback --batch --yes" \
-    --addsign "${rpms[@]}"
-
-# rpmsign can exit 0 having attached nothing, and an unsigned package is only
-# rejected later, on the installing machine. Both header tags are checked
-# because an RSA signature lands in RSAHEADER and a DSA or EdDSA one in
-# DSAHEADER.
-for pkg in "${rpms[@]}"; do
-    signature="$(rpm --query --queryformat '%{RSAHEADER:pgpsig}%{DSAHEADER:pgpsig}' --package "${pkg}")"
-    if [[ "${signature}" == "(none)(none)" ]]; then
-        echo "sign_rpm.sh: ${pkg} is unsigned after rpmsign." >&2
-        exit 1
-    fi
-done

From 421af6db796631da4ff8b78f5d3bafae0fd3ca32 Mon Sep 17 00:00:00 2001
From: Vito Tumas <5780819+Tapanito@users.noreply.github.com>
Date: Wed, 26 Aug 2026 00:08:25 +0000
Subject: [PATCH 25/32] fix: Reject open-ended vaults at LoanBrokerSet (#8076)

Co-authored-by: Kenny Lei <3003853+kennyzlei@users.noreply.github.com>
---
 .../tx/transactors/lending/LoanBrokerSet.cpp  | 16 ++++++
 src/test/app/Batch_test.cpp                   | 10 +++-
 src/test/app/Sponsor_test.cpp                 |  8 ++-
 src/test/app/invariants/InvariantsBase.cpp    | 13 ++++-
 .../app/invariants/InvariantsVault_test.cpp   | 13 ++++-
 src/test/app/lending/LendingHelpers_test.cpp  |  9 ++-
 src/test/app/lending/LoanBroker_test.cpp      | 36 +++++++-----
 src/test/app/lending/LoanLifecycle_test.cpp   |  8 ++-
 src/test/app/lending/LoanSecurity_test.cpp    | 10 +++-
 src/test/app/lending/LoanTestBase.h           | 24 +++++++-
 src/test/app/lending/LoanValidation_test.cpp  | 57 +++++++++++++++++++
 src/test/app/vault/VaultBugs_test.cpp         | 31 ++++++----
 src/test/app/vault/VaultClawback_test.cpp     | 26 ++++++++-
 src/test/app/vault/VaultScale_test.cpp        |  8 ++-
 .../app/vault/VaultSoleShareholder_test.cpp   | 28 ++++++---
 src/test/jtx/impl/vault.cpp                   | 26 +++++++++
 src/test/jtx/vault.h                          | 34 +++++++++++
 17 files changed, 310 insertions(+), 47 deletions(-)

diff --git a/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp b/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp
index d6cda9c326..1ab4eb2ce0 100644
--- a/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp
+++ b/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp
@@ -8,7 +8,9 @@
 #include 
 #include 
 #include 
+#include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -144,6 +146,20 @@ LoanBrokerSet::preclaim(PreclaimContext const& ctx)
     }
     else
     {
+        // LP V1.1: only closed-ended vaults may host a loan broker. The
+        // lending protocol relies on the closed-ended Subscription /
+        // Investment / Redemption phase structure; attaching a broker to
+        // an open-ended vault has no well-defined lifecycle. VaultCreate
+        // stays unrestricted so existing open-ended flows keep working;
+        // the constraint is enforced here, at the point where the vault
+        // is first bound to the lending protocol.
+        if (ctx.view.rules().enabled(featureLendingProtocolV1_1) &&
+            getVaultKind(sleVault) != VaultKind::ClosedEnded)
+        {
+            JLOG(ctx.j.warn()) << "LoanBroker requires a closed-ended Vault.";
+            return tecNO_PERMISSION;
+        }
+
         if (auto const ter = canAddHolding(ctx.view, asset))
             return ter;
 
diff --git a/src/test/app/Batch_test.cpp b/src/test/app/Batch_test.cpp
index c332b26a5b..7e6ecfb8ca 100644
--- a/src/test/app/Batch_test.cpp
+++ b/src/test/app/Batch_test.cpp
@@ -3169,7 +3169,12 @@ class Batch_test : public beast::unit_test::Suite
         auto const debtMaximumValue = asset(25'000).value();
         auto const coverDepositValue = asset(1000).value();
 
-        auto [tx, vaultKeylet] = vault.create({.owner = lender, .asset = asset});
+        // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim only
+        // accepts closed-ended vaults, so build one with a subscription
+        // window that lets the lender deposit now, then advance the clock
+        // past SubscriptionDate before creating loans.
+        auto [tx, vaultKeylet, subscriptionDate] =
+            vault.createClosedEnded({.owner = lender, .asset = asset});
         env(tx);
         env.close();
         BEAST_EXPECT(env.le(vaultKeylet));
@@ -3177,6 +3182,9 @@ class Batch_test : public beast::unit_test::Suite
         env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = deposit}));
         env.close();
 
+        // Move into the Investment phase before creating loans.
+        vault.closePastSubscription(subscriptionDate);
+
         auto const brokerKeylet =
             keylet::loanBroker(lender.id(), SeqProxy::rawSequence(env.seq(lender)));
 
diff --git a/src/test/app/Sponsor_test.cpp b/src/test/app/Sponsor_test.cpp
index a1a9f80a11..e58d8c9f8f 100644
--- a/src/test/app/Sponsor_test.cpp
+++ b/src/test/app/Sponsor_test.cpp
@@ -1877,7 +1877,11 @@ public:
 
             PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
             Vault const vault{env};
-            auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = xrpAsset});
+            // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim only
+            // accepts closed-ended vaults; build one and advance past
+            // SubscriptionDate before creating a loan.
+            auto [vaultTx, vaultKeylet, subscriptionDate] =
+                vault.createClosedEnded({.owner = alice, .asset = xrpAsset});
             env(vaultTx);
             env.close();
 
@@ -1885,6 +1889,8 @@ public:
                 {.depositor = alice, .id = vaultKeylet.key, .amount = xrpAsset(1000)}));
             env.close();
 
+            vault.closePastSubscription(subscriptionDate);
+
             auto const brokerKeylet =
                 keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice)));
             env(loan_broker::set(alice, vaultKeylet.key),
diff --git a/src/test/app/invariants/InvariantsBase.cpp b/src/test/app/invariants/InvariantsBase.cpp
index 92d75eca77..a573cc45ea 100644
--- a/src/test/app/invariants/InvariantsBase.cpp
+++ b/src/test/app/invariants/InvariantsBase.cpp
@@ -18,6 +18,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -29,6 +30,7 @@
 #include 
 #include 
 
+#include 
 #include 
 #include 
 #include 
@@ -178,10 +180,17 @@ InvariantsBase::createLoanBroker(
 {
     using namespace jtx;
 
-    // Create vault
+    // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim only
+    // accepts closed-ended vaults. Build one with a comfortable
+    // subscription window; LoanBrokerSet itself is not phase-gated,
+    // so leaving the vault in the Subscription phase is fine here.
     uint256 vaultID;
     Vault const vault{env};
-    auto [tx, vKeylet] = vault.create({.owner = a, .asset = asset});
+    auto [tx, vKeylet, _] = vault.createClosedEnded(
+        {.owner = a,
+         .asset = asset,
+         .subscriptionOffset = std::chrono::seconds{60},
+         .investmentWindow = std::chrono::seconds{kMinInvestmentPeriod + 1'000'000u}});
     env(tx);
     BEAST_EXPECT(env.le(vKeylet));
 
diff --git a/src/test/app/invariants/InvariantsVault_test.cpp b/src/test/app/invariants/InvariantsVault_test.cpp
index 56ccaa46fc..5b2511626e 100644
--- a/src/test/app/invariants/InvariantsVault_test.cpp
+++ b/src/test/app/invariants/InvariantsVault_test.cpp
@@ -1974,8 +1974,16 @@ class InvariantsVault_test : public InvariantsBase
         env(pay(issuer, borrower, usd(1'000)));
         env.close();
 
+        // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim only
+        // accepts closed-ended vaults. The 10-year investment window
+        // covers this helper's 120 monthly payments so LoanSet's
+        // RedemptionDate bound is satisfied.
         Vault const vault{env};
-        auto [vaultTx, vaultKeylet] = vault.create({.owner = owner, .asset = usd});
+        auto [vaultTx, vaultKeylet, subscriptionDate] = vault.createClosedEnded(
+            {.owner = owner,
+             .asset = usd,
+             .subscriptionOffset = std::chrono::seconds{60},
+             .investmentWindow = std::chrono::seconds{10ull * 365ull * 24ull * 60ull * 60ull}});
         env(vaultTx);
         env.close();
 
@@ -1999,6 +2007,9 @@ class InvariantsVault_test : public InvariantsBase
             env.close();
         }
 
+        // LoanSet is gated on Investment; advance out of Subscription.
+        vault.closePastSubscription(subscriptionDate);
+
         auto const brokerSle = env.le(brokerKeylet);
         if (!BEAST_EXPECT(brokerSle))
             return vaultKeylet;
diff --git a/src/test/app/lending/LendingHelpers_test.cpp b/src/test/app/lending/LendingHelpers_test.cpp
index 32c49feb02..96adfd5254 100644
--- a/src/test/app/lending/LendingHelpers_test.cpp
+++ b/src/test/app/lending/LendingHelpers_test.cpp
@@ -1901,12 +1901,19 @@ public:
         env.fund(XRP(10'000), lender, borrower);
         env.close();
 
-        auto [vaultTx, vaultKeylet] = vault.create({.owner = lender, .asset = xrpIssue()});
+        // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim only
+        // accepts closed-ended vaults, so build one with a near-future
+        // SubscriptionDate, deposit while still in the Subscription phase,
+        // and advance past SubscriptionDate before creating the broker.
+        auto [vaultTx, vaultKeylet, subscriptionDate] =
+            vault.createClosedEnded({.owner = lender, .asset = xrpIssue()});
         env(vaultTx);
         env.close();
         env(vault.deposit({.depositor = lender, .id = vaultKeylet.key, .amount = XRP(1'000)}));
         env.close();
 
+        vault.closePastSubscription(subscriptionDate);
+
         auto const brokerKeylet =
             keylet::loanBroker(lender.id(), SeqProxy::rawSequence(env.seq(lender)));
         env(loan_broker::set(lender, vaultKeylet.key));
diff --git a/src/test/app/lending/LoanBroker_test.cpp b/src/test/app/lending/LoanBroker_test.cpp
index 437a0cea99..d75b359868 100644
--- a/src/test/app/lending/LoanBroker_test.cpp
+++ b/src/test/app/lending/LoanBroker_test.cpp
@@ -72,7 +72,13 @@ class LoanBroker_test : public beast::unit_test::Suite
 {
     // Ensure that all the features needed for Lending Protocol are included,
     // even if they are set to unsupported.
-    FeatureBitset const all_{jtx::testableAmendments()};
+    //
+    // featureLendingProtocolV1_1 is excluded from the default set: it adds
+    // the closed-ended vault gate on LoanBrokerSet::preclaim (see
+    // LoanBrokerSet.cpp), but this suite exercises loan-broker mechanics on
+    // plain open-ended vaults. Tests that specifically exercise the
+    // amendment opt it back in explicitly and use closed-ended vaults.
+    FeatureBitset const all_{jtx::testableAmendments() - featureLendingProtocolV1_1};
 
     void
     testDisabled()
@@ -872,7 +878,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         using namespace loan_broker;
         Account const issuer{"issuer"};
         Account const alice{"alice"};
-        Env env(*this);
+        Env env(*this, all_);
         Vault const vault{env};
 
         env.fund(XRP(100'000), issuer, alice);
@@ -1108,7 +1114,7 @@ class LoanBroker_test : public beast::unit_test::Suite
             Account const alice{"alice"};
             Account const issuer{"issuer"};
             auto const usd = alice["USD"];
-            Env env(*this);
+            Env env(*this, all_);
             env.fund(XRP(100'000), alice);
             env.close();
 
@@ -1211,7 +1217,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         // This test is lifted directly from
         // https://bugs.immunefi.com/dashboard/submission/57808
         using namespace jtx;
-        Env env(*this);
+        Env env(*this, all_);
 
         Account const alice{"alice"};
         env.fund(XRP(10000), alice);
@@ -1269,7 +1275,7 @@ class LoanBroker_test : public beast::unit_test::Suite
 
         Account const issuer{"issuer"};
         Account const alice{"alice"};
-        Env env(*this);
+        Env env(*this, all_);
         Vault vault{env};
 
         env.fund(XRP(100'000), issuer, alice);
@@ -1377,7 +1383,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         using namespace loan_broker;
         Account const issuer{"issuer"};
         Account const alice{"alice"};
-        Env env(*this);
+        Env env(*this, all_);
         Vault const vault{env};
 
         env.fund(XRP(100'000), issuer, alice);
@@ -1543,7 +1549,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         Account const& broker = issuer;
 
         auto test = [&](auto&& getToken) {
-            Env env(*this);
+            Env env(*this, all_);
 
             env.fund(XRP(1'000), issuer, holder);
             env.close();
@@ -1616,7 +1622,7 @@ class LoanBroker_test : public beast::unit_test::Suite
     {
         testcase << "RIPD-4466 - LoanBrokerSet disallows frozen vaults";
         using namespace jtx;
-        Env env(*this);
+        Env env(*this, all_);
 
         Account const issuer{"issuer"}, lender{"lender"}, borrower{"borrower"};
         env.fund(XRP(20'000), issuer, lender, borrower);
@@ -1855,7 +1861,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         // === IOU ===
         {
             testcase("LoanBrokerCoverDeposit IOU freeze checks");
-            Env env(*this);
+            Env env(*this, all_);
             Vault const vault{env};
 
             env.fund(XRP(100'000), issuer, alice);
@@ -1922,7 +1928,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         // === MPT ===
         {
             testcase("LoanBrokerCoverDeposit MPT lock checks");
-            Env env(*this);
+            Env env(*this, all_);
             Vault const vault{env};
 
             env.fund(XRP(100'000), issuer, alice);
@@ -2005,7 +2011,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         Account const issuer{"issuer"};
         Account const alice{"alice"};
         Account const dest{"dest"};
-        Env env{*this};
+        Env env{*this, all_};
         Vault const vault{env};
 
         env.fund(XRP(100'000), issuer, alice, dest);
@@ -2071,7 +2077,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         // === IOU ===
         {
             testcase("LoanBrokerCoverWithdraw IOU freeze checks");
-            Env env(*this);
+            Env env(*this, all_);
             Vault const vault{env};
 
             env.fund(XRP(100'000), issuer, alice);
@@ -2183,7 +2189,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         // === MPT ===
         {
             testcase("LoanBrokerCoverWithdraw MPT lock checks");
-            Env env(*this);
+            Env env(*this, all_);
             Vault const vault{env};
 
             env.fund(XRP(100'000), issuer, alice);
@@ -2304,7 +2310,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         };
 
         auto test = [&](TrustState trustState) {
-            Env env(*this);
+            Env env(*this, all_);
 
             testcase << "RIPD-4274 IOU with state: " << static_cast(trustState);
 
@@ -2429,7 +2435,7 @@ class LoanBroker_test : public beast::unit_test::Suite
         };
 
         auto test = [&](MPTState mptState) {
-            Env env(*this);
+            Env env(*this, all_);
 
             testcase << "RIPD-4274 MPT with state: " << static_cast(mptState);
 
diff --git a/src/test/app/lending/LoanLifecycle_test.cpp b/src/test/app/lending/LoanLifecycle_test.cpp
index 6cced5c97a..dae6f6ce16 100644
--- a/src/test/app/lending/LoanLifecycle_test.cpp
+++ b/src/test/app/lending/LoanLifecycle_test.cpp
@@ -347,7 +347,11 @@ private:
             auto const& asset = debtMaximumRequest.asset();
             auto const initialVault = asset(debtMaximumRequest * 100);
 
-            auto [tx, vaultKeylet] = vault.create({.owner = broker, .asset = asset});
+            // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim
+            // only accepts closed-ended vaults, so build one and advance
+            // past SubscriptionDate before creating broker/loan.
+            auto [tx, vaultKeylet, subscriptionDate] =
+                vault.createClosedEnded({.owner = broker, .asset = asset});
             env(tx, txFee);
             env.close();
 
@@ -356,6 +360,8 @@ private:
                 txFee);
             env.close();
 
+            vault.closePastSubscription(subscriptionDate);
+
             auto const brokerKeylet =
                 keylet::loanBroker(broker.id(), SeqProxy::rawSequence(env.seq(broker)));
 
diff --git a/src/test/app/lending/LoanSecurity_test.cpp b/src/test/app/lending/LoanSecurity_test.cpp
index 21772d0617..b878547a70 100644
--- a/src/test/app/lending/LoanSecurity_test.cpp
+++ b/src/test/app/lending/LoanSecurity_test.cpp
@@ -411,13 +411,17 @@ private:
         Account const depositor{"depositor"};
         auto const txFee = Fee(XRP(100));
 
+        // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim only
+        // accepts closed-ended vaults, so build one and advance past
+        // SubscriptionDate before creating the broker and the loan.
         Env env(*this);
         Vault const vault(env);
 
         env.fund(XRP(10'000), lender, issuer, borrower, depositor);
         env.close();
 
-        auto [tx, vaultKeyLet] = vault.create({.owner = lender, .asset = xrpIssue()});
+        auto [tx, vaultKeyLet, subscriptionDate] =
+            vault.createClosedEnded({.owner = lender, .asset = xrpIssue()});
         env(tx, txFee);
         env.close();
 
@@ -425,6 +429,10 @@ private:
             txFee);
         env.close();
 
+        // Move into the Investment phase before creating the broker and
+        // the loan.
+        vault.closePastSubscription(subscriptionDate);
+
         auto const brokerKeyLet =
             keylet::loanBroker(lender.id(), SeqProxy::rawSequence(env.seq(lender)));
 
diff --git a/src/test/app/lending/LoanTestBase.h b/src/test/app/lending/LoanTestBase.h
index b3669742fe..99b387938e 100644
--- a/src/test/app/lending/LoanTestBase.h
+++ b/src/test/app/lending/LoanTestBase.h
@@ -496,9 +496,27 @@ protected:
 
         auto const coverRateMinValue = params.coverRateMin;
 
+        // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim rejects
+        // brokers attached to open-ended vaults. Many callers of this
+        // helper leave vaultKind at the OpenEnded default and don't care
+        // about the vault kind per se — they just need a broker on a
+        // vault. When LP V1.1 is enabled, transparently promote to
+        // ClosedEnded so those tests keep working without threading
+        // vaultKind through every call site. Callers that explicitly
+        // asked for ClosedEnded are left untouched. Tests that want to
+        // exercise the open-ended rejection under LP V1.1 build their own
+        // vault directly instead of going through this helper, since it
+        // always promotes OpenEnded once the amendment is enabled.
+        auto effectiveVaultKind = params.vaultKind;
+        if (env.current()->rules().enabled(featureLendingProtocolV1_1) &&
+            effectiveVaultKind == VaultKind::OpenEnded)
+        {
+            effectiveVaultKind = VaultKind::ClosedEnded;
+        }
+
         std::optional subscriptionDate;
         std::optional redemptionDate;
-        if (params.vaultKind == VaultKind::ClosedEnded)
+        if (effectiveVaultKind == VaultKind::ClosedEnded)
         {
             auto const nowSec = env.now().time_since_epoch().count();
             subscriptionDate = nowSec + params.subscriptionOffset;
@@ -508,9 +526,9 @@ protected:
         auto [tx, vaultKeylet] = vault.create(
             {.owner = lender,
              .asset = asset,
-             .vaultKind = params.vaultKind == VaultKind::OpenEnded
+             .vaultKind = effectiveVaultKind == VaultKind::OpenEnded
                  ? std::optional{}
-                 : std::optional{std::to_underlying(params.vaultKind)},
+                 : std::optional{std::to_underlying(effectiveVaultKind)},
              .subscriptionDate = subscriptionDate,
              .redemptionDate = redemptionDate});
         if (params.vaultScale)
diff --git a/src/test/app/lending/LoanValidation_test.cpp b/src/test/app/lending/LoanValidation_test.cpp
index c6ff22bbb3..ebbef70f40 100644
--- a/src/test/app/lending/LoanValidation_test.cpp
+++ b/src/test/app/lending/LoanValidation_test.cpp
@@ -13,6 +13,7 @@
 #include 
 #include 
 #include 
+#include 
 
 #include 
 #include 
@@ -530,6 +531,61 @@ private:
         env.close();
     }
 
+    // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim rejects
+    // attaching a broker to an open-ended vault. VaultCreate itself is
+    // not gated by the amendment, so the same open-ended vault can be
+    // built under either feature set; only the broker create is
+    // amendment-sensitive. Cover both branches: LP V1.1 disabled lets
+    // the broker create succeed, LP V1.1 enabled rejects it. The gate
+    // only fires on the create path; existing brokers keep working.
+    void
+    testLoanBrokerRequiresClosedEndedVault()
+    {
+        testcase("LoanBrokerSet requires closed-ended vault under LP V1.1");
+        using namespace jtx;
+
+        Account const owner{"lp11_owner"};
+
+        auto const build = [&](FeatureBitset features,
+                               TER expected,
+                               std::optional updateExpected = std::nullopt) {
+            Env env(*this, features);
+            env.fund(XRP(1'000), owner);
+            env.close();
+
+            Vault const vault{env};
+            auto [tx, vaultKeylet] = vault.create({.owner = owner, .asset = xrpIssue()});
+            env(tx);
+            env.close();
+            env(vault.deposit({.depositor = owner, .id = vaultKeylet.key, .amount = XRP(100)}));
+            env.close();
+
+            auto const brokerKeylet =
+                keylet::loanBroker(owner.id(), SeqProxy::rawSequence(env.seq(owner)));
+            env(loan_broker::set(owner, vaultKeylet.key), Ter(expected));
+            env.close();
+
+            // The create-path gate is the only new check; updates to an
+            // existing broker on the same open-ended vault are not
+            // affected. Only exercise the update path when the create
+            // succeeded (so there is a broker to update).
+            if (updateExpected && expected == tesSUCCESS)
+            {
+                env(loan_broker::set(owner, vaultKeylet.key),
+                    loan_broker::kLoanBrokerId(brokerKeylet.key),
+                    loan_broker::kDebtMaximum(XRP(1'000).value()),
+                    Ter(*updateExpected));
+                env.close();
+            }
+        };
+
+        // Baseline: LP V1.1 disabled -> open-ended vault + broker succeeds.
+        build(all_, tesSUCCESS, tesSUCCESS);
+
+        // LP V1.1 enabled -> open-ended vault + broker rejected on create.
+        build(all_ | featureLendingProtocolV1_1, tecNO_PERMISSION);
+    }
+
     void
     runAmendmentIndependent()
     {
@@ -541,6 +597,7 @@ private:
         testInvalidLoanPay();
         testRequireAuth();
         testLimitExceeded();
+        testLoanBrokerRequiresClosedEndedVault();
     }
 
     // Tests run under each entry in amendmentCombinations().
diff --git a/src/test/app/vault/VaultBugs_test.cpp b/src/test/app/vault/VaultBugs_test.cpp
index ad6fdcc8b8..e3e0c40bc2 100644
--- a/src/test/app/vault/VaultBugs_test.cpp
+++ b/src/test/app/vault/VaultBugs_test.cpp
@@ -564,44 +564,49 @@ private:
             env.close();
         };
 
+        // Strip featureLendingProtocolV1_1: this scenario runs an
+        // open-ended vault through deposit/broker/loan/repay/deposit,
+        // which spans both Subscription and post-loan lifetime — a phase
+        // pattern that only makes sense on open-ended vaults. The gate
+        // added by LP V1.1 is unrelated to the truncation bug asserted
+        // here.
+        auto const legacy = testableAmendments() - featureLendingProtocolV1_1;
         {
             testcase(
                 "bug: VaultDeposit share truncation lets depositor debit "
                 "round away to zero (pre-fixCleanup3_4_0)");
-            runScenario(testableAmendments() - fixCleanup3_4_0, Line::Holding, tecINVARIANT_FAILED);
+            runScenario(legacy - fixCleanup3_4_0, Line::Holding, tecINVARIANT_FAILED);
         }
         {
             testcase(
                 "bug: VaultDeposit share truncation lets depositor debit "
                 "round away to zero (pre-fixCleanup3_2_0 and pre-fixCleanup3_4_0)");
             runScenario(
-                testableAmendments() - fixCleanup3_2_0 - fixCleanup3_4_0,
-                Line::Holding,
-                tecINVARIANT_FAILED);
+                legacy - fixCleanup3_2_0 - fixCleanup3_4_0, Line::Holding, tecINVARIANT_FAILED);
         }
         {
             testcase(
                 "bug: VaultDeposit share truncation rejected with "
                 "tecPRECISION_LOSS (post-fixCleanup3_4_0)");
-            runScenario(testableAmendments(), Line::Holding, tecPRECISION_LOSS);
+            runScenario(legacy, Line::Holding, tecPRECISION_LOSS);
         }
         {
             testcase(
                 "bug: VaultDeposit share truncation rejected with "
                 "tecPRECISION_LOSS (post-fixCleanup3_4_0, pre-fixCleanup3_2_0)");
-            runScenario(testableAmendments() - fixCleanup3_2_0, Line::Holding, tecPRECISION_LOSS);
+            runScenario(legacy - fixCleanup3_2_0, Line::Holding, tecPRECISION_LOSS);
         }
         {
             testcase(
                 "bug: VaultDeposit share truncation against a debt balance "
                 "round away to zero (pre-fixCleanup3_4_0)");
-            runScenario(testableAmendments() - fixCleanup3_4_0, Line::InDebt, tecINVARIANT_FAILED);
+            runScenario(legacy - fixCleanup3_4_0, Line::InDebt, tecINVARIANT_FAILED);
         }
         {
             testcase(
                 "bug: VaultDeposit share truncation against a debt balance rejected with "
                 "tecPRECISION_LOSS (post-fixCleanup3_4_0)");
-            runScenario(testableAmendments(), Line::InDebt, tecPRECISION_LOSS);
+            runScenario(legacy, Line::InDebt, tecPRECISION_LOSS);
         }
     }
 
@@ -1104,7 +1109,10 @@ private:
         using namespace test::jtx;
 
         auto runScenario = [this](FeatureBitset features, bool withFix) {
-            Env env{*this, features};
+            // This regression requires the open-ended vault lifecycle: deposit,
+            // originate and repay a loan, then claw back shares. LP V1.1
+            // independently rejects attaching a broker to an open-ended vault.
+            Env env{*this, features - featureLendingProtocolV1_1};
 
             auto const setup = makeRoundTripOvershootVault(env);
             if (!BEAST_EXPECT(setup))
@@ -1165,7 +1173,10 @@ private:
         using namespace test::jtx;
 
         auto runScenario = [this](FeatureBitset features, bool withFix) {
-            Env env{*this, features};
+            // This regression requires the open-ended vault lifecycle: deposit,
+            // originate and repay a loan, then withdraw shares. LP V1.1
+            // independently rejects attaching a broker to an open-ended vault.
+            Env env{*this, features - featureLendingProtocolV1_1};
 
             auto const setup = makeRoundTripOvershootVault(env);
             if (!BEAST_EXPECT(setup))
diff --git a/src/test/app/vault/VaultClawback_test.cpp b/src/test/app/vault/VaultClawback_test.cpp
index 2a9fe42b1c..6ce847f9fd 100644
--- a/src/test/app/vault/VaultClawback_test.cpp
+++ b/src/test/app/vault/VaultClawback_test.cpp
@@ -68,12 +68,20 @@ private:
             return sleIssuance->at(sfOutstandingAmount);
         };
 
+        // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim only
+        // accepts closed-ended vaults, so build vaults in this suite as
+        // closed-ended and advance past SubscriptionDate before creating
+        // brokers/loans. VaultClawback itself is not phase-gated. The
+        // subscription offset must be large enough that the deposit
+        // ledger close does not accidentally push us past SubscriptionDate
+        // (which would land the deposit in Investment phase and fail).
         auto const setupVault = [&](PrettyAsset const& asset,
                                     Account const& owner,
                                     Account const& depositor) -> std::pair {
             Vault const vault{env};
 
-            auto const& [tx, vaultKeylet] = vault.create({.owner = owner, .asset = asset});
+            auto const& [tx, vaultKeylet, subscriptionDate] = vault.createClosedEnded(
+                {.owner = owner, .asset = asset, .subscriptionOffset = std::chrono::seconds{60}});
             env(tx, Ter(tesSUCCESS));
             env.close();
 
@@ -87,6 +95,10 @@ private:
                 Ter(tesSUCCESS));
             env.close();
 
+            // Move past SubscriptionDate so LoanBrokerSet/LoanSet run in
+            // the Investment phase.
+            vault.closePastSubscription(subscriptionDate);
+
             auto const& [availablePreDefault, totalPreDefault] = vaultAssetBalance(vaultKeylet);
             BEAST_EXPECT(availablePreDefault == totalPreDefault);
             BEAST_EXPECT(availablePreDefault == asset(100).value());
@@ -313,13 +325,21 @@ private:
         Env env(*this);
         env.enableFeature(fixCleanup3_1_3);
 
+        // Under featureLendingProtocolV1_1 LoanBrokerSet::preclaim only
+        // accepts closed-ended vaults; some tests using this helper later
+        // attach loan brokers to the vault. Build it as closed-ended and
+        // advance past SubscriptionDate so subsequent broker/loan setup
+        // runs in the Investment phase. VaultClawback itself is not
+        // phase-gated. See the other setupVault (share tests) for why the
+        // subscription offset must be generous.
         auto const setupVault = [&](PrettyAsset const& asset,
                                     Account const& owner,
                                     Account const& depositor,
                                     Account const& issuer) -> std::pair {
             Vault const vault{env};
 
-            auto const& [tx, vaultKeylet] = vault.create({.owner = owner, .asset = asset});
+            auto const& [tx, vaultKeylet, subscriptionDate] = vault.createClosedEnded(
+                {.owner = owner, .asset = asset, .subscriptionOffset = std::chrono::seconds{60}});
             env(tx, Ter(tesSUCCESS));
             env.close();
 
@@ -331,6 +351,8 @@ private:
                 Ter(tesSUCCESS));
             env.close();
 
+            vault.closePastSubscription(subscriptionDate);
+
             return std::make_pair(vault, vaultKeylet);
         };
 
diff --git a/src/test/app/vault/VaultScale_test.cpp b/src/test/app/vault/VaultScale_test.cpp
index 28c9729d78..b2ce4a5abf 100644
--- a/src/test/app/vault/VaultScale_test.cpp
+++ b/src/test/app/vault/VaultScale_test.cpp
@@ -22,6 +22,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -71,7 +72,12 @@ private:
 
         auto testCase = [&, this](
                             std::uint8_t scale, std::function test) {
-            Env env{*this, testableAmendments()};
+            // These scale-focused tests build an open-ended vault and
+            // exercise deposit/withdraw/clawback (with one test also
+            // attaching a loan broker). featureLendingProtocolV1_1 adds a
+            // closed-ended vault gate on LoanBrokerSet::preclaim and is
+            // orthogonal to what this suite asserts, so strip it here.
+            Env env{*this, testableAmendments() - featureLendingProtocolV1_1};
             Account const owner{"owner"};
             Account const issuer{"issuer"};
             Account const depositor{"depositor"};
diff --git a/src/test/app/vault/VaultSoleShareholder_test.cpp b/src/test/app/vault/VaultSoleShareholder_test.cpp
index ffaad07112..62cbc28bcd 100644
--- a/src/test/app/vault/VaultSoleShareholder_test.cpp
+++ b/src/test/app/vault/VaultSoleShareholder_test.cpp
@@ -464,7 +464,10 @@ private:
             "Vault withdraw: sole-shareholder partial fixed-shares uses "
             "full-price rate (fixCleanup3_2_0)");
 
-        Env env(*this, all_ | fixCleanup3_2_0);
+        // Strip featureLendingProtocolV1_1: setupStuckDepositor builds an
+        // open-ended vault and this test asserts amendment-independent
+        // withdrawal invariants (see the note on run()).
+        Env env(*this, (all_ - featureLendingProtocolV1_1) | fixCleanup3_2_0);
         auto const f = setupStuckDepositor(env);
         if (!f.vaultKeylet || !f.asset || f.sharesLender == 0)
         {
@@ -551,7 +554,8 @@ private:
             "Vault withdraw: sole shareholder fully exits after impaired "
             "loan is repaid (fixCleanup3_2_0)");
 
-        Env env(*this, all_ | fixCleanup3_2_0);
+        // Strip featureLendingProtocolV1_1 as above.
+        Env env(*this, (all_ - featureLendingProtocolV1_1) | fixCleanup3_2_0);
         auto const f = setupStuckDepositor(env);
         if (!f.vaultKeylet || !f.asset || !f.loanKeylet || f.sharesLender == 0)
         {
@@ -639,12 +643,20 @@ public:
     void
     run() override
     {
-        testWithdrawSoleShareholderFixedAssetExit(all_ - fixCleanup3_2_0);
-        testWithdrawSoleShareholderFixedAssetExit(all_);
-        testWithdrawSoleShareholderFullSharesRejected(all_ - fixCleanup3_2_0);
-        testWithdrawSoleShareholderFullSharesRejected(all_);
-        testWithdrawSoleShareholderCleanVaultUnaffected(all_ - fixCleanup3_2_0);
-        testWithdrawSoleShareholderCleanVaultUnaffected(all_);
+        // These sole-shareholder exit scenarios build an open-ended vault
+        // and drive it through deposits, a loan broker, an impaired loan
+        // and finally a withdrawal by the last shareholder. Under
+        // featureLendingProtocolV1_1 LoanBrokerSet::preclaim rejects
+        // brokers attached to open-ended vaults, so this suite runs with
+        // the amendment stripped; the invariants asserted here are
+        // amendment-independent.
+        auto const legacy = all_ - featureLendingProtocolV1_1;
+        testWithdrawSoleShareholderFixedAssetExit(legacy - fixCleanup3_2_0);
+        testWithdrawSoleShareholderFixedAssetExit(legacy);
+        testWithdrawSoleShareholderFullSharesRejected(legacy - fixCleanup3_2_0);
+        testWithdrawSoleShareholderFullSharesRejected(legacy);
+        testWithdrawSoleShareholderCleanVaultUnaffected(legacy - fixCleanup3_2_0);
+        testWithdrawSoleShareholderCleanVaultUnaffected(legacy);
         testWithdrawSoleShareholderPartialFixedSharesUsesFullPrice();
         testWithdrawSoleShareholderLoanRepaymentExit();
     }
diff --git a/src/test/jtx/impl/vault.cpp b/src/test/jtx/impl/vault.cpp
index 978c3864d6..4688e8c4a6 100644
--- a/src/test/jtx/impl/vault.cpp
+++ b/src/test/jtx/impl/vault.cpp
@@ -3,17 +3,22 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
 #include 
 
+#include 
+#include 
 #include 
 #include 
+#include 
 
 namespace xrpl::test::jtx {
 
@@ -37,6 +42,27 @@ Vault::create(CreateArgs const& args) const
     return {jv, keylet};
 }
 
+std::tuple
+Vault::createClosedEnded(CreateClosedEndedArgs const& args) const
+{
+    auto const sub = env.now() + args.subscriptionOffset;
+    auto const red = sub + args.investmentWindow;
+    auto [jv, keylet] = create(
+        {.owner = args.owner,
+         .asset = args.asset,
+         .flags = args.flags,
+         .vaultKind = std::to_underlying(VaultKind::ClosedEnded),
+         .subscriptionDate = static_cast(sub.time_since_epoch().count()),
+         .redemptionDate = static_cast(red.time_since_epoch().count())});
+    return {jv, keylet, sub};
+}
+
+void
+Vault::closePastSubscription(NetClock::time_point subscriptionDate) const
+{
+    env.close(subscriptionDate + std::chrono::seconds{1});
+}
+
 json::Value
 Vault::set(SetArgs const& args)
 {
diff --git a/src/test/jtx/vault.h b/src/test/jtx/vault.h
index 992051b61f..6b2ffddfb3 100644
--- a/src/test/jtx/vault.h
+++ b/src/test/jtx/vault.h
@@ -3,10 +3,12 @@
 #include 
 
 #include 
+#include 
 #include 
 #include 
 #include 
 
+#include 
 #include 
 #include 
 #include 
@@ -39,6 +41,38 @@ struct Vault
     [[nodiscard]] std::tuple
     create(CreateArgs const& args) const;
 
+    struct CreateClosedEndedArgs
+    {
+        Account owner;
+        Asset asset;
+        std::optional flags =
+            std::nullopt;  // NOLINT(readability-redundant-member-init)
+        NetClock::duration subscriptionOffset = std::chrono::seconds{10};
+        NetClock::duration investmentWindow = std::chrono::seconds{1'000'000};
+    };
+
+    /**
+     * Return a VaultCreate transaction for a closed-ended vault, its
+     * expected keylet, and the vault's SubscriptionDate.
+     *
+     * Under featureLendingProtocolV1_1, LoanBrokerSet::preclaim only
+     * accepts closed-ended vaults, so tests that attach a loan broker
+     * need one. SubscriptionDate is set to now() + subscriptionOffset,
+     * giving callers a window to deposit while still in the Subscription
+     * phase; pass the returned date to closePastSubscription() afterwards
+     * to advance into the Investment phase.
+     */
+    [[nodiscard]] std::tuple
+    createClosedEnded(CreateClosedEndedArgs const& args) const;
+
+    /**
+     * Advance env's clock to just past subscriptionDate, moving a
+     * closed-ended vault from the Subscription phase into the Investment
+     * phase.
+     */
+    void
+    closePastSubscription(NetClock::time_point subscriptionDate) const;
+
     struct SetArgs
     {
         Account owner;

From 50527485d3c365dc34ef61e9fc681ee1dd134166 Mon Sep 17 00:00:00 2001
From: Ayaz Salikhov 
Date: Wed, 26 Aug 2026 13:13:59 +0000
Subject: [PATCH 26/32] build: Rename release channels: unstable->rc,
 experimental->beta (#8116)

---
 .github/actions/release-info/action.yml |  4 +--
 cmake/XrplPackaging.cmake               |  5 ++--
 docs/install.md                         |  4 +--
 package/README.md                       | 37 ++++++++++++++-----------
 package/build_pkg.py                    |  5 ++--
 package/publish_pkg.py                  |  1 +
 6 files changed, 32 insertions(+), 24 deletions(-)

diff --git a/.github/actions/release-info/action.yml b/.github/actions/release-info/action.yml
index 7f1061df93..ab69a35f68 100644
--- a/.github/actions/release-info/action.yml
+++ b/.github/actions/release-info/action.yml
@@ -61,9 +61,9 @@ runs:
         elif [[ -z "${pre_release}" ]]; then
             channel=stable
         elif [[ "${pre_release}" =~ ^rc[0-9]+(\+.*)?$ ]]; then
-            channel=unstable
+            channel=rc
         elif [[ "${pre_release}" =~ ^b(0|[1-9][0-9]*)(\+.*)?$ ]]; then
-            channel=experimental
+            channel=beta
         else
             echo "Unsupported pre-release in tag '${REF_NAME}'. Use bN or rcN." >&2
             exit 1
diff --git a/cmake/XrplPackaging.cmake b/cmake/XrplPackaging.cmake
index c454f487dc..e2f7029ad2 100644
--- a/cmake/XrplPackaging.cmake
+++ b/cmake/XrplPackaging.cmake
@@ -47,8 +47,9 @@ endif()
 add_custom_target(
     package
     COMMAND
-        ${CMAKE_SOURCE_DIR}/package/build_pkg.py --package-type ${pkg_type}
-        --build-dir ${CMAKE_BINARY_DIR} --pkg-release ${pkg_release}
+        ${CMAKE_SOURCE_DIR}/package/build_pkg.py --package-type=${pkg_type}
+        --build-dir=${CMAKE_BINARY_DIR} --pkg-release=${pkg_release}
+        --channel=UNRELEASED
     WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
     DEPENDS xrpld validator-keys
     COMMENT "Building Linux ${pkg_type} package"
diff --git a/docs/install.md b/docs/install.md
index ee9c31868b..01cfc144a1 100644
--- a/docs/install.md
+++ b/docs/install.md
@@ -14,8 +14,8 @@ To build from source instead, see [BUILD.md](../BUILD.md).
 Packages are published to four channels:
 
 - `stable` - the latest production release
-- `unstable` - release candidates
-- `experimental` - beta builds
+- `rc` - release candidates
+- `beta` - beta builds
 - `develop` - every push to the [`develop` branch](https://github.com/XRPLF/rippled/tree/develop)
 
 See [Publishing packages](../package/README.md#publishing-packages) for how channels are produced.
diff --git a/package/README.md b/package/README.md
index 04f4db2ea5..bacd79efe5 100644
--- a/package/README.md
+++ b/package/README.md
@@ -86,7 +86,10 @@ docker run --rm \
     -v "$(pwd):/src" \
     -w /src \
     "${IMAGE}" \
-    ./package/build_pkg.py --package-type rpm --pkg-release "${PKG_RELEASE}"
+    ./package/build_pkg.py \
+    --package-type rpm \
+    --pkg-release "${PKG_RELEASE}" \
+    --channel UNRELEASED
 
 # Output:
 #   build/debbuild/*.deb         (DEB + dbgsym; Debian names both .deb)
@@ -114,9 +117,9 @@ The `cmake/XrplPackaging.cmake` module defines the `package` target only if at
 least one of `rpmbuild` / `dpkg-buildpackage` is present and both the `xrpld` and
 `validator-keys` targets exist (`-Dxrpld=ON -Dvalidator_keys=ON`); the target
 builds both binaries before packaging, passing `--package-type deb` when
-`dpkg-buildpackage` is present and `rpm` otherwise. The packaging script installs to
-FHS-standard paths (`/usr/bin`, `/etc/xrpld`, etc.) regardless of
-`CMAKE_INSTALL_PREFIX`.
+`dpkg-buildpackage` is present and `rpm` otherwise, and `--channel UNRELEASED`.
+The packaging script installs to FHS-standard paths (`/usr/bin`, `/etc/xrpld`,
+etc.) regardless of `CMAKE_INSTALL_PREFIX`.
 
 The package version is not a CMake input on this path: `build_pkg.py` derives it
 from the just-built `xrpld` binary's `xrpld --version` output. The package
@@ -128,13 +131,13 @@ Packages are published to the XRPLF repositories on Sonatype Nexus at
 `https://packages.xrplf.org`. The `release-info` action decides the channel from
 the event, and `publish_pkg.py` maps that channel to its repositories:
 
-| Event                    | Version           | Channel        | DEB repository     | RPM upload repository     |
-| ------------------------ | ----------------- | -------------- | ------------------ | ------------------------- |
-| tag                      | `X.Y.Z`           | `stable`       | `deb-stable`       | `rpm-stable-hosted`       |
-| tag                      | `X.Y.Z-rcN`       | `unstable`     | `deb-unstable`     | `rpm-unstable-hosted`     |
-| tag                      | `X.Y.Z-bN`        | `experimental` | `deb-experimental` | `rpm-experimental-hosted` |
-| push to `develop`        | `xrpld --version` | `develop`      | `deb-develop`      | `rpm-develop-hosted`      |
-| tag, non-public codebase | _any_             | `private`      | `deb-private`      | `rpm-private-hosted`      |
+| Event                    | Version           | Channel   | DEB repository | RPM upload repository |
+| ------------------------ | ----------------- | --------- | -------------- | --------------------- |
+| tag                      | `X.Y.Z`           | `stable`  | `deb-stable`   | `rpm-stable-hosted`   |
+| tag                      | `X.Y.Z-rcN`       | `rc`      | `deb-rc`       | `rpm-rc-hosted`       |
+| tag                      | `X.Y.Z-bN`        | `beta`    | `deb-beta`     | `rpm-beta-hosted`     |
+| push to `develop`        | `xrpld --version` | `develop` | `deb-develop`  | `rpm-develop-hosted`  |
+| tag, non-public codebase | _any_             | `private` | `deb-private`  | `rpm-private-hosted`  |
 
 Only a tag names a channel — do not extend that to `develop`, where
 `BuildInfo.cpp`'s `versionString` moves through `-bN`, `-rcN` and even the final
@@ -207,9 +210,11 @@ With `PKG_RELEASE=1`, the package metadata becomes:
 from the build host, so the RHEL image can track a newer release without
 changing what the packages claim to target.
 
-The Debian changelog entry carries the channel passed as `--channel`,
-defaulting to `unstable`. An unsupported pre-release, and build metadata on a
-final release such as `3.2.0+abc123`, are both rejected.
+The Debian changelog entry carries the channel passed as `--channel`, which
+only accepts the channels in the table above plus `UNRELEASED`, the Debian
+convention for a build that targets no channel at all — what local and CMake
+builds pass, since nothing publishes them. An unsupported pre-release, and
+build metadata on a final release such as `3.2.0+abc123`, are both rejected.
 
 The RPM path intentionally uses `~` in `Version`, matching the Debian
 pre-release ordering convention, so RPM filenames/NVRs begin with forms like
@@ -220,8 +225,8 @@ The package format is `--package-type`, either `deb` or `rpm`. It is required,
 so a job never silently builds the wrong format for the image it runs in; the
 matching build tool still has to be on PATH.
 
-Every input is a named argument. CMake passes `--package-type`, `--build-dir`
-and `--pkg-release`; CI adds `--channel`. The repository root is not an argument
+Every input is a named argument, and every argument but `--build-dir` and
+`--pkg-release` is required. The repository root is not an argument
 at all: the script reads it from its own location. Only secrets stay in the
 environment, so they never reach the process list -- `PKG_SIGNING_KEY` for
 `sign_rpm.py`, and `NEXUS_USERNAME` / `NEXUS_PASSWORD` for `publish_pkg.py`.
diff --git a/package/build_pkg.py b/package/build_pkg.py
index 28835d1ccd..2518d8c1db 100755
--- a/package/build_pkg.py
+++ b/package/build_pkg.py
@@ -219,8 +219,9 @@ def main() -> None:
     )
     parser.add_argument(
         "--channel",
-        default="unstable",
-        help="release channel, written to debian/changelog (default: %(default)s)",
+        required=True,
+        choices=("stable", "rc", "beta", "develop", "private", "UNRELEASED"),
+        help="release channel, written to debian/changelog",
     )
     args = parser.parse_args()
     package_type: str = args.package_type
diff --git a/package/publish_pkg.py b/package/publish_pkg.py
index 2c320a595a..0bd39d0845 100755
--- a/package/publish_pkg.py
+++ b/package/publish_pkg.py
@@ -81,6 +81,7 @@ def main() -> None:
     parser.add_argument(
         "--channel",
         required=True,
+        choices=("stable", "rc", "beta", "develop", "private"),
         help="release channel, selecting the deb- and rpm--hosted repositories",
     )
     parser.add_argument(

From f7ea645bf4d4886149166aad9ed97c72b6484b69 Mon Sep 17 00:00:00 2001
From: Peter Chen <34582813+PeterChen13579@users.noreply.github.com>
Date: Wed, 26 Aug 2026 13:14:53 +0000
Subject: [PATCH 27/32] fix: AMMClawback exact LP token boundary (#7373)

---
 .../tx/transactors/dex/AMMClawback.cpp        | 11 ++--
 src/test/app/AMMClawback_test.cpp             | 64 +++++++++++++++++++
 2 files changed, 70 insertions(+), 5 deletions(-)

diff --git a/src/libxrpl/tx/transactors/dex/AMMClawback.cpp b/src/libxrpl/tx/transactors/dex/AMMClawback.cpp
index e690cd7693..b25c90069c 100644
--- a/src/libxrpl/tx/transactors/dex/AMMClawback.cpp
+++ b/src/libxrpl/tx/transactors/dex/AMMClawback.cpp
@@ -324,11 +324,13 @@ AMMClawback::equalWithdrawMatchingOneAmount(
     auto amount2Withdraw = amount2Balance * frac;
 
     auto const lpTokensWithdraw = toSTAmount(lptAMMBalance.asset(), lptAMMBalance * frac);
-    if (lpTokensWithdraw > holdLPtokens)
+    auto const& rules = sb.rules();
+    // Pre-fixCleanup3_4_0 only a strictly greater computed LP amount takes
+    // the withdraw-all path. Equality left the last holder unable to be
+    // fully clawed. The amendment treats equality as withdraw-all.
+    if (rules.enabled(fixCleanup3_4_0) ? lpTokensWithdraw >= holdLPtokens
+                                       : lpTokensWithdraw > holdLPtokens)
     {
-        // if lptoken balance less than what the issuer intended to clawback,
-        // clawback all the tokens. Because we are doing a two-asset withdrawal,
-        // tfee is actually not used, so pass tfee as 0.
         return AMMWithdraw::equalWithdrawTokens(
             sb,
             ammSle,
@@ -348,7 +350,6 @@ AMMClawback::equalWithdrawMatchingOneAmount(
             ctx_.journal);
     }
 
-    auto const& rules = sb.rules();
     if (rules.enabled(fixAMMClawbackRounding))
     {
         auto tokensAdj = getRoundedLPTokens(rules, lptAMMBalance, frac, IsDeposit::No);
diff --git a/src/test/app/AMMClawback_test.cpp b/src/test/app/AMMClawback_test.cpp
index 90bface1fb..230d148ff9 100644
--- a/src/test/app/AMMClawback_test.cpp
+++ b/src/test/app/AMMClawback_test.cpp
@@ -13,7 +13,9 @@
 
 #include 
 #include 
+#include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -2713,6 +2715,67 @@ class AMMClawback_test : public beast::unit_test::Suite
         }
     }
 
+    void
+    testExactLPTokenEquality(FeatureBitset features)
+    {
+        using namespace jtx;
+
+        if (!features[fixAMMv1_3] || !features[fixAMMClawbackRounding])
+            return;
+
+        testcase("test exact LP token equality boundary");
+
+        Env env(*this, features);
+        Account const gw{"gateway"}, alice{"alice"}, bob{"bob"};
+        env.fund(XRP(100000), gw, alice, bob);
+        env.close();
+        env(fset(gw, asfAllowTrustLineClawback));
+        env.close();
+
+        auto const usd = gw["USD"];
+        env.trust(usd(100000), alice);
+        env(pay(gw, alice, usd(50000)));
+        env.trust(usd(100000), bob);
+        env(pay(gw, bob, usd(40000)));
+        env.close();
+
+        // bob keeps alice from being the sole LP, otherwise the clawback
+        // first rewrites the AMM's LP balance to alice's tokens and the
+        // boundary is no longer distinguishable.
+        AMM amm(env, alice, XRP(2), usd(1));
+        amm.deposit(alice, IOUAmount{1'876123487565916, -15});
+        amm.deposit(bob, IOUAmount{1'000'000});
+
+        auto const [amountBalance, amount2Balance, lptAMMBalance] = amm.balances(usd, XRP);
+        auto const aliceLP = amm.getLPTokensBalance(alice);
+        auto const holderLPTokens = STAmount{aliceLP, amm.lptIssue()};
+        BEAST_EXPECT(lptAMMBalance > holderLPTokens);
+
+        // Clawing alice's pro-rata share lands the transactor's computed LP
+        // amount exactly on her balance.
+        auto const amount = toSTAmount(usd, Number{amountBalance} * holderLPTokens / lptAMMBalance);
+        BEAST_EXPECT(
+            toSTAmount(lptAMMBalance.asset(), lptAMMBalance * (Number{amount} / amountBalance)) ==
+            holderLPTokens);
+
+        env(amm::ammClawback(gw, alice, usd, XRP, amount));
+        env.close();
+
+        auto const aliceLPAfter = amm.getLPTokensBalance(alice);
+        if (features[fixCleanup3_4_0])
+        {
+            // Equality takes the withdraw-all path, redeeming alice's tokens
+            // exactly.
+            BEAST_EXPECT(aliceLPAfter == IOUAmount(0));
+        }
+        else
+        {
+            // The fall-through re-rounds the LP amount against the much
+            // larger pool balance, leaving alice with dust.
+            BEAST_EXPECT(aliceLPAfter != IOUAmount(0) && aliceLPAfter < aliceLP);
+        }
+    }
+
     void
     run() override
     {
@@ -2746,6 +2809,7 @@ class AMMClawback_test : public beast::unit_test::Suite
             testAssetFrozen(features);
             testSingleDepositAndClawback(features);
             testLastHolderLPTokenBalance(features);
+            testExactLPTokenEquality(features);
         }
     }
 };

From 36c165f74df17cb813c0b0aa42c1d6954e1fee40 Mon Sep 17 00:00:00 2001
From: Vito Tumas <5780819+Tapanito@users.noreply.github.com>
Date: Wed, 26 Aug 2026 13:38:24 +0000
Subject: [PATCH 28/32] fix: Prevent early loan impairment and due-date
 manipulation (#6557)

Co-authored-by: Ed Hennis 
Co-authored-by: Timur Yalymov <36795566+tyalymov@users.noreply.github.com>
---
 include/xrpl/ledger/helpers/LendingHelpers.h  |   6 +
 src/libxrpl/ledger/helpers/LendingHelpers.cpp |  25 +-
 .../tx/transactors/lending/LoanManage.cpp     |  59 +-
 .../tx/transactors/lending/LoanPay.cpp        |   8 +-
 .../tx/transactors/vault/VaultDelete.cpp      |   1 +
 .../app/invariants/InvariantsVault_test.cpp   |  12 +
 src/test/app/lending/LoanCashBasis_test.cpp   |  11 +-
 .../app/lending/LoanCoverFreezeAuth_test.cpp  |   4 +
 src/test/app/lending/LoanPay_test.cpp         | 393 ++++++++++++
 src/test/app/lending/LoanRounding_test.cpp    |   2 +
 src/test/app/lending/LoanSecurity_test.cpp    | 580 ++++++++++++++++++
 src/test/app/lending/LoanTestBase.h           |  70 ++-
 src/test/app/lending/LoanValidation_test.cpp  |   7 +-
 src/test/app/vault/VaultPrecisionFixture.h    |  22 +-
 .../app/vault/VaultSoleShareholder_test.cpp   |  20 +-
 15 files changed, 1175 insertions(+), 45 deletions(-)

diff --git a/include/xrpl/ledger/helpers/LendingHelpers.h b/include/xrpl/ledger/helpers/LendingHelpers.h
index 4aa89ea672..f3fc82eacb 100644
--- a/include/xrpl/ledger/helpers/LendingHelpers.h
+++ b/include/xrpl/ledger/helpers/LendingHelpers.h
@@ -324,6 +324,12 @@ computeFullPaymentInterest(
     std::uint32_t startDate,
     TenthBips32 closeInterestRate);
 
+// Returns true if the loan's next payment is late per protocol rules. The
+// boundary is amendment-gated: with fixCleanup3_4_0 the due date must be
+// strictly in the past, otherwise the exact due-date instant counts as late.
+[[nodiscard]] bool
+isPaymentLate(ReadView const& view, SLE::const_ref loanSle);
+
 // Deltas applied to Vault.AssetsTotal and LoanBroker.DebtTotal at a single
 // accounting touch point (origination, payment, impair/unimpair/default).
 struct AccountingDeltas
diff --git a/src/libxrpl/ledger/helpers/LendingHelpers.cpp b/src/libxrpl/ledger/helpers/LendingHelpers.cpp
index cf1bd4915f..10c7e62c6c 100644
--- a/src/libxrpl/ledger/helpers/LendingHelpers.cpp
+++ b/src/libxrpl/ledger/helpers/LendingHelpers.cpp
@@ -169,6 +169,16 @@ isRounded(Asset const& asset, Number const& value, std::int32_t scale)
         roundToAsset(asset, value, scale, Number::RoundingMode::Upward);
 }
 
+[[nodiscard]] bool
+isPaymentLate(ReadView const& view, SLE::const_ref loanSle)
+{
+    return hasExpired(
+        view,
+        loanSle->at(sfNextPaymentDueDate),
+        view.rules().enabled(fixCleanup3_4_0) ? ExpiryComparison::Exclusive
+                                              : ExpiryComparison::Inclusive);
+}
+
 namespace accrual {
 
 AccountingDeltas
@@ -514,7 +524,7 @@ loanLatePaymentInterest(
     // If the payment is not late by any amount of time, then there's no late
     // interest
     if (now <= nextPaymentDueDate)
-        return 0;
+        return kNumZero;
 
     // Equation (3) from XLS-66 spec, Section A-2 Equation Glossary
     auto const secondsOverdue = now - nextPaymentDueDate;
@@ -1035,7 +1045,7 @@ doOverpayment(
 std::expected
 computeLatePayment(
     Asset const& asset,
-    ApplyView const& view,
+    ReadView const& view,
     SLE::const_ref loan,
     ExtendedPaymentComponents const& periodic,
     STAmount const& amount,
@@ -1046,8 +1056,11 @@ computeLatePayment(
     std::int32_t const loanScale = loan->at(sfLoanScale);
 
     // Check if the due date has passed. If not, reject the payment as
-    // being too soon
-    if (!hasExpired(view, nextDueDate))
+    // being too soon. Uses isPaymentLate() so this agrees with the
+    // regular payment path on whether the loan is actually late at the
+    // exact due date boundary (amendment-gated: Exclusive once
+    // fixCleanup3_4_0 is enabled, Inclusive otherwise).
+    if (!isPaymentLate(view, loan))
         return std::unexpected(tecTOO_SOON);
 
     // Calculate the penalty interest based on how long the payment is overdue.
@@ -1128,7 +1141,7 @@ computeLatePayment(
 std::expected
 computeFullPayment(
     Asset const& asset,
-    ApplyView& view,
+    ReadView const& view,
     SLE::const_ref loan,
     Number const& periodicRate,
     STAmount const& amount,
@@ -2270,7 +2283,7 @@ loanMakePayment(
 
     // -------------------------------------------------------------
     // A late payment not flagged as late overrides all other options.
-    if (paymentType != LoanPaymentType::Late && hasExpired(view, nextDueDateProxy))
+    if (paymentType != LoanPaymentType::Late && isPaymentLate(view, loan))
     {
         // If the payment is late, and the late flag was not set, it's not
         // valid
diff --git a/src/libxrpl/tx/transactors/lending/LoanManage.cpp b/src/libxrpl/tx/transactors/lending/LoanManage.cpp
index a312dba3b3..2d710ceebe 100644
--- a/src/libxrpl/tx/transactors/lending/LoanManage.cpp
+++ b/src/libxrpl/tx/transactors/lending/LoanManage.cpp
@@ -104,7 +104,11 @@ LoanManage::preclaim(PreclaimContext const& ctx)
         return tecNO_PERMISSION;
     }
     if (tx.isFlag(tfLoanDefault) &&
-        !hasExpired(ctx.view, loanSle->at(sfNextPaymentDueDate) + loanSle->at(sfGracePeriod)))
+        !hasExpired(
+            ctx.view,
+            loanSle->at(sfNextPaymentDueDate) + loanSle->at(sfGracePeriod),
+            ctx.view.rules().enabled(fixCleanup3_4_0) ? ExpiryComparison::Exclusive
+                                                      : ExpiryComparison::Inclusive))
     {
         JLOG(ctx.j.warn()) << "A loan can not be defaulted before the next payment due date.";
         return tecTOO_SOON;
@@ -287,6 +291,14 @@ LoanManage::impairLoan(
     Asset const& vaultAsset,
     beast::Journal j)
 {
+    bool const fixEnabled340 = view.rules().enabled(fixCleanup3_4_0);
+
+    if (fixEnabled340 && !isPaymentLate(view, loanSle))
+    {
+        JLOG(j.warn()) << "Cannot impair a loan that is not late";
+        return tecTOO_SOON;
+    }
+
     Number const lossUnrealized = loanVaultExposure(vaultSle, loanSle);
 
     // The vault may be at a different scale than the loan. Reduce rounding
@@ -301,20 +313,22 @@ LoanManage::impairLoan(
     {
         // Having a loss greater than the vault's unavailable assets
         // will leave the vault in an invalid / inconsistent state.
-        JLOG(j.warn()) << "Vault unrealized loss is too large, and will "
-                          "corrupt the vault.";
+        JLOG(j.warn()) << "Vault unrealized loss is too large, and will corrupt the vault.";
         return tecLIMIT_EXCEEDED;
     }
     view.update(vaultSle);
 
     // Update the Loan object
     loanSle->setFlag(lsfLoanImpaired);
-    auto loanNextDueProxy = loanSle->at(sfNextPaymentDueDate);
-    if (!hasExpired(view, loanNextDueProxy))
+
+    if (!fixEnabled340)
     {
-        // loan payment is not yet late -
-        // move the next payment due date to now
-        loanNextDueProxy = view.parentCloseTime().time_since_epoch().count();
+        auto loanNextDueProxy = loanSle->at(sfNextPaymentDueDate);
+        if (!isPaymentLate(view, loanSle))
+        {
+            // loan payment is not yet late move the next payment due date to now
+            loanNextDueProxy = view.parentCloseTime().time_since_epoch().count();
+        }
     }
     view.update(loanSle);
 
@@ -351,19 +365,24 @@ LoanManage::unimpairLoan(
 
     // Update the Loan object
     loanSle->clearFlag(lsfLoanImpaired);
-    auto const paymentInterval = loanSle->at(sfPaymentInterval);
-    auto const normalPaymentDueDate =
-        std::max(loanSle->at(sfPreviousPaymentDueDate), loanSle->at(sfStartDate)) + paymentInterval;
-    if (!hasExpired(view, normalPaymentDueDate))
+    if (!view.rules().enabled(fixCleanup3_4_0))
     {
-        // loan was unimpaired within the payment interval
-        loanSle->at(sfNextPaymentDueDate) = normalPaymentDueDate;
-    }
-    else
-    {
-        // loan was unimpaired after the original payment due date
-        loanSle->at(sfNextPaymentDueDate) =
-            view.parentCloseTime().time_since_epoch().count() + paymentInterval;
+        auto const paymentInterval = loanSle->at(sfPaymentInterval);
+        auto const normalPaymentDueDate =
+            std::max(loanSle->at(sfPreviousPaymentDueDate), loanSle->at(sfStartDate)) +
+            paymentInterval;
+
+        if (!hasExpired(view, normalPaymentDueDate))
+        {
+            // loan was unimpaired within the payment interval
+            loanSle->at(sfNextPaymentDueDate) = normalPaymentDueDate;
+        }
+        else
+        {
+            // loan was unimpaired after the original payment due date
+            loanSle->at(sfNextPaymentDueDate) =
+                view.parentCloseTime().time_since_epoch().count() + paymentInterval;
+        }
     }
     view.update(loanSle);
 
diff --git a/src/libxrpl/tx/transactors/lending/LoanPay.cpp b/src/libxrpl/tx/transactors/lending/LoanPay.cpp
index 6e3487ec8e..18886b2682 100644
--- a/src/libxrpl/tx/transactors/lending/LoanPay.cpp
+++ b/src/libxrpl/tx/transactors/lending/LoanPay.cpp
@@ -7,7 +7,6 @@
 #include 
 #include 
 #include 
-#include 
 #include 
 #include 
 #include 
@@ -134,10 +133,13 @@ LoanPay::calculateBaseFee(ReadView const& view, STTx const& tx)
         return normalCost;
     }
 
-    if (hasExpired(view, loanSle->at(sfNextPaymentDueDate)))
+    if (isPaymentLate(view, loanSle))
     {
         // If the payment is late, and the late payment flag is not set, it'll
-        // fail
+        // fail. Uses isPaymentLate() so the fee matches apply at the exact
+        // NextPaymentDueDate boundary (Exclusive once fixCleanup3_4_0 is
+        // enabled): a catch-up at that instant can still process up to
+        // kLoanMaximumPaymentsPerTransaction payments.
         return normalCost;
     }
 
diff --git a/src/libxrpl/tx/transactors/vault/VaultDelete.cpp b/src/libxrpl/tx/transactors/vault/VaultDelete.cpp
index f3a587d5a4..35bf80c29f 100644
--- a/src/libxrpl/tx/transactors/vault/VaultDelete.cpp
+++ b/src/libxrpl/tx/transactors/vault/VaultDelete.cpp
@@ -34,6 +34,7 @@ VaultDelete::preflight(PreflightContext const& ctx)
     if (ctx.tx.isFieldPresent(sfMemoData) && !ctx.rules.enabled(featureLendingProtocolV1_1))
         return temDISABLED;
 
+    // The sfMemoData field is an optional field used to record the deletion reason.
     if (!validDataLength(ctx.tx[~sfMemoData], kMaxDataPayloadLength))
         return temMALFORMED;
 
diff --git a/src/test/app/invariants/InvariantsVault_test.cpp b/src/test/app/invariants/InvariantsVault_test.cpp
index 5b2511626e..4b6002580b 100644
--- a/src/test/app/invariants/InvariantsVault_test.cpp
+++ b/src/test/app/invariants/InvariantsVault_test.cpp
@@ -2029,6 +2029,18 @@ class InvariantsVault_test : public InvariantsBase
                 Fee(env.current()->fees().base * 200));
             env.close();
 
+            // Under fixCleanup3_4_0 impair requires the payment to already
+            // be late, so advance past the loan's due date first.
+            if (env.current()->rules().enabled(fixCleanup3_4_0))
+            {
+                auto const loanSle = env.le(loanKeylet);
+                if (!BEAST_EXPECT(loanSle))
+                    return vaultKeylet;
+                std::uint32_t const dueDate = loanSle->at(sfNextPaymentDueDate);
+                env.close(
+                    NetClock::time_point{NetClock::duration{dueDate}} + std::chrono::seconds{1});
+            }
+
             env(manage(owner, loanKeylet.key, tfLoanImpair));
             env.close();
         }
diff --git a/src/test/app/lending/LoanCashBasis_test.cpp b/src/test/app/lending/LoanCashBasis_test.cpp
index 11053b6fd0..a3ca28437d 100644
--- a/src/test/app/lending/LoanCashBasis_test.cpp
+++ b/src/test/app/lending/LoanCashBasis_test.cpp
@@ -562,6 +562,7 @@ private:
             BEAST_EXPECT(vaultBeforeImpair);
             Number const lossBefore = vaultBeforeImpair->at(sfLossUnrealized);
 
+            advancePastDueDate(env, loanKeylet);
             env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
             env.close();
 
@@ -612,6 +613,7 @@ private:
                 ? principalOutstanding
                 : totalValueOutstanding - managementFeeOutstanding;
 
+            advancePastDueDate(env, loanKeylet);
             env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
             env.close();
 
@@ -822,12 +824,17 @@ private:
         Number const managementFeeBeforeImpair = loanBeforeImpair->at(sfManagementFeeOutstanding);
         Number const expectedExposure = totalValueBeforeImpair - managementFeeBeforeImpair;
 
+        // With fixCleanup3_4_0, impairment is only allowed once the
+        // payment is late. After the earlier LoanPay the due date advanced by
+        // one interval, so use the current due date rather than startDate.
+        std::uint32_t const dueDateBeforeImpair = loanBeforeImpair->at(sfNextPaymentDueDate);
+        env.close(NetClock::time_point{NetClock::duration{dueDateBeforeImpair}} + 1s);
+
         env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
         env.close();
 
-        LoanState const stateAtImpair = getCurrentState(env, broker, loanKeylet);
         env.close(
-            stateAtImpair.startDate + std::chrono::seconds(paymentInterval) +
+            NetClock::time_point{NetClock::duration{dueDateBeforeImpair}} +
             std::chrono::seconds(gracePeriod) + 60s);
 
         auto const vaultBeforeDefault = env.le(broker.vaultKeylet());
diff --git a/src/test/app/lending/LoanCoverFreezeAuth_test.cpp b/src/test/app/lending/LoanCoverFreezeAuth_test.cpp
index b0c43190c5..f8bdb5a3d7 100644
--- a/src/test/app/lending/LoanCoverFreezeAuth_test.cpp
+++ b/src/test/app/lending/LoanCoverFreezeAuth_test.cpp
@@ -238,6 +238,9 @@ private:
             Ter(tesSUCCESS));
         env.close();
 
+        // Under fixCleanup3_4_0 impair requires the payment to be late.
+        advancePastDueDate(env, loanKeylet);
+
         // Impair the loan to create unrealized loss
         env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
         env.close();
@@ -461,6 +464,7 @@ private:
         auto const loanKeylet = keylet::loan(brokerInfo.brokerID, SeqProxy::rawSequence(1));
 
         // Realize a loss via impairment before locking.
+        advancePastDueDate(env, loanKeylet);
         env(manage(lender, loanKeylet.key, tfLoanImpair));
         env.close();
 
diff --git a/src/test/app/lending/LoanPay_test.cpp b/src/test/app/lending/LoanPay_test.cpp
index ce08e71932..038ef4067b 100644
--- a/src/test/app/lending/LoanPay_test.cpp
+++ b/src/test/app/lending/LoanPay_test.cpp
@@ -13,8 +13,11 @@
 
 #include 
 #include 
+#include 
 #include 
+#include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -33,6 +36,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 
 namespace xrpl::test {
@@ -1031,10 +1035,399 @@ private:
             borrowerAfter + vaultAfter + lenderAfter);
     }
 
+    // Env::close() cannot land the ledger's parentCloseTime on an arbitrary
+    // instant: it always rounds the requested time forward to the next
+    // close-time-resolution boundary (see Env::close() and
+    // roundCloseTime()/effCloseTime() in LedgerTiming.h), so it can only be
+    // used to reach times strictly *after* a given due date, never exactly
+    // on it. To pin the exact-boundary behavior of isPaymentLate(), directly
+    // overwrite the loan's NextPaymentDueDate so that it matches the
+    // *current* (already fixed) parentCloseTime of the open ledger, without
+    // closing again. This exercises the same comparison
+    // (parentCloseTime vs. NextPaymentDueDate) at the exact boundary that
+    // env.close() cannot reliably reach.
+    void
+    setLoanNextPaymentDueDate(jtx::Env& env, Keylet const& loanKeylet, std::uint32_t dueDate)
+    {
+        using namespace jtx;
+        bool const ok = env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) {
+            auto const sle = view.read(loanKeylet);
+            if (!sle)
+                return false;
+            auto replacement = std::make_shared(*sle);
+            (*replacement)[sfNextPaymentDueDate] = dueDate;
+            view.rawReplace(replacement);
+            return true;
+        });
+        BEAST_EXPECT(ok);
+    }
+
+    // With fixCleanup3_4_0, isPaymentLate() uses a strict (Exclusive)
+    // comparison: a payment due exactly "now" is not yet late. A plain
+    // (non-late) LoanPay submitted at the exact NextPaymentDueDate instant
+    // must therefore succeed, advance the due date by exactly one
+    // PaymentInterval, and charge only the regular periodic payment amount
+    // (no late interest / late fee).
+    void
+    testLoanPayAtExactDueDateSucceedsPostAmendment()
+    {
+        testcase("LoanPay at exact due date succeeds with fixCleanup3_4_0");
+
+        using namespace jtx;
+        using namespace loan;
+
+        Env env(*this, all_);
+        BEAST_EXPECT(env.enabled(fixCleanup3_4_0));
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(10'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const asset{xrpIssue(), 1000};
+        auto const broker = createVaultAndBroker(env, asset, lender);
+
+        auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
+        if (!BEAST_EXPECT(brokerSle))
+            return;
+        auto const loanKeylet =
+            keylet::loan(broker.brokerID, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence)));
+
+        // Set a large, non-zero late interest rate and late fee so that if
+        // the late-payment path were incorrectly taken, the extra charge
+        // would be large and easy to detect (far more than any rounding
+        // slack in the regular periodic payment amount).
+        env(set(borrower, broker.brokerID, asset(1'000).value()),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(12),
+            kPaymentInterval(600),
+            kLateInterestRate(TenthBips32(percentageToTenthBips(24))),
+            kLatePaymentFee(asset(50).value()),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        auto const stateBefore = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(stateBefore.paymentRemaining == 12);
+
+        STAmount const roundedPeriodicPayment{
+            asset, roundPeriodicPayment(asset, stateBefore.periodicPayment, stateBefore.loanScale)};
+
+        // Set NextPaymentDueDate to exactly the current parentCloseTime,
+        // without closing the ledger again.
+        std::uint32_t const exactDueDate =
+            env.current()->parentCloseTime().time_since_epoch().count();
+        setLoanNextPaymentDueDate(env, loanKeylet, exactDueDate);
+
+        STAmount const payFee{env.current()->fees().base};
+        auto const borrowerBefore = env.balance(borrower, asset).number();
+
+        // A plain payment (no tfLoanLatePayment) for exactly the regular
+        // periodic amount must succeed: at this instant the payment is not
+        // yet late.
+        //
+        // Note: deliberately not calling env.close() after this: closing
+        // the ledger re-derives the resulting state from the last validated
+        // ledger plus the recorded transaction set, which would discard the
+        // direct NextPaymentDueDate override made above via rawReplace().
+        // Reading state from the still-open ledger (as env.le()/env.balance()
+        // do) reflects the transaction as it was actually applied.
+        env(pay(borrower, loanKeylet.key, roundedPeriodicPayment), Fee(payFee), Ter(tesSUCCESS));
+
+        auto const borrowerAfter = env.balance(borrower, asset).number();
+
+        // No more than the regular periodic amount (plus the transaction
+        // fee) was charged: if the late-payment path had wrongly been
+        // taken, the (large, non-zero) late interest and late fee set above
+        // would have pushed the charge well past this bound.
+        Number const charged = borrowerBefore - borrowerAfter - Number{payFee};
+        BEAST_EXPECT(charged > Number{});
+        BEAST_EXPECT(charged <= Number{roundedPeriodicPayment});
+
+        auto const stateAfter = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(stateAfter.paymentRemaining == stateBefore.paymentRemaining - 1);
+        BEAST_EXPECT(stateAfter.nextPaymentDate == exactDueDate + stateBefore.paymentInterval);
+    }
+
+    // Pins the amendment gate itself (as opposed to
+    // testLoanPayAtExactDueDateSucceedsPostAmendment, which pins the
+    // comparison operator): without fixCleanup3_4_0, isPaymentLate() keeps
+    // using the pre-amendment Inclusive comparison, so a payment due exactly
+    // "now" is already considered late, and a plain (non-late) LoanPay is
+    // rejected.
+    void
+    testLoanPayAtExactDueDateFailsPreAmendment()
+    {
+        testcase("LoanPay at exact due date fails without fixCleanup3_4_0");
+
+        using namespace jtx;
+        using namespace loan;
+
+        Env env(*this, all_ - fixCleanup3_4_0);
+        BEAST_EXPECT(!env.enabled(fixCleanup3_4_0));
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(10'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const asset{xrpIssue(), 1000};
+        auto const broker = createVaultAndBroker(env, asset, lender);
+
+        auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
+        if (!BEAST_EXPECT(brokerSle))
+            return;
+        auto const loanKeylet =
+            keylet::loan(broker.brokerID, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence)));
+
+        env(set(borrower, broker.brokerID, asset(1'000).value()),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(12),
+            kPaymentInterval(600),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        auto const stateBefore = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(stateBefore.paymentRemaining == 12);
+
+        STAmount const roundedPeriodicPayment{
+            asset, roundPeriodicPayment(asset, stateBefore.periodicPayment, stateBefore.loanScale)};
+
+        // Set NextPaymentDueDate to exactly the current parentCloseTime,
+        // without closing the ledger again.
+        std::uint32_t const exactDueDate =
+            env.current()->parentCloseTime().time_since_epoch().count();
+        setLoanNextPaymentDueDate(env, loanKeylet, exactDueDate);
+
+        // Without the amendment, the due date is already considered late at
+        // this exact instant, so a plain payment must be rejected.
+        //
+        // Note: deliberately not calling env.close() after this: closing
+        // the ledger re-derives the resulting state from the last validated
+        // ledger plus the recorded transaction set, which would discard the
+        // direct NextPaymentDueDate override made above via rawReplace().
+        // Reading state from the still-open ledger (as env.le() does)
+        // reflects the transaction as it was actually applied.
+        env(pay(borrower, loanKeylet.key, roundedPeriodicPayment), Ter(tecEXPIRED));
+
+        auto const stateAfter = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(stateAfter.paymentRemaining == stateBefore.paymentRemaining);
+        BEAST_EXPECT(stateAfter.nextPaymentDate == exactDueDate);
+    }
+
+    // computeLatePayment() must agree with isPaymentLate() at the exact
+    // due-date boundary: once fixCleanup3_4_0 is enabled, a payment due
+    // exactly "now" is not yet late, so a tfLoanLatePayment submitted at
+    // that same instant must be rejected with tecTOO_SOON rather than being
+    // admitted and charged the late interest/fee.
+    void
+    testLoanLatePaymentAtExactDueDateRejectedPostAmendment()
+    {
+        testcase("LoanPay(tfLoanLatePayment) at exact due date rejected with fixCleanup3_4_0");
+
+        using namespace jtx;
+        using namespace loan;
+
+        Env env(*this, all_);
+        BEAST_EXPECT(env.enabled(fixCleanup3_4_0));
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(10'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const asset{xrpIssue(), 1000};
+        auto const broker = createVaultAndBroker(env, asset, lender);
+
+        auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
+        if (!BEAST_EXPECT(brokerSle))
+            return;
+        auto const loanKeylet =
+            keylet::loan(broker.brokerID, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence)));
+
+        env(set(borrower, broker.brokerID, asset(1'000).value()),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(12),
+            kPaymentInterval(600),
+            kLateInterestRate(TenthBips32(percentageToTenthBips(24))),
+            kLatePaymentFee(asset(50).value()),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        auto const stateBefore = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(stateBefore.paymentRemaining == 12);
+
+        // Overpay generously so that, if the late-payment path were
+        // incorrectly admitted, funds would not be the limiting factor;
+        // we want to isolate the timing check itself.
+        STAmount const generousAmount{
+            asset,
+            roundPeriodicPayment(asset, stateBefore.periodicPayment, stateBefore.loanScale) * 2};
+
+        // Set NextPaymentDueDate to exactly the current parentCloseTime,
+        // without closing the ledger again.
+        std::uint32_t const exactDueDate =
+            env.current()->parentCloseTime().time_since_epoch().count();
+        setLoanNextPaymentDueDate(env, loanKeylet, exactDueDate);
+
+        // At this exact instant the loan is not yet late (Exclusive
+        // comparison), so even an explicit late payment must be rejected
+        // as premature, matching the plain-payment path.
+        //
+        // Note: deliberately not calling env.close() after this, for the
+        // same reason given in testLoanPayAtExactDueDateSucceedsPostAmendment
+        // above: closing would discard the direct NextPaymentDueDate
+        // override made via rawReplace().
+        env(pay(borrower, loanKeylet.key, generousAmount, tfLoanLatePayment), Ter(tecTOO_SOON));
+
+        auto const stateAfter = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(stateAfter.paymentRemaining == stateBefore.paymentRemaining);
+        BEAST_EXPECT(stateAfter.nextPaymentDate == exactDueDate);
+    }
+
+    // calculateBaseFee must use isPaymentLate(), not a raw inclusive
+    // hasExpired(): once fixCleanup3_4_0 is enabled, a plain catch-up at
+    // exactly NextPaymentDueDate succeeds and can process many payments, so
+    // the fee has to scale with that work. Charging a single base fee here
+    // would disagree with apply (and with the fixCleanup3_1_3 cap).
+    void
+    testLoanPayCatchUpFeeAtExactDueDatePostAmendment()
+    {
+        testcase("LoanPay catch-up fee at exact due date with fixCleanup3_4_0");
+
+        using namespace jtx;
+        using namespace loan;
+        using namespace lending;
+
+        Env env(*this, all_);
+        BEAST_EXPECT(env.enabled(fixCleanup3_4_0));
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(10'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const asset{xrpIssue(), 1000};
+        auto const broker = createVaultAndBroker(env, asset, lender);
+
+        auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
+        if (!BEAST_EXPECT(brokerSle))
+            return;
+        auto const loanKeylet =
+            keylet::loan(broker.brokerID, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence)));
+
+        env(set(borrower, broker.brokerID, asset(10'000).value()),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(50),
+            kPaymentInterval(600),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        auto const stateBefore = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(stateBefore.paymentRemaining == 50);
+        BEAST_EXPECT(stateBefore.paymentRemaining > kLoanPaymentsPerFeeIncrement);
+
+        auto const loanSle = env.le(loanKeylet);
+        if (!BEAST_EXPECT(loanSle))
+            return;
+        Number const regularPayment =
+            roundPeriodicPayment(asset, stateBefore.periodicPayment, stateBefore.loanScale) +
+            loanSle->at(sfLoanServiceFee);
+        int const payCount = kLoanPaymentsPerFeeIncrement * 4;
+        STAmount const catchUp{asset, regularPayment * payCount};
+        XRPAmount const baseFee = env.current()->fees().base;
+        XRPAmount const escalatedFee{baseFee * (payCount / kLoanPaymentsPerFeeIncrement)};
+
+        std::uint32_t const exactDueDate =
+            env.current()->parentCloseTime().time_since_epoch().count();
+        setLoanNextPaymentDueDate(env, loanKeylet, exactDueDate);
+
+        // Under-fee: apply would process `payCount` payments, so a single
+        // base fee is not enough.
+        env(pay(borrower, loanKeylet.key, catchUp), Fee(baseFee), Ter(telINSUF_FEE_P));
+
+        // Same catch-up with the scaled fee must succeed at this instant.
+        // Do not env.close() after the SLE override (see
+        // testLoanPayAtExactDueDateSucceedsPostAmendment).
+        env(pay(borrower, loanKeylet.key, catchUp), Fee(escalatedFee), Ter(tesSUCCESS));
+
+        auto const stateAfter = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(stateAfter.paymentRemaining == stateBefore.paymentRemaining - payCount);
+    }
+
+    // Without the amendment, inclusive hasExpired still treats the exact
+    // due-date instant as late, so calculateBaseFee correctly charges a
+    // single base fee and apply rejects a plain LoanPay with tecEXPIRED.
+    void
+    testLoanPayCatchUpFeeAtExactDueDatePreAmendment()
+    {
+        testcase("LoanPay catch-up fee at exact due date without fixCleanup3_4_0");
+
+        using namespace jtx;
+        using namespace loan;
+        using namespace lending;
+
+        Env env(*this, all_ - fixCleanup3_4_0);
+        BEAST_EXPECT(!env.enabled(fixCleanup3_4_0));
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(10'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const asset{xrpIssue(), 1000};
+        auto const broker = createVaultAndBroker(env, asset, lender);
+
+        auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
+        if (!BEAST_EXPECT(brokerSle))
+            return;
+        auto const loanKeylet =
+            keylet::loan(broker.brokerID, SeqProxy::rawSequence(brokerSle->at(sfLoanSequence)));
+
+        env(set(borrower, broker.brokerID, asset(10'000).value()),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(50),
+            kPaymentInterval(600),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        auto const stateBefore = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(stateBefore.paymentRemaining == 50);
+
+        auto const loanSle = env.le(loanKeylet);
+        if (!BEAST_EXPECT(loanSle))
+            return;
+        Number const regularPayment =
+            roundPeriodicPayment(asset, stateBefore.periodicPayment, stateBefore.loanScale) +
+            loanSle->at(sfLoanServiceFee);
+        int const payCount = kLoanPaymentsPerFeeIncrement * 4;
+        STAmount const catchUp{asset, regularPayment * payCount};
+        XRPAmount const baseFee = env.current()->fees().base;
+
+        std::uint32_t const exactDueDate =
+            env.current()->parentCloseTime().time_since_epoch().count();
+        setLoanNextPaymentDueDate(env, loanKeylet, exactDueDate);
+
+        env(pay(borrower, loanKeylet.key, catchUp), Fee(baseFee), Ter(tecEXPIRED));
+
+        auto const stateAfter = getCurrentState(env, broker, loanKeylet);
+        BEAST_EXPECT(stateAfter.paymentRemaining == stateBefore.paymentRemaining);
+        BEAST_EXPECT(stateAfter.nextPaymentDate == exactDueDate);
+    }
+
     void
     runAmendmentIndependent()
     {
         testLoanSetNearZeroInterestRateSucceeds();
+        testLoanPayAtExactDueDateSucceedsPostAmendment();
+        testLoanPayAtExactDueDateFailsPreAmendment();
+        testLoanLatePaymentAtExactDueDateRejectedPostAmendment();
+        testLoanPayCatchUpFeeAtExactDueDatePostAmendment();
+        testLoanPayCatchUpFeeAtExactDueDatePreAmendment();
         testRepayIntoUnauthorizedVault();
     }
 
diff --git a/src/test/app/lending/LoanRounding_test.cpp b/src/test/app/lending/LoanRounding_test.cpp
index b666281fee..ded1c816a2 100644
--- a/src/test/app/lending/LoanRounding_test.cpp
+++ b/src/test/app/lending/LoanRounding_test.cpp
@@ -948,6 +948,7 @@ private:
         env.close();
 
         // Impair the loan so LossUnrealized > 0.
+        advancePastDueDate(env, loanKeylet);
         env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
         env.close();
 
@@ -1017,6 +1018,7 @@ private:
                 Ter(tesSUCCESS));
             env.close();
 
+            advancePastDueDate(env, iouLoanKeylet);
             env(manage(iouLender, iouLoanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
             env.close();
 
diff --git a/src/test/app/lending/LoanSecurity_test.cpp b/src/test/app/lending/LoanSecurity_test.cpp
index b878547a70..463273e227 100644
--- a/src/test/app/lending/LoanSecurity_test.cpp
+++ b/src/test/app/lending/LoanSecurity_test.cpp
@@ -5,27 +5,36 @@
 #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 {
@@ -33,6 +42,30 @@ namespace xrpl::test {
 class LoanSecurity_test : public LoanTestBase
 {
 private:
+    // Env::close() cannot land the ledger's parentCloseTime on an arbitrary
+    // instant: it always rounds the requested time forward to the next
+    // close-time-resolution boundary (see Env::close() and
+    // roundCloseTime()/effCloseTime() in LedgerTiming.h), so it can only be
+    // used to reach times strictly *after* a given due date, never exactly
+    // on it. To pin the exact-boundary behavior of isPaymentLate(), directly
+    // overwrite the loan's NextPaymentDueDate instead, without closing the
+    // ledger again.
+    void
+    setLoanNextPaymentDueDate(jtx::Env& env, Keylet const& loanKeylet, std::uint32_t dueDate)
+    {
+        using namespace jtx;
+        bool const ok = env.app().getOpenLedger().modify([&](OpenView& view, beast::Journal) {
+            auto const sle = view.read(loanKeylet);
+            if (!sle)
+                return false;
+            auto replacement = std::make_shared(*sle);
+            (*replacement)[sfNextPaymentDueDate] = dueDate;
+            view.rawReplace(replacement);
+            return true;
+        });
+        BEAST_EXPECT(ok);
+    }
+
     void
     testPoCUnsignedUnderflowOnFullPayAfterEarlyPeriodic(FeatureBitset features)
     {
@@ -516,10 +549,557 @@ private:
             PaymentParameters{.showStepBalances = true});
     }
 
+    // Verify that with fixCleanup3_4_0:
+    // 1. A loan cannot be impaired before its payment is late.
+    // 2. Impairing a late loan does not change sfNextPaymentDueDate.
+    // 3. The unimpair operation does not change sfNextPaymentDueDate.
+    void
+    testImpairmentPaymentDateUnchanged()
+    {
+        using namespace jtx;
+        using namespace loan;
+        using namespace std::chrono_literals;
+
+        testcase("Impairment does not change payment due date");
+
+        Env env(*this, all_ | fixCleanup3_4_0);
+        BEAST_EXPECT(env.enabled(fixCleanup3_4_0));
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(100'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+        auto const broker = createVaultAndBroker(env, xrpAsset, lender);
+
+        auto const sleBroker = env.le(keylet::loanBroker(broker.brokerID));
+        if (!BEAST_EXPECT(sleBroker))
+            return;
+        auto const loanKeylet =
+            keylet::loan(broker.brokerID, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence)));
+
+        Number const principalRequest{1, 3};
+        env(set(borrower, broker.brokerID, broker.asset(principalRequest).value()),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(12),
+            kPaymentInterval(600),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        auto const loanSle = env.le(loanKeylet);
+        if (!BEAST_EXPECT(loanSle))
+            return;
+        std::uint32_t const originalNextDueDate = loanSle->at(sfNextPaymentDueDate);
+        BEAST_EXPECT(originalNextDueDate > 0);
+
+        // 1. Impairment must fail when payment is not yet late
+        env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecTOO_SOON));
+
+        {
+            auto const loan = env.le(loanKeylet);
+            BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == originalNextDueDate);
+        }
+
+        // 1b. Impairment must still fail at the exact due date instant: a
+        // payment due "now" is not yet late (strict/Exclusive comparison).
+        // Temporarily set NextPaymentDueDate to exactly the current
+        // parentCloseTime (without closing the ledger again), exercise the
+        // check, then restore the original due date.
+        {
+            std::uint32_t const exactNow =
+                env.current()->parentCloseTime().time_since_epoch().count();
+            setLoanNextPaymentDueDate(env, loanKeylet, exactNow);
+
+            env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecTOO_SOON));
+
+            setLoanNextPaymentDueDate(env, loanKeylet, originalNextDueDate);
+        }
+
+        {
+            auto const loan = env.le(loanKeylet);
+            BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == originalNextDueDate);
+        }
+
+        env.close(NetClock::time_point{NetClock::duration{originalNextDueDate}} + 1s);
+
+        // 2. Impairment succeeds when payment is late
+        env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
+
+        {
+            auto const loan = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loan))
+                return;
+            BEAST_EXPECT(loan->isFlag(lsfLoanImpaired));
+            BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == originalNextDueDate);
+        }
+
+        // 3. Unimpair also does not change sfNextPaymentDueDate
+        env(manage(lender, loanKeylet.key, tfLoanUnimpair), Ter(tesSUCCESS));
+
+        {
+            auto const loan = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loan))
+                return;
+            BEAST_EXPECT(!loan->isFlag(lsfLoanImpaired));
+            BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == originalNextDueDate);
+        }
+    }
+
+    // Verify that without fixCleanup3_4_0, the pre-amendment
+    // impair/unimpair behaviour is preserved:
+    // 1. Impairing a loan before its payment is late moves
+    //    sfNextPaymentDueDate to "now".
+    // 2a. Unimpair within the original payment interval restores
+    //     sfNextPaymentDueDate to StartDate + PaymentInterval.
+    // 2b. Unimpair after the original due date sets
+    //     sfNextPaymentDueDate to now + PaymentInterval.
+    void
+    testImpairmentPaymentDatePreAmendment()
+    {
+        using namespace jtx;
+        using namespace loan;
+        using namespace std::chrono_literals;
+
+        testcase("Pre-amendment impair/unimpair date restoration");
+
+        Env env(*this, all_ - fixCleanup3_4_0);
+        BEAST_EXPECT(!env.enabled(fixCleanup3_4_0));
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(100'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+        auto const broker = createVaultAndBroker(env, xrpAsset, lender);
+
+        Number const principalRequest{1, 3};
+        auto createNewLoan = [&]() {
+            auto const sleBroker = env.le(keylet::loanBroker(broker.brokerID));
+            if (!BEAST_EXPECT(sleBroker))
+                return keylet::loan(uint256{});
+            auto const lk =
+                keylet::loan(broker.brokerID, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence)));
+            env(set(borrower, broker.brokerID, broker.asset(principalRequest).value()),
+                Sig(sfCounterpartySignature, lender),
+                kPaymentTotal(12),
+                kPaymentInterval(600),
+                Fee(env.current()->fees().base * 2));
+            env.close();
+            return lk;
+        };
+
+        // Default + delete a loan and replenish first-loss capital so the
+        // broker is ready for the next loan.
+        auto cleanupLoan = [&](Keylet const& loanKeylet, std::uint32_t dueDate) {
+            env.close(NetClock::time_point{NetClock::duration{dueDate + 60}} + 1s);
+            env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS));
+            env.close();
+
+            auto const brokerSle = env.le(keylet::loanBroker(broker.brokerID));
+            if (!BEAST_EXPECT(brokerSle))
+                return;
+            auto const coverNeeded =
+                broker.asset(broker.params.coverDeposit).value() - brokerSle->at(sfCoverAvailable);
+            if (coverNeeded > 0)
+            {
+                env(loan_broker::coverDeposit(
+                    lender, broker.brokerID, STAmount{broker.asset, coverNeeded}));
+                env.close();
+            }
+            env(del(lender, loanKeylet.key));
+            env.close();
+        };
+
+        // ---- Case A: impair before late, unimpair within original interval ----
+        {
+            auto const loanKeylet = createNewLoan();
+            auto const loanSle = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loanSle))
+                return;
+            std::uint32_t const startDate = loanSle->at(sfStartDate);
+            std::uint32_t const originalNextDueDate = loanSle->at(sfNextPaymentDueDate);
+            BEAST_EXPECT(originalNextDueDate == startDate + 600);
+
+            // Payment is not late yet - impair succeeds and moves due date
+            // to now (pre-amendment allows immediate impairment)
+            env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
+
+            {
+                auto const loan = env.le(loanKeylet);
+                if (!BEAST_EXPECT(loan))
+                    return;
+                BEAST_EXPECT(loan->isFlag(lsfLoanImpaired));
+                std::uint32_t const movedDueDate = loan->at(sfNextPaymentDueDate);
+                BEAST_EXPECT(movedDueDate != originalNextDueDate);
+                BEAST_EXPECT(movedDueDate < originalNextDueDate);
+            }
+
+            // Unimpair while still within the original payment interval. The
+            // normal due date (startDate + 600) has not yet expired, so it
+            // should be restored.
+            env(manage(lender, loanKeylet.key, tfLoanUnimpair), Ter(tesSUCCESS));
+
+            {
+                auto const loan = env.le(loanKeylet);
+                if (!BEAST_EXPECT(loan))
+                    return;
+                BEAST_EXPECT(!loan->isFlag(lsfLoanImpaired));
+                BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == originalNextDueDate);
+            }
+
+            cleanupLoan(loanKeylet, originalNextDueDate);
+        }
+
+        // ---- Case B: impair before late, unimpair after original due date ----
+        {
+            auto const loanKeylet = createNewLoan();
+            auto const loanSle = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loanSle))
+                return;
+            std::uint32_t const startDate = loanSle->at(sfStartDate);
+            std::uint32_t const originalNextDueDate = loanSle->at(sfNextPaymentDueDate);
+            BEAST_EXPECT(originalNextDueDate == startDate + 600);
+
+            env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
+
+            env.close(NetClock::time_point{NetClock::duration{originalNextDueDate}} + 10s);
+
+            auto const timeBeforeUnimpair =
+                env.current()->header().parentCloseTime.time_since_epoch().count();
+
+            env(manage(lender, loanKeylet.key, tfLoanUnimpair), Ter(tesSUCCESS));
+
+            {
+                auto const loan = env.le(loanKeylet);
+                if (!BEAST_EXPECT(loan))
+                    return;
+                BEAST_EXPECT(!loan->isFlag(lsfLoanImpaired));
+                std::uint32_t const newDueDate = loan->at(sfNextPaymentDueDate);
+                BEAST_EXPECT(newDueDate > originalNextDueDate);
+                BEAST_EXPECT(newDueDate == timeBeforeUnimpair + 600);
+            }
+        }
+    }
+
+    // FN-68: a borrower must not be able to bypass late-payment charges by
+    // paying an impaired, overdue loan with a plain LoanPay. Under
+    // fixCleanup3_4_0 impairment no longer moves the due date, so
+    // the payment logic sees the real (overdue) date: a regular payment is
+    // rejected with tecEXPIRED, and only a tfLoanLatePayment (which charges
+    // the late fee + late interest) is accepted.
+    void
+    testImpairedOverdueLoanPayRequiresLateFlag()
+    {
+        using namespace jtx;
+        using namespace loan;
+        using namespace std::chrono_literals;
+
+        testcase("Impaired overdue LoanPay requires late-payment flag");
+
+        Env env(*this, all_ | fixCleanup3_4_0);
+        BEAST_EXPECT(env.enabled(fixCleanup3_4_0));
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(100'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+        auto const broker = createVaultAndBroker(env, xrpAsset, lender);
+
+        auto const sleBroker = env.le(keylet::loanBroker(broker.brokerID));
+        if (!BEAST_EXPECT(sleBroker))
+            return;
+        auto const loanKeylet =
+            keylet::loan(broker.brokerID, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence)));
+
+        // Loan with non-zero late-payment terms, so the late path carries a
+        // real penalty that the exploit would otherwise avoid.
+        Number const principalRequest{1, 3};
+        env(set(borrower, broker.brokerID, broker.asset(principalRequest).value()),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(12),
+            kPaymentInterval(600),
+            kLatePaymentFee(broker.asset(3).number()),
+            kLateInterestRate(TenthBips32{30322}),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        auto const loanSle = env.le(loanKeylet);
+        if (!BEAST_EXPECT(loanSle))
+            return;
+        std::uint32_t const originalNextDueDate = loanSle->at(sfNextPaymentDueDate);
+        std::uint32_t const paymentsBefore = loanSle->at(sfPaymentRemaining);
+        BEAST_EXPECT(originalNextDueDate > 0);
+
+        // Advance past the due date so the loan is overdue, then impair it
+        // (impairment is only allowed once the payment is late).
+        env.close(NetClock::time_point{NetClock::duration{originalNextDueDate}} + 1s);
+        env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
+        env.close();
+
+        {
+            auto const loan = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loan))
+                return;
+            BEAST_EXPECT(loan->isFlag(lsfLoanImpaired));
+            BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == originalNextDueDate);
+        }
+
+        auto const payAmount = broker.asset(500).value();
+
+        // The exploit: a plain LoanPay (Flags = 0) on an impaired, overdue
+        // loan must be rejected. Before FN-9 the auto-unimpair pushed the due
+        // date into the future and this returned tesSUCCESS, letting the
+        // borrower skip the late fee and late interest.
+        env(pay(borrower, loanKeylet.key, payAmount), Ter(tecEXPIRED));
+        env.close();
+
+        {
+            auto const loan = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loan))
+                return;
+            BEAST_EXPECT(loan->isFlag(lsfLoanImpaired));
+            BEAST_EXPECT(loan->at(sfPaymentRemaining) == paymentsBefore);
+            BEAST_EXPECT(loan->at(sfNextPaymentDueDate) == originalNextDueDate);
+        }
+
+        env(pay(borrower, loanKeylet.key, payAmount, tfLoanLatePayment), Ter(tesSUCCESS));
+        env.close();
+        {
+            auto const loan = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loan))
+                return;
+            BEAST_EXPECT(!loan->isFlag(lsfLoanImpaired));
+            BEAST_EXPECT(loan->at(sfPaymentRemaining) == paymentsBefore - 1);
+        }
+
+        {
+            auto const vaultSle = env.le(broker.vaultKeylet());
+            if (!BEAST_EXPECT(vaultSle))
+                return;
+            BEAST_EXPECT(vaultSle->at(sfLossUnrealized) == 0);
+        }
+    }
+
+    // FN-68 (pre-amendment): documents the original vulnerability. Without
+    // fixCleanup3_4_0, impairing moves the due date and LoanPay
+    // auto-unimpair pushes it into the future before the late check, so a
+    // plain (Flags = 0) LoanPay on an impaired, overdue loan is accepted as
+    // on-time (tesSUCCESS) and the borrower dodges the late-payment charges.
+    // This is what testImpairedOverdueLoanPayRequiresLateFlag closes once the
+    // amendment is enabled.
+    void
+    testImpairedOverdueLoanPayBypassPreAmendment()
+    {
+        using namespace jtx;
+        using namespace loan;
+        using namespace std::chrono_literals;
+
+        testcase("Impaired overdue LoanPay bypass (pre-amendment)");
+
+        Env env(*this, all_ - fixCleanup3_4_0);
+        BEAST_EXPECT(!env.enabled(fixCleanup3_4_0));
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(100'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+        auto const broker = createVaultAndBroker(env, xrpAsset, lender);
+
+        auto const sleBroker = env.le(keylet::loanBroker(broker.brokerID));
+        if (!BEAST_EXPECT(sleBroker))
+            return;
+        auto const loanKeylet =
+            keylet::loan(broker.brokerID, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence)));
+
+        Number const principalRequest{1, 3};
+        env(set(borrower, broker.brokerID, broker.asset(principalRequest).value()),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(12),
+            kPaymentInterval(600),
+            kLatePaymentFee(broker.asset(3).number()),
+            kLateInterestRate(TenthBips32{30322}),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        auto const loanSle = env.le(loanKeylet);
+        if (!BEAST_EXPECT(loanSle))
+            return;
+        std::uint32_t const originalNextDueDate = loanSle->at(sfNextPaymentDueDate);
+        BEAST_EXPECT(originalNextDueDate > 0);
+
+        env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
+        env.close();
+
+        env.close(NetClock::time_point{NetClock::duration{originalNextDueDate}} + 1s);
+
+        {
+            auto const loan = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loan))
+                return;
+            BEAST_EXPECT(loan->isFlag(lsfLoanImpaired));
+        }
+
+        auto const payAmount = broker.asset(500).value();
+
+        // The bug: a plain LoanPay is accepted as on-time and clears the
+        // loan's impaired flag, so the late fee / late interest are never
+        // charged.
+        env(pay(borrower, loanKeylet.key, payAmount), Ter(tesSUCCESS));
+        env.close();
+        {
+            auto const loan = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loan))
+                return;
+            BEAST_EXPECT(!loan->isFlag(lsfLoanImpaired));
+        }
+    }
+
+    // Default uses NextPaymentDueDate + GracePeriod. Once fixCleanup3_4_0
+    // is enabled, that gate is Exclusive, matching impair/isPaymentLate:
+    // default is allowed only after grace has passed, not at the instant
+    // it expires.
+    void
+    testLoanDefaultAtExactGraceExpiryRejectedPostAmendment()
+    {
+        testcase("LoanManage default at exact grace expiry rejected with fixCleanup3_4_0");
+
+        using namespace jtx;
+        using namespace loan;
+        using namespace std::chrono_literals;
+
+        Env env(*this, all_);
+        BEAST_EXPECT(env.enabled(fixCleanup3_4_0));
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(100'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+        auto const broker = createVaultAndBroker(env, xrpAsset, lender);
+
+        auto const sleBroker = env.le(keylet::loanBroker(broker.brokerID));
+        if (!BEAST_EXPECT(sleBroker))
+            return;
+        auto const loanKeylet =
+            keylet::loan(broker.brokerID, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence)));
+
+        env(set(borrower, broker.brokerID, broker.asset(Number{1, 3}).value()),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(12),
+            kPaymentInterval(600),
+            kGracePeriod(60),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        // Advance far enough that parentCloseTime > GracePeriod, so
+        // (now - grace) cannot underflow when pinning the exact expiry.
+        env.close(env.now() + 1000s);
+
+        auto const loanSle = env.le(loanKeylet);
+        if (!BEAST_EXPECT(loanSle))
+            return;
+        auto const grace = loanSle->at(sfGracePeriod);
+        std::uint32_t const now = env.current()->parentCloseTime().time_since_epoch().count();
+        BEAST_EXPECT(now > grace + 1);
+
+        // parentCloseTime == NextPaymentDueDate + GracePeriod: grace expires
+        // this instant, so default must still be too soon.
+        setLoanNextPaymentDueDate(env, loanKeylet, now - grace);
+        env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tecTOO_SOON));
+        {
+            auto const loan = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loan))
+                return;
+            BEAST_EXPECT(!loan->isFlag(lsfLoanDefault));
+        }
+
+        // One second after grace expires, default succeeds.
+        setLoanNextPaymentDueDate(env, loanKeylet, now - grace - 1);
+        env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS));
+        {
+            auto const loan = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loan))
+                return;
+            BEAST_EXPECT(loan->isFlag(lsfLoanDefault));
+        }
+    }
+
+    void
+    testLoanDefaultAtExactGraceExpirySucceedsPreAmendment()
+    {
+        testcase("LoanManage default at exact grace expiry succeeds without fixCleanup3_4_0");
+
+        using namespace jtx;
+        using namespace loan;
+        using namespace std::chrono_literals;
+
+        Env env(*this, all_ - fixCleanup3_4_0);
+        BEAST_EXPECT(!env.enabled(fixCleanup3_4_0));
+
+        Account const lender{"lender"};
+        Account const borrower{"borrower"};
+
+        env.fund(XRP(100'000'000), lender, borrower);
+        env.close();
+
+        PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
+        auto const broker = createVaultAndBroker(env, xrpAsset, lender);
+
+        auto const sleBroker = env.le(keylet::loanBroker(broker.brokerID));
+        if (!BEAST_EXPECT(sleBroker))
+            return;
+        auto const loanKeylet =
+            keylet::loan(broker.brokerID, SeqProxy::rawSequence(sleBroker->at(sfLoanSequence)));
+
+        env(set(borrower, broker.brokerID, broker.asset(Number{1, 3}).value()),
+            Sig(sfCounterpartySignature, lender),
+            kPaymentTotal(12),
+            kPaymentInterval(600),
+            kGracePeriod(60),
+            Fee(env.current()->fees().base * 2));
+        env.close();
+
+        env.close(env.now() + 1000s);
+
+        auto const loanSle = env.le(loanKeylet);
+        if (!BEAST_EXPECT(loanSle))
+            return;
+        auto const grace = loanSle->at(sfGracePeriod);
+        std::uint32_t const now = env.current()->parentCloseTime().time_since_epoch().count();
+        BEAST_EXPECT(now > grace);
+
+        setLoanNextPaymentDueDate(env, loanKeylet, now - grace);
+        env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS));
+        {
+            auto const loan = env.le(loanKeylet);
+            if (!BEAST_EXPECT(loan))
+                return;
+            BEAST_EXPECT(loan->isFlag(lsfLoanDefault));
+        }
+    }
+
     void
     runAmendmentIndependent()
     {
         testRIPD3901();
+        testImpairmentPaymentDateUnchanged();
+        testImpairmentPaymentDatePreAmendment();
+        testImpairedOverdueLoanPayRequiresLateFlag();
+        testImpairedOverdueLoanPayBypassPreAmendment();
+        testLoanDefaultAtExactGraceExpiryRejectedPostAmendment();
+        testLoanDefaultAtExactGraceExpirySucceedsPreAmendment();
     }
 
     // Tests run under each entry in amendmentCombinations().
diff --git a/src/test/app/lending/LoanTestBase.h b/src/test/app/lending/LoanTestBase.h
index 99b387938e..c9d4a3185b 100644
--- a/src/test/app/lending/LoanTestBase.h
+++ b/src/test/app/lending/LoanTestBase.h
@@ -674,6 +674,23 @@ protected:
         return true;
     }
 
+    // Under fixCleanup3_4_0, LoanManage rejects tfLoanImpair with tecTOO_SOON
+    // unless the loan payment is already late. Advance the ledger past the
+    // loan's sfNextPaymentDueDate so shared lifecycle flows still exercise
+    // the tesSUCCESS branch when the amendment is active. No-op when the
+    // amendment is disabled.
+    void
+    advancePastDueDate(jtx::Env& env, Keylet const& loanKeylet)
+    {
+        if (!env.current()->rules().enabled(fixCleanup3_4_0))
+            return;
+        auto const loan = env.le(loanKeylet);
+        if (!BEAST_EXPECT(loan))
+            return;
+        std::uint32_t const dueDate = loan->at(sfNextPaymentDueDate);
+        env.close(NetClock::time_point{NetClock::duration{dueDate}} + std::chrono::seconds{1});
+    }
+
     enum class AssetType { XRP = 0, IOU = 1, MPT = 2 };
 
     // Specify the accounts as params to allow other accounts to be used
@@ -1592,12 +1609,30 @@ protected:
 
         // Check the vault
         bool const canImpair = canImpairLoan(env, broker, state);
-        // Impair the loan, if possible
-        env(manage(lender, keylet.key, tfLoanImpair),
-            canImpair ? Ter(tesSUCCESS) : Ter(tecLIMIT_EXCEEDED));
-        // Unimpair the loan
-        env(manage(lender, keylet.key, tfLoanUnimpair),
-            canImpair ? Ter(tesSUCCESS) : Ter(tecNO_PERMISSION));
+        // Under fixCleanup3_4_0, impair rejects a not-yet-late loan with
+        // tecTOO_SOON. Advancing time to satisfy the gate here would push
+        // the loan into a "late" state and break the toEndOfLife flows
+        // (singlePayment/fullPayment) that expect a fresh loan without the
+        // tfLoanLatePayment flag. The tesSUCCESS/tecLIMIT_EXCEEDED impair
+        // path is already covered under fixCleanup3_4_0 by dedicated tests
+        // in LoanSecurity_test.cpp and LoanCashBasis_test.cpp.
+        if (!env.current()->rules().enabled(fixCleanup3_4_0))
+        {
+            // Impair the loan, if possible
+            env(manage(lender, keylet.key, tfLoanImpair),
+                canImpair ? Ter(tesSUCCESS) : Ter(tecLIMIT_EXCEEDED));
+            // Unimpair the loan
+            env(manage(lender, keylet.key, tfLoanUnimpair),
+                canImpair ? Ter(tesSUCCESS) : Ter(tecNO_PERMISSION));
+        }
+        else
+        {
+            // With the fix on, a not-yet-late loan can never be impaired
+            // (tecTOO_SOON) and the follow-up unimpair on an unimpaired
+            // loan is still tecNO_PERMISSION.
+            env(manage(lender, keylet.key, tfLoanImpair), Ter(tecTOO_SOON));
+            env(manage(lender, keylet.key, tfLoanUnimpair), Ter(tecNO_PERMISSION));
+        }
 
         auto const nextDueDate = startDate + *loanParams.payInterval;
 
@@ -2188,6 +2223,11 @@ protected:
                 {
                     // Check the vault
                     bool const canImpair = canImpairLoan(env, broker, state);
+                    // Under fixCleanup3_4_0 impair requires the payment to
+                    // already be late. Advance past the loan's next due
+                    // date so this exercises the tesSUCCESS branch. No-op
+                    // when the fix is disabled.
+                    advancePastDueDate(env, loanKeylet);
                     // Impair the loan, if possible
                     env(manage(lender, loanKeylet.key, tfLoanImpair),
                         canImpair ? Ter(tesSUCCESS) : Ter(tecLIMIT_EXCEEDED));
@@ -2195,7 +2235,11 @@ protected:
                     if (canImpair)
                     {
                         state.flags |= tfLoanImpair;
-                        state.nextPaymentDate = env.now().time_since_epoch().count();
+                        // Prior to fixCleanup3_4_0 impair rewrote
+                        // sfNextPaymentDueDate to parentCloseTime. Under the
+                        // fix, the due date is preserved.
+                        if (!env.current()->rules().enabled(fixCleanup3_4_0))
+                            state.nextPaymentDate = env.now().time_since_epoch().count();
 
                         // Once the loan is impaired, it can't be impaired again
                         env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tecNO_PERMISSION));
@@ -2815,7 +2859,17 @@ protected:
 
                     auto const borrowerBalanceBeforePayment = env.balance(borrower, broker.asset);
 
-                    if (canImpairLoan(env, broker, state))
+                    // Under fixCleanup3_4_0 impair requires the payment to
+                    // already be late. This periodic-payment loop stays
+                    // within each payment interval, so the loan is never
+                    // late here; skip the impair rather than perturb the
+                    // payment schedule.
+                    auto const loanSle = env.le(loanKeylet);
+                    bool const impairAllowed = BEAST_EXPECT(loanSle) &&
+                        canImpairLoan(env, broker, state) &&
+                        (!env.current()->rules().enabled(fixCleanup3_4_0) ||
+                         isPaymentLate(*env.current(), loanSle));
+                    if (impairAllowed)
                     {
                         // Making a payment will unimpair the loan
                         env(manage(lender, loanKeylet.key, tfLoanImpair));
diff --git a/src/test/app/lending/LoanValidation_test.cpp b/src/test/app/lending/LoanValidation_test.cpp
index ebbef70f40..169a02c462 100644
--- a/src/test/app/lending/LoanValidation_test.cpp
+++ b/src/test/app/lending/LoanValidation_test.cpp
@@ -345,7 +345,12 @@ private:
         env(trust(issuer, lender["IOU"](1'000), tfClearFreeze | tfClearDeepFreeze));
         env.close();
 
-        // The payment is late by this point
+        // The payment is late by this point. With fixCleanup3_4_0,
+        // isPaymentLate() uses a strict (Exclusive) comparison, so advance
+        // one more ledger close to be sure the due date instant itself has
+        // passed, not merely reached.
+        env.close();
+
         env(pay(borrower, loanKeylet.key, debtMaximumRequest), Ter(tecEXPIRED));
         env.close();
         env(pay(borrower, loanKeylet.key, debtMaximumRequest, tfLoanLatePayment));
diff --git a/src/test/app/vault/VaultPrecisionFixture.h b/src/test/app/vault/VaultPrecisionFixture.h
index c324161347..d1067e245d 100644
--- a/src/test/app/vault/VaultPrecisionFixture.h
+++ b/src/test/app/vault/VaultPrecisionFixture.h
@@ -13,7 +13,9 @@
 
 #include 
 #include 
+#include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -224,17 +226,29 @@ protected:
         // Loan 2: sibling loan of principal 11.
         f.loan2Keylet = setLoan(Number{11});
 
-        // Impair loan 1 → drives sfLossUnrealized to loan 1's value.
-        env(jtx::loan::manage(f.lender, f.loan1Keylet.key, tfLoanImpair), bigFee);
-        env.close();
-
         // Pay off loan 2 in full so its total value flows into the vault
         // and pushes T-A upward, meeting the residual loss.  Generous
         // upper bound; the transactor takes only what is due.
+        //
+        // This happens before the impair below because impair under
+        // fixCleanup3_4_0 requires loan 1 to already be late, and the two
+        // loans are originated close enough together that advancing past
+        // loan 1's due date also makes loan 2 late — which would reject
+        // this full payment with tecEXPIRED.
         auto const payoff = asset(Number{50}).value();
         env(pay(f.borrower, f.loan2Keylet.key, payoff, tfLoanFullPayment), bigFee);
         env.close();
 
+        // Impair loan 1 → drives sfLossUnrealized to loan 1's value.
+        if (env.current()->rules().enabled(fixCleanup3_4_0))
+        {
+            std::uint32_t const dueDate = env.le(f.loan1Keylet)->at(sfNextPaymentDueDate);
+            env.close(NetClock::time_point{NetClock::duration{dueDate}} + std::chrono::seconds{1});
+        }
+
+        env(jtx::loan::manage(f.lender, f.loan1Keylet.key, tfLoanImpair), bigFee);
+        env.close();
+
         return f;
     }
 };
diff --git a/src/test/app/vault/VaultSoleShareholder_test.cpp b/src/test/app/vault/VaultSoleShareholder_test.cpp
index 62cbc28bcd..92d5dd04d4 100644
--- a/src/test/app/vault/VaultSoleShareholder_test.cpp
+++ b/src/test/app/vault/VaultSoleShareholder_test.cpp
@@ -13,6 +13,7 @@
 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -133,6 +134,7 @@ private:
 
         {
             using namespace loan;
+            using namespace std::chrono_literals;
             env(set(f.borrower, f.brokerID, kStuckPrincipal),
                 Sig(sfCounterpartySignature, f.lender),
                 kPaymentTotal(kStuckPayTotal),
@@ -140,6 +142,15 @@ private:
                 Fee(env.current()->fees().base * 2),
                 Ter(tesSUCCESS));
             env.close();
+
+            // Impairment requires the payment to be late, so advance past
+            // the due date before impairing.
+            auto const loanSle = env.le(*f.loanKeylet);
+            if (!BEAST_EXPECT(loanSle))
+                return f;
+            std::uint32_t const dueDate = loanSle->at(sfNextPaymentDueDate);
+            env.close(NetClock::time_point{NetClock::duration{dueDate}} + 1s);
+
             env(manage(f.lender, f.loanKeylet->key, tfLoanImpair), Ter(tesSUCCESS));
             env.close();
         }
@@ -592,7 +603,14 @@ private:
         BEAST_EXPECT(retainedShares == f.sharesLender - 750'018'750);
 
         // Borrower repays the loan in full (pays more than the outstanding
-        // total; the loan transactor caps the receivable).
+        // total each time; the loan transactor caps the receivable). The
+        // loan is still overdue from the impairment setup, so the first
+        // (and only remaining, since kStuckPayTotal == 2) outstanding
+        // installment must be caught up with a late payment before the
+        // final regular payment can close the loan out.
+        env(pay(f.borrower, loanKey.key, asset(kStuckPrincipal * 2), tfLoanLatePayment),
+            Ter(tesSUCCESS));
+        env.close();
         env(pay(f.borrower, loanKey.key, asset(kStuckPrincipal * 2)), Ter(tesSUCCESS));
         env.close();
 

From 42502e4263f7011158264ddcdaed29c2e969ef53 Mon Sep 17 00:00:00 2001
From: Sergey Kuznetsov 
Date: Wed, 26 Aug 2026 13:42:19 +0000
Subject: [PATCH 29/32] ci: Update CI image (#8121)

---
 .github/scripts/strategy-matrix/linux.json   |  2 +-
 .github/workflows/build-nix-images.yml       |  2 ++
 .github/workflows/cargo-audit.yml            |  2 +-
 .github/workflows/pre-commit.yml             |  2 +-
 .github/workflows/publish-docs.yml           |  2 +-
 .github/workflows/reusable-clang-tidy.yml    |  2 +-
 .github/workflows/reusable-rust.yml          |  6 +++---
 .github/workflows/reusable-upload-recipe.yml |  2 +-
 nix/check-tools/nix-ubuntu-amd64.txt         | 20 ++++++++++----------
 nix/check-tools/nix-ubuntu-arm64.txt         | 20 ++++++++++----------
 10 files changed, 31 insertions(+), 29 deletions(-)

diff --git a/.github/scripts/strategy-matrix/linux.json b/.github/scripts/strategy-matrix/linux.json
index 8450c3079e..14d1c725d7 100644
--- a/.github/scripts/strategy-matrix/linux.json
+++ b/.github/scripts/strategy-matrix/linux.json
@@ -1,5 +1,5 @@
 {
-  "image_tag": "sha-a0074f8",
+  "image_tag": "sha-473fe44",
   "configs": {
     "ubuntu": [
       {
diff --git a/.github/workflows/build-nix-images.yml b/.github/workflows/build-nix-images.yml
index 813edd8aff..a528786dd4 100644
--- a/.github/workflows/build-nix-images.yml
+++ b/.github/workflows/build-nix-images.yml
@@ -12,6 +12,7 @@ on:
       - "nix/**"
       - "!nix/docker/README.md"
       - "!nix/devshell.nix"
+      - "!nix/check-tools/*.txt"
       - "bin/check-tools.sh"
       - "bin/default-loader-path.sh"
       - "bin/install-sanitizer-libs.sh"
@@ -24,6 +25,7 @@ on:
       - "nix/**"
       - "!nix/docker/README.md"
       - "!nix/devshell.nix"
+      - "!nix/check-tools/*.txt"
       - "bin/check-tools.sh"
       - "bin/default-loader-path.sh"
       - "bin/install-sanitizer-libs.sh"
diff --git a/.github/workflows/cargo-audit.yml b/.github/workflows/cargo-audit.yml
index d167e52e61..6ddc6cdac9 100644
--- a/.github/workflows/cargo-audit.yml
+++ b/.github/workflows/cargo-audit.yml
@@ -34,7 +34,7 @@ permissions:
 jobs:
   audit:
     runs-on: ubuntu-latest
-    container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-a0074f8
+    container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-473fe44
     permissions:
       contents: read
       # Needed to open an issue on scheduled failures.
diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml
index 905e910591..1f6b69f087 100644
--- a/.github/workflows/pre-commit.yml
+++ b/.github/workflows/pre-commit.yml
@@ -17,4 +17,4 @@ jobs:
     uses: XRPLF/actions/.github/workflows/pre-commit.yml@f1952595d212e86169935135efc66294b4574131
     with:
       runs_on: ubuntu-latest
-      container: '{ "image": "ghcr.io/xrplf/xrpld/pre-commit:sha-f56b79f" }'
+      container: '{ "image": "ghcr.io/xrplf/xrpld/pre-commit:sha-473fe44" }'
diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml
index b8ca7751ab..8c5d10929c 100644
--- a/.github/workflows/publish-docs.yml
+++ b/.github/workflows/publish-docs.yml
@@ -41,7 +41,7 @@ env:
 jobs:
   build:
     runs-on: ubuntu-latest
-    container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-a0074f8
+    container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-473fe44
     steps:
       - name: Checkout repository
         uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
diff --git a/.github/workflows/reusable-clang-tidy.yml b/.github/workflows/reusable-clang-tidy.yml
index ac21c83ea0..045d384181 100644
--- a/.github/workflows/reusable-clang-tidy.yml
+++ b/.github/workflows/reusable-clang-tidy.yml
@@ -34,7 +34,7 @@ jobs:
     needs: [determine-files]
     if: ${{ needs.determine-files.outputs.cpp_changed_files != '' || needs.determine-files.outputs.need_full_run == 'true' }}
     runs-on: ["self-hosted", "Linux", "X64", "heavy"]
-    container: "ghcr.io/xrplf/xrpld/nix-debian:sha-a0074f8"
+    container: "ghcr.io/xrplf/xrpld/nix-debian:sha-473fe44"
     permissions:
       contents: read
       issues: write
diff --git a/.github/workflows/reusable-rust.yml b/.github/workflows/reusable-rust.yml
index 83301f97ad..a0199f0129 100644
--- a/.github/workflows/reusable-rust.yml
+++ b/.github/workflows/reusable-rust.yml
@@ -27,7 +27,7 @@ permissions:
 jobs:
   clippy:
     runs-on: ubuntu-latest
-    container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-a0074f8
+    container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-473fe44
     steps:
       - name: Checkout repository
         uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -40,7 +40,7 @@ jobs:
 
   coverage:
     runs-on: ubuntu-latest
-    container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-a0074f8
+    container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-473fe44
     steps:
       - name: Checkout repository
         uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -66,7 +66,7 @@ jobs:
 
   doc:
     runs-on: ubuntu-latest
-    container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-a0074f8
+    container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-473fe44
     steps:
       - name: Checkout repository
         uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
diff --git a/.github/workflows/reusable-upload-recipe.yml b/.github/workflows/reusable-upload-recipe.yml
index 680d95fb97..608a5ea988 100644
--- a/.github/workflows/reusable-upload-recipe.yml
+++ b/.github/workflows/reusable-upload-recipe.yml
@@ -40,7 +40,7 @@ defaults:
 jobs:
   upload:
     runs-on: ubuntu-latest
-    container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-a0074f8
+    container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-473fe44
     env:
       REMOTE_NAME: ${{ inputs.remote_name }}
       CONAN_LOGIN_USERNAME_XRPLF: ${{ secrets.remote_username }}
diff --git a/nix/check-tools/nix-ubuntu-amd64.txt b/nix/check-tools/nix-ubuntu-amd64.txt
index a5857c93f1..28b6c38014 100644
--- a/nix/check-tools/nix-ubuntu-amd64.txt
+++ b/nix/check-tools/nix-ubuntu-amd64.txt
@@ -114,8 +114,8 @@ Development tooling:
 
 Rust toolchain:
   ✅ cargo
-     cargo 1.95.0 (f2d3ce0bd 2026-03-21)
-     /nix/store/85qbwr3vzfs58m7ywnjblz105p8ahbrv-cargo-1.95.0-x86_64-unknown-linux-gnu/bin/cargo
+     cargo 1.97.1 (c980f4866 2026-06-30)
+     /nix/store/88abzp43ywyzql1rhf8jh5aj5n5j7xzr-cargo-1.97.1-x86_64-unknown-linux-gnu/bin/cargo
   ✅ cargo-audit
      cargo-audit-audit 0.22.1
      /nix/store/2w9if868piw98xz057sz97jnjvf7hnvf-cargo-audit-0.22.1/bin/cargo-audit
@@ -126,17 +126,17 @@ Rust toolchain:
      cargo-nextest 0.9.137
      /nix/store/jhkr7gwyrchkml33gyns9cy0yn7b57qc-cargo-nextest-0.9.137/bin/cargo-nextest
   ✅ clippy-driver
-     clippy 0.1.95 (59807616e1 2026-04-14)
-     /nix/store/bnvg9nmdq4g98dd9v3r6nvjg5h2rr8i7-rust-minimal-1.95.0/bin/clippy-driver
+     clippy 0.1.97 (8bab26f4f6 2026-07-14)
+     /nix/store/40d3mzka7r1ps71l0yv2fs6616nbw85m-rust-minimal-1.97.1/bin/clippy-driver
   ✅ rust-analyzer
-     rust-analyzer 1.95.0 (5980761 2026-04-14)
-     /nix/store/i3cnpngfwa3k4jn431pl6ji1r4qmxky9-rust-analyzer-preview-1.95.0-x86_64-unknown-linux-gnu/bin/rust-analyzer
+     rust-analyzer 1.97.1 (8bab26f 2026-07-14)
+     /nix/store/lr3m97p3hx1k22a7c44pb0wa7rbayhfi-rust-analyzer-preview-1.97.1-x86_64-unknown-linux-gnu/bin/rust-analyzer
   ✅ rustc
-     rustc 1.95.0 (59807616e 2026-04-14)
-     /nix/store/bnvg9nmdq4g98dd9v3r6nvjg5h2rr8i7-rust-minimal-1.95.0/bin/rustc
+     rustc 1.97.1 (8bab26f4f 2026-07-14)
+     /nix/store/40d3mzka7r1ps71l0yv2fs6616nbw85m-rust-minimal-1.97.1/bin/rustc
   ✅ rustfmt
-     rustfmt 1.9.0-stable (59807616e1 2026-04-14)
-     /nix/store/366hhk2dgwxmnf4hgrj4b8llhjr3hf0i-rustfmt-preview-1.95.0-x86_64-unknown-linux-gnu/bin/rustfmt
+     rustfmt 1.9.0-stable (8bab26f4f6 2026-07-14)
+     /nix/store/6f1icmb2za20kxn30pgmbv5jq9fnbf4z-rustfmt-preview-1.97.1-x86_64-unknown-linux-gnu/bin/rustfmt
 
 GCC toolchain:
   ✅ gcc
diff --git a/nix/check-tools/nix-ubuntu-arm64.txt b/nix/check-tools/nix-ubuntu-arm64.txt
index 820c6de086..b3b5885a7f 100644
--- a/nix/check-tools/nix-ubuntu-arm64.txt
+++ b/nix/check-tools/nix-ubuntu-arm64.txt
@@ -114,8 +114,8 @@ Development tooling:
 
 Rust toolchain:
   ✅ cargo
-     cargo 1.95.0 (f2d3ce0bd 2026-03-21)
-     /nix/store/yw1rs50s6qpsw0zyl7j3dpm18swbl0ag-cargo-1.95.0-aarch64-unknown-linux-gnu/bin/cargo
+     cargo 1.97.1 (c980f4866 2026-06-30)
+     /nix/store/6hch2qrr86n2sa2m90lrpxrfxxwbkayl-cargo-1.97.1-aarch64-unknown-linux-gnu/bin/cargo
   ✅ cargo-audit
      cargo-audit-audit 0.22.1
      /nix/store/9rxbrn9aa2r1z96186s69pc7vzizyfch-cargo-audit-0.22.1/bin/cargo-audit
@@ -126,17 +126,17 @@ Rust toolchain:
      cargo-nextest 0.9.137
      /nix/store/qb6bcg2fjvm3r9s9j98nmffmf9xwh45s-cargo-nextest-0.9.137/bin/cargo-nextest
   ✅ clippy-driver
-     clippy 0.1.95 (59807616e1 2026-04-14)
-     /nix/store/nz4qv12pf16c092qr9hh4dsn0fzf47da-rust-minimal-1.95.0/bin/clippy-driver
+     clippy 0.1.97 (8bab26f4f6 2026-07-14)
+     /nix/store/a6p27cg6b8szfixfyvkssx6l0c345zw8-rust-minimal-1.97.1/bin/clippy-driver
   ✅ rust-analyzer
-     rust-analyzer 1.95.0 (5980761 2026-04-14)
-     /nix/store/m1rn67sqfz8s44idcxqallg680ifk71r-rust-analyzer-preview-1.95.0-aarch64-unknown-linux-gnu/bin/rust-analyzer
+     rust-analyzer 1.97.1 (8bab26f 2026-07-14)
+     /nix/store/262830dlw2517lnagfx7i7agqgl4fmsd-rust-analyzer-preview-1.97.1-aarch64-unknown-linux-gnu/bin/rust-analyzer
   ✅ rustc
-     rustc 1.95.0 (59807616e 2026-04-14)
-     /nix/store/nz4qv12pf16c092qr9hh4dsn0fzf47da-rust-minimal-1.95.0/bin/rustc
+     rustc 1.97.1 (8bab26f4f 2026-07-14)
+     /nix/store/a6p27cg6b8szfixfyvkssx6l0c345zw8-rust-minimal-1.97.1/bin/rustc
   ✅ rustfmt
-     rustfmt 1.9.0-stable (59807616e1 2026-04-14)
-     /nix/store/jidfsprj2820glyzjn54ldn3j1fmz8c5-rustfmt-preview-1.95.0-aarch64-unknown-linux-gnu/bin/rustfmt
+     rustfmt 1.9.0-stable (8bab26f4f6 2026-07-14)
+     /nix/store/nd8g81wv1smnvdpy4whpcyv2siwjmaan-rustfmt-preview-1.97.1-aarch64-unknown-linux-gnu/bin/rustfmt
 
 GCC toolchain:
   ✅ gcc

From f8fba079fe8d20d7a9e1051e44311f2943c40b49 Mon Sep 17 00:00:00 2001
From: Timur Yalymov <36795566+tyalymov@users.noreply.github.com>
Date: Wed, 26 Aug 2026 13:55:04 +0000
Subject: [PATCH 30/32] fix: Refuse a pseudo-account as the vault clawback
 holder (#8111)

Co-authored-by: Vito Tumas <5780819+Tapanito@users.noreply.github.com>
---
 .../tx/transactors/vault/VaultClawback.cpp    | 12 +++
 src/test/app/vault/VaultClawback_test.cpp     | 83 +++++++++++++++++++
 2 files changed, 95 insertions(+)

diff --git a/src/libxrpl/tx/transactors/vault/VaultClawback.cpp b/src/libxrpl/tx/transactors/vault/VaultClawback.cpp
index 7348e1734b..c2c099f14e 100644
--- a/src/libxrpl/tx/transactors/vault/VaultClawback.cpp
+++ b/src/libxrpl/tx/transactors/vault/VaultClawback.cpp
@@ -6,6 +6,7 @@
 #include 
 #include 
 #include 
+#include 
 #include 
 #include 
 #include 
@@ -95,6 +96,17 @@ VaultClawback::preclaim(PreclaimContext const& ctx)
         // LCOV_EXCL_STOP
     }
 
+    // A pseudo-account holds no vault shares, so a clawback naming one is a no-op: the vault's own
+    // pseudo-account issues the shares, and no flow hands them to another one.
+    // Pre-fixCleanup3_4_0: an implicit amount ends in tecPRECISION_LOSS, an explicit one debits the
+    // vault and trips the "shares must move" invariant.
+    // Post-fixCleanup3_4_0: refused here.
+    if (ctx.view.rules().enabled(fixCleanup3_4_0) && isPseudoAccount(ctx.view, holder))
+    {
+        JLOG(ctx.j.debug()) << "VaultClawback: holder is a pseudo-account.";
+        return tecPSEUDO_ACCOUNT;
+    }
+
     Asset const share = MPTIssue{mptIssuanceID};
 
     // Ambiguous case: If Issuer is Owner they must specify the asset
diff --git a/src/test/app/vault/VaultClawback_test.cpp b/src/test/app/vault/VaultClawback_test.cpp
index 6ce847f9fd..0290b67047 100644
--- a/src/test/app/vault/VaultClawback_test.cpp
+++ b/src/test/app/vault/VaultClawback_test.cpp
@@ -1129,12 +1129,95 @@ private:
         }
     }
 
+    // The vault's pseudo-account issues the shares, so it never holds any, and naming it as Holder
+    // asks for a clawback that cannot move anything. Before the rule an implicit amount resolved to
+    // zero shares and ended in tecPRECISION_LOSS, while an explicit one debited the vault first and
+    // was caught by the invariant that shares must move.
+    void
+    testClawbackPseudoAccountHolder()
+    {
+        using namespace test::jtx;
+
+        auto const runScenario = [this](FeatureBitset features, std::string const& prefix) {
+            bool const guarded = features[fixCleanup3_4_0];
+            Env env{*this, features};
+
+            Account const owner{"owner"};
+            Account const depositor{"depositor"};
+            Account const issuer{"issuer"};
+
+            env.fund(XRP(1'000), owner, depositor, issuer);
+            env.close();
+
+            env(fset(issuer, asfAllowTrustLineClawback));
+            env.close();
+
+            PrettyAsset const asset = issuer["IOU"];
+            env.trust(asset(1'000), owner);
+            env.trust(asset(1'000), depositor);
+            env(pay(issuer, depositor, asset(200)));
+            env.close();
+
+            Vault const vault{env};
+            auto [tx, keylet] = vault.create({.owner = owner, .asset = asset});
+            env(tx);
+            env.close();
+
+            auto const vaultSle = env.le(keylet);
+            if (!BEAST_EXPECT(vaultSle))
+                return;
+            Account const pseudo{"vault pseudo-account", vaultSle->at(sfAccount)};
+            env.memoize(pseudo);
+
+            env(vault.deposit({.depositor = depositor, .id = keylet.key, .amount = asset(100)}));
+            env.close();
+
+            auto const assetsBefore = [&]() -> Number {
+                auto const sle = env.le(keylet);
+                if (!BEAST_EXPECT(sle))
+                    return Number{};
+                return sle->at(sfAssetsTotal);
+            }();
+
+            {
+                testcase("VaultClawback - " + prefix + " pseudo-account holder, implicit amount");
+                env(vault.clawback({
+                        .issuer = issuer,
+                        .id = keylet.key,
+                        .holder = pseudo,
+                    }),
+                    Ter(guarded ? TER{tecPSEUDO_ACCOUNT} : TER{tecPRECISION_LOSS}));
+                env.close();
+            }
+
+            {
+                testcase("VaultClawback - " + prefix + " pseudo-account holder, explicit amount");
+                env(vault.clawback({
+                        .issuer = issuer,
+                        .id = keylet.key,
+                        .holder = pseudo,
+                        .amount = asset(10).value(),
+                    }),
+                    Ter(guarded ? TER{tecPSEUDO_ACCOUNT} : TER{tecINVARIANT_FAILED}));
+                env.close();
+            }
+
+            // Neither attempt may touch the vault, whichever way it was refused.
+            auto const sleAfter = env.le(keylet);
+            BEAST_EXPECT(sleAfter && sleAfter->at(sfAssetsTotal) == assetsBefore);
+        };
+
+        runScenario(all_, "post-rule");
+        runScenario(all_ - fixCleanup3_4_0, "pre-rule");
+    }
+
 public:
     void
     run() override
     {
         testVaultClawbackBurnShares();
         testVaultClawbackAssets();
+        testClawbackPseudoAccountHolder();
         testVaultEscrowedMPT();
     }
 };

From d83a84510e2ae62c4ea784e0994a4cbed398f627 Mon Sep 17 00:00:00 2001
From: Ayaz Salikhov 
Date: Wed, 26 Aug 2026 14:03:01 +0000
Subject: [PATCH 31/32] chore: Bump version to 3.4.0-b2 (#8120)

---
 src/libxrpl/protocol/BuildInfo.cpp | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/libxrpl/protocol/BuildInfo.cpp b/src/libxrpl/protocol/BuildInfo.cpp
index 7f10630581..f1917eccff 100644
--- a/src/libxrpl/protocol/BuildInfo.cpp
+++ b/src/libxrpl/protocol/BuildInfo.cpp
@@ -23,7 +23,7 @@ namespace {
 //------------------------------------------------------------------------------
 // clang-format off
 // NOLINTNEXTLINE(readability-identifier-naming)
-char const* const versionString = "3.4.0-b1"
+char const* const versionString = "3.4.0-b2"
     // clang-format on
     ;
 

From c28d389e0e3b6a59aa8a193b8df8da565cfe7f97 Mon Sep 17 00:00:00 2001
From: Ayaz Salikhov 
Date: Wed, 26 Aug 2026 14:04:58 +0000
Subject: [PATCH 32/32] build: Refactor generate.py to make packaging_config
 part of config (#8115)

---
 .github/scripts/strategy-matrix/generate.py | 101 ++++++++++++--------
 .github/scripts/strategy-matrix/linux.json  |  35 ++-----
 .github/workflows/reusable-package.yml      |   4 +-
 package/README.md                           |  49 +++++-----
 4 files changed, 98 insertions(+), 91 deletions(-)

diff --git a/.github/scripts/strategy-matrix/generate.py b/.github/scripts/strategy-matrix/generate.py
index 7a3b7a8cf5..65671dbd11 100755
--- a/.github/scripts/strategy-matrix/generate.py
+++ b/.github/scripts/strategy-matrix/generate.py
@@ -23,6 +23,19 @@ _SANITIZER_SUFFIX: dict[str, str] = {
 }
 
 
+def config_name(
+    distro: str,
+    compiler: str,
+    build_type: str,
+    arch: str,
+    suffix: str = "",
+    sanitizer: str = "",
+) -> str:
+    """Name a config. Its artifacts are named after it, so packaging reuses this."""
+    parts = [s for s in [suffix, _SANITIZER_SUFFIX.get(sanitizer, "")] if s]
+    return "-".join([f"{distro}-{compiler}-{build_type.lower()}-{arch}", *parts])
+
+
 def get_cmake_args(build_type: str, extra_args: str) -> str:
     """Get the full list of CMake arguments for a config."""
     args = _BASE_CMAKE_ARGS.copy()
@@ -37,17 +50,27 @@ def get_cmake_args(build_type: str, extra_args: str) -> str:
 
 
 # Every config must declare 'minimal'. Minimal configs form the reduced matrix
-# built for pull requests by default; the full matrix adds the rest. Packaging
-# configs declare it too, but packaging is gated in the workflow, not by it.
+# built for pull requests by default; the full matrix adds the rest.
 #
-# Configs may also opt into 'benchmark' to smoke-run the benchmarks. Note that
-# the flag applies to every entry a config expands into, so only set it on
-# configs that expand to a single combination.
+# Configs may also opt into 'benchmark' to smoke-run the benchmarks, or carry a
+# 'package' map to be packaged as well. Note that either applies to every entry
+# a config expands into, so only set them on configs that expand to a single
+# combination.
+
+
+@dataclasses.dataclass
+class PackageConfig:
+    """The 'package' map of a config whose binaries are also packaged."""
+
+    type: str  # "deb" or "rpm"; has to match what the image provides
+    # The packaging container image: a vanilla distro image, not the nix image
+    # the config itself builds in.
+    image: str
 
 
 @dataclasses.dataclass
 class LinuxConfig:
-    """One entry in linux.json's 'configs' or 'package_configs' arrays."""
+    """One entry in a linux.json 'configs' array."""
 
     compiler: list[str]
     build_type: list[str]
@@ -57,9 +80,11 @@ class LinuxConfig:
     sanitizers: list[str] = dataclasses.field(default_factory=list)
     suffix: str = ""
     extra_cmake_args: str = ""
-    # The two below are only used by package_configs entries.
-    image: str = ""
-    package_type: str = ""  # "deb" or "rpm"; has to match what image provides
+    package: PackageConfig | None = None  # set to also package this config
+
+    def __post_init__(self) -> None:
+        if isinstance(self.package, dict):
+            self.package = PackageConfig(**self.package)
 
 
 @dataclasses.dataclass
@@ -68,22 +93,16 @@ class LinuxFile:
 
     image_tag: str
     configs: dict[str, list[LinuxConfig]]  # distro → configs
-    package_configs: dict[str, list[LinuxConfig]]  # distro → packaging configs
 
     @classmethod
     def load(cls, path: Path) -> "LinuxFile":
         data = json.loads(path.read_text())
-
-        def parse(section: dict) -> dict[str, list[LinuxConfig]]:
-            return {
-                distro: [LinuxConfig(**c) for c in cfgs]
-                for distro, cfgs in section.items()
-            }
-
         return cls(
             image_tag=data["image_tag"],
-            configs=parse(data["configs"]),
-            package_configs=parse(data.get("package_configs", {})),
+            configs={
+                distro: [LinuxConfig(**c) for c in cfgs]
+                for distro, cfgs in data["configs"].items()
+            },
         )
 
 
@@ -199,13 +218,9 @@ def expand_linux_matrix(linux: LinuxFile, minimal: bool) -> list[MatrixEntry]:
                 effective_sanitizers,
                 effective_archs.items(),
             ):
-                name = f"{distro}-{compiler}-{build_type.lower()}-{arch}"
-                suffix_parts = [
-                    s for s in [cfg.suffix, _SANITIZER_SUFFIX.get(sanitizer, "")] if s
-                ]
-                if suffix_parts:
-                    name += "-" + "-".join(suffix_parts)
-
+                name = config_name(
+                    distro, compiler, build_type, arch, cfg.suffix, sanitizer
+                )
                 entries.append(
                     MatrixEntry(
                         config_name=name,
@@ -225,27 +240,33 @@ def expand_linux_matrix(linux: LinuxFile, minimal: bool) -> list[MatrixEntry]:
 
 
 def expand_linux_packaging(linux: LinuxFile) -> list[PackagingEntry]:
-    """Generate the packaging matrix from a LinuxFile's package_configs section.
+    """Generate the packaging matrix from the configs that carry a 'package' map.
 
-    Packaging uses vanilla distro images (debian:bookworm, almalinux:9) instead of
-    the nix-based build images, because deb/rpm tooling (debhelper, rpm-build)
-    is taken from the distro's archive rather than from nixpkgs. Each config
-    entry carries its own 'image'.
+    Packaging consumes the binaries that config's build job uploaded, so the
+    artifact names come from the same config name, and a packaged config is one
+    that passes -Dvalidator_keys=ON.
 
-    The artifact names must match what the build job uploads: one artifact per
-    binary, each named after the build config.
+    Packaging itself runs in vanilla distro images (debian:trixie, almalinux:10)
+    instead of the nix-based build images, because deb/rpm tooling (debhelper,
+    rpm-build) is taken from the distro's archive rather than from nixpkgs.
     """
     entries = []
-    for distro, configs in linux.package_configs.items():
+    for distro, configs in linux.configs.items():
         for cfg in configs:
-            for compiler, build_type in itertools.product(cfg.compiler, cfg.build_type):
-                config_name = f"{distro}-{compiler}-{build_type.lower()}-amd64"
+            if cfg.package is None:
+                continue
+            for compiler, build_type, arch in itertools.product(
+                cfg.compiler, cfg.build_type, cfg.arch
+            ):
+                # The packaging workflow hardcodes an amd64 runner.
+                assert arch == "amd64", f"cannot package {distro} on {arch}"
+                name = config_name(distro, compiler, build_type, arch, cfg.suffix)
                 entries.append(
                     PackagingEntry(
-                        xrpld_artifact_name=f"xrpld-{config_name}",
-                        validator_keys_artifact_name=f"validator-keys-{config_name}",
-                        image=cfg.image,
-                        package_type=cfg.package_type,
+                        xrpld_artifact_name=f"xrpld-{name}",
+                        validator_keys_artifact_name=f"validator-keys-{name}",
+                        image=cfg.package.image,
+                        package_type=cfg.package.type,
                     )
                 )
 
diff --git a/.github/scripts/strategy-matrix/linux.json b/.github/scripts/strategy-matrix/linux.json
index 14d1c725d7..731536a748 100644
--- a/.github/scripts/strategy-matrix/linux.json
+++ b/.github/scripts/strategy-matrix/linux.json
@@ -71,7 +71,11 @@
         "build_type": ["Release"],
         "arch": ["amd64"],
         "minimal": false,
-        "extra_cmake_args": "-Dvalidator_keys=ON"
+        "extra_cmake_args": "-Dvalidator_keys=ON",
+        "package": {
+          "type": "deb",
+          "image": "ghcr.io/xrplf/xrpld/packaging-debian:sha-45e4b88"
+        }
       }
     ],
 
@@ -81,30 +85,11 @@
         "build_type": ["Release"],
         "arch": ["amd64"],
         "minimal": false,
-        "extra_cmake_args": "-Dvalidator_keys=ON"
-      }
-    ]
-  },
-  "package_configs": {
-    "debian": [
-      {
-        "compiler": ["gcc"],
-        "build_type": ["Release"],
-        "arch": ["amd64"],
-        "minimal": false,
-        "image": "ghcr.io/xrplf/xrpld/packaging-debian:sha-45e4b88",
-        "package_type": "deb"
-      }
-    ],
-
-    "rhel": [
-      {
-        "compiler": ["gcc"],
-        "build_type": ["Release"],
-        "arch": ["amd64"],
-        "minimal": false,
-        "image": "ghcr.io/xrplf/xrpld/packaging-rhel:sha-45e4b88",
-        "package_type": "rpm"
+        "extra_cmake_args": "-Dvalidator_keys=ON",
+        "package": {
+          "type": "rpm",
+          "image": "ghcr.io/xrplf/xrpld/packaging-rhel:sha-45e4b88"
+        }
       }
     ]
   }
diff --git a/.github/workflows/reusable-package.yml b/.github/workflows/reusable-package.yml
index 4d1968b93c..aa9183be37 100644
--- a/.github/workflows/reusable-package.yml
+++ b/.github/workflows/reusable-package.yml
@@ -1,7 +1,7 @@
 # Build Linux packages from the pre-built xrpld and validator-keys artifacts:
 #
-#   - one job per distro, taken from "package_configs" in linux.json
-#   - each entry names its container image and the format it builds there
+#   - one job per config that carries a "package" map in linux.json
+#   - that map names the container image and the format it builds there
 #   - with 'publish: true' a job also uploads what it built
 #     (see package/publish_pkg.py)
 #
diff --git a/package/README.md b/package/README.md
index bacd79efe5..8295a8a38e 100644
--- a/package/README.md
+++ b/package/README.md
@@ -23,16 +23,16 @@ package/
 
 ## Prerequisites
 
-Packaging targets and their container images are declared in
-[`.github/scripts/strategy-matrix/linux.json`](../.github/scripts/strategy-matrix/linux.json)
-under `package_configs`, one entry per distro. Today only `linux/amd64` is
-emitted. Each entry pins its full container image in an `image` field; to move
-to a new image, edit that field and both CI and local builds pick it up. The
-entry also declares the format that image builds in a `package_type` field,
-which CI passes to `build_pkg.py` as `--package-type`; the two have to stay in
-step.
+Packaging is declared on the build configs themselves, in
+[`.github/scripts/strategy-matrix/linux.json`](../.github/scripts/strategy-matrix/linux.json):
+a config that is also packaged carries a `package` map, so its binaries and its
+packaging job cannot drift apart. Today only `linux/amd64` is emitted. The map
+pins the full container image in `image` — edit that field to move to a new
+image and both CI and local builds pick it up — and names the format that image
+builds in `type`, which CI passes to `build_pkg.py` as `--package-type`; the two
+have to stay in step.
 
-| Package type | Image (`package_configs.[].image` in `linux.json`) | Tools required                                      |
+| Package type | Image (`configs.[].package.image` in `linux.json`) | Tools required                                      |
 | ------------ | ---------------------------------------------------------- | --------------------------------------------------- |
 | RPM          | `ghcr.io/xrplf/xrpld/packaging-rhel:sha-`             | `rpmbuild`, `rpmsign`                               |
 | DEB          | `ghcr.io/xrplf/xrpld/packaging-debian:sha-`           | `dpkg-buildpackage`, debhelper with compat level 13 |
@@ -50,19 +50,20 @@ To print the full packaging matrix (artifact names and images) for the current
 
 Caller workflows (`on-pr.yml`, `on-tag.yml`, `on-trigger.yml`) call
 `reusable-package.yml`. That workflow generates its own packaging matrix from
-`package_configs` in `linux.json` (via `generate.py --packaging`) and fans out
-one job per distro. Each job downloads the pre-built `xrpld` and `validator-keys`
-binary artifacts and runs in that distro's container, building the format its
-`package_type` declares. The packaging script derives the package version from
-the downloaded binary's `xrpld --version` output; no CMake configure or build
-step is needed inside the packaging job.
+the configs that carry a `package` map (via `generate.py --packaging`) and fans
+out one job per distro. Each job downloads the pre-built `xrpld` and
+`validator-keys` binary artifacts and runs in that distro's container, building
+the format `package.type` declares. The packaging script derives the package
+version from the downloaded binary's `xrpld --version` output; no CMake
+configure or build step is needed inside the packaging job.
 
-The binaries come from the `debian` and `rhel` build configurations in
-`linux.json`'s `configs` section, which pass `-Dvalidator_keys=ON` so that the
+The binaries come from the `debian` and `rhel` build configs themselves — the
+ones carrying the `package` map — which pass `-Dvalidator_keys=ON` so that the
 build job produces `validator-keys` next to `xrpld` and uploads it as the
-`validator-keys-` artifact. The packaging entry for a distro names
-both artifacts (`xrpld_artifact_name` and `validator_keys_artifact_name`), so a
-packaged configuration must keep `-Dvalidator_keys=ON`.
+`validator-keys-` artifact. The packaging matrix names both
+artifacts (`xrpld_artifact_name` and `validator_keys_artifact_name`) after that
+same config, so a packaged config must keep `-Dvalidator_keys=ON`. Those configs
+are not `minimal`, so `on-pr.yml` only packages once a PR runs the full matrix.
 
 `validator-keys` is fetched from an exact commit pinned in
 [`cmake/XrplValidatorKeys.cmake`](../cmake/XrplValidatorKeys.cmake), so a given
@@ -75,10 +76,10 @@ With `xrpld` and `validator-keys` binaries already built at `build/xrpld` and
 The image tag is derived from `linux.json` so you don't need to hardcode a SHA.
 
 ```bash
-# From the repo root. Each distro's container image is the `image` field of its
-# package_configs entry in linux.json. Example for the rpm-producing image (use
-# .package_configs.debian[0].image and --package-type deb for the other one):
-IMAGE=$(jq -r '.package_configs.rhel[0].image' .github/scripts/strategy-matrix/linux.json)
+# From the repo root. Each distro's container image is the `package.image` field
+# of its config in linux.json. Example for the rpm-producing image (use
+# .configs.debian[0].package.image and --package-type deb for the other one):
+IMAGE=$(jq -r '.configs.rhel[0].package.image' .github/scripts/strategy-matrix/linux.json)
 
 PKG_RELEASE=1