From 51e888785d67f12f551044dcb3e3f668385518a9 Mon Sep 17 00:00:00 2001 From: Vito <5780819+Tapanito@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:18:24 +0200 Subject: [PATCH 1/4] feat: adds an invariant to ensure a pseudo-account is not deleted --- include/xrpl/protocol/detail/features.macro | 1 + include/xrpl/tx/invariants/InvariantCheck.h | 23 +++++++- src/libxrpl/tx/invariants/InvariantCheck.cpp | 57 +++++++++++++++++++ src/test/app/Invariants_test.cpp | 60 ++++++++++++++++++++ 4 files changed, 138 insertions(+), 3 deletions(-) diff --git a/include/xrpl/protocol/detail/features.macro b/include/xrpl/protocol/detail/features.macro index 2b2f24ba536..f2cc90aabdd 100644 --- a/include/xrpl/protocol/detail/features.macro +++ b/include/xrpl/protocol/detail/features.macro @@ -15,6 +15,7 @@ // Add new amendments to the top of this list. // Keep it sorted in reverse chronological order. +XRPL_FIX (Cleanup3_3_0, Supported::No, VoteBehavior::DefaultNo) XRPL_FIX (Cleanup3_2_0, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FEATURE(MPTokensV2, Supported::No, VoteBehavior::DefaultNo) XRPL_FIX (Cleanup3_1_3, Supported::Yes, VoteBehavior::DefaultYes) diff --git a/include/xrpl/tx/invariants/InvariantCheck.h b/include/xrpl/tx/invariants/InvariantCheck.h index 93780627262..6438d7e5bfa 100644 --- a/include/xrpl/tx/invariants/InvariantCheck.h +++ b/include/xrpl/tx/invariants/InvariantCheck.h @@ -375,16 +375,32 @@ class NoModifiedUnmodifiableFields */ class ValidAmounts { - std::vector> afterEntries_; + std::vector afterEntries_; public: void - visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + visitEntry(bool, SLE::const_ref, SLE::const_ref); [[nodiscard]] bool finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&) const; }; +/* + * Verify that all objects that have associated pseudo-accounts, always have the said + * pseudo-accounts. + */ +class ObjectHasPseudoAccount +{ +public: + void + visitEntry(bool, SLE::const_ref, SLE::const_ref); + + [[nodiscard]] bool + finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&) const; + +private: + SLE::const_pointer sle_; +}; // additional invariant checks can be declared above and then added to this // tuple using InvariantChecks = std::tuple< @@ -415,7 +431,8 @@ using InvariantChecks = std::tuple< ValidVault, ValidMPTPayment, ValidAmounts, - ValidMPTTransfer>; + ValidMPTTransfer, + ObjectHasPseudoAccount>; /** * @brief get a tuple of all invariant checks diff --git a/src/libxrpl/tx/invariants/InvariantCheck.cpp b/src/libxrpl/tx/invariants/InvariantCheck.cpp index b4a533905c4..94d59276030 100644 --- a/src/libxrpl/tx/invariants/InvariantCheck.cpp +++ b/src/libxrpl/tx/invariants/InvariantCheck.cpp @@ -1069,4 +1069,61 @@ ValidAmounts::finalize( return true; } +void +ObjectHasPseudoAccount::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) +{ + // If an object is deleted, pseudo-account is also deleted + if (isDelete) + return; + + // After should never be null when isDelete = false, if it is, something went horribly wrong + if (!after) + return; + + switch (after->getType()) + { + case ltAMM: + case ltVAULT: + case ltLOAN_BROKER: + sle_ = after; + default: + return; + } +} + +[[nodiscard]] bool +ObjectHasPseudoAccount::finalize( + STTx const&, + TER const, + XRPAmount const, + ReadView const& view, + beast::Journal const& j) const +{ + if (!view.rules().enabled(fixCleanup3_3_0)) + return true; + + if (!sle_) + return true; + + // For current ledger entry types, pseudo-account is identified by `sfAccount` field. + if (!sle_->isFieldPresent(sfAccount)) + { + JLOG(j.fatal()) << "Invariant failed: ledger entry " << sle_->getSType() + << " is missing pseudo-account field"; + return false; + } + + bool const exists = view.read(keylet::account(sle_->getAccountID(sfAccount))) != nullptr; + + // The pseudo-account must exist on the ledger + if (!exists) + { + JLOG(j.fatal()) << "Invariant failed: ledger entry " << sle_->getSType() + << " pseudo-account does not exist"; + return false; + } + + return true; +} + } // namespace xrpl diff --git a/src/test/app/Invariants_test.cpp b/src/test/app/Invariants_test.cpp index 6d53d256615..9891bbf0542 100644 --- a/src/test/app/Invariants_test.cpp +++ b/src/test/app/Invariants_test.cpp @@ -2789,6 +2789,7 @@ class Invariants_test : public beast::unit_test::Suite 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; }, @@ -2861,6 +2862,7 @@ class Invariants_test : public beast::unit_test::Suite 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); @@ -4883,6 +4885,63 @@ class Invariants_test : public beast::unit_test::Suite } } + void + testObjectHasPseudoAccount() + { + testcase << "object has pseudo-account"; + using namespace jtx; + + auto const amendments = defaultAmendments() | fixCleanup3_3_0; + // An account that is never funded, so it won't exist in any view. + AccountID const ghostId = Account{"Ghost"}.id(); + + // Vault: pseudo-account is referenced but does not exist on the ledger. + doInvariantCheck( + Env{*this, amendments}, + {{"pseudo-account does not exist"}}, + [ghostId](Account const& a1, Account const&, ApplyContext& ac) { + auto const vaultKeylet = keylet::vault(a1.id(), ac.view().seq()); + auto sle = std::make_shared(vaultKeylet); + sle->setAccountID(sfAccount, ghostId); + auto const page = ac.view().dirInsert( + keylet::ownerDir(a1.id()), sle->key(), describeOwnerDir(a1.id())); + if (!page) + return false; + sle->setFieldU64(sfOwnerNode, *page); + ac.view().insert(sle); + return true; + }); + + // AMM: pseudo-account is referenced but does not exist on the ledger. + doInvariantCheck( + Env{*this, amendments}, + {{"pseudo-account does not exist"}}, + [ghostId](Account const&, Account const&, ApplyContext& ac) { + auto const ammKeylet = keylet::amm(uint256(1u)); + auto sle = std::make_shared(ammKeylet); + sle->setAccountID(sfAccount, ghostId); + ac.view().insert(sle); + return true; + }); + + // LoanBroker: pseudo-account is referenced but does not exist on the ledger. + doInvariantCheck( + Env{*this, amendments}, + {{"pseudo-account does not exist"}}, + [ghostId](Account const& a1, Account const&, ApplyContext& ac) { + auto const brokerKeylet = keylet::loanbroker(a1.id(), ac.view().seq()); + auto sle = std::make_shared(brokerKeylet); + sle->setAccountID(sfAccount, ghostId); + auto const page = ac.view().dirInsert( + keylet::ownerDir(a1.id()), sle->key(), describeOwnerDir(a1.id())); + if (!page) + return false; + sle->setFieldU64(sfOwnerNode, *page); + ac.view().insert(sle); + return true; + }); + } + public: void run() override @@ -4914,6 +4973,7 @@ class Invariants_test : public beast::unit_test::Suite testInvariantOverwrite(defaultAmendments() - fixCleanup3_1_3); testVaultComputeCoarsestScale(); testAMM(); + testObjectHasPseudoAccount(); } }; From 66693fb7a4d3822e6e97ebcfcfafec7a2f61f856 Mon Sep 17 00:00:00 2001 From: Vito <5780819+Tapanito@users.noreply.github.com> Date: Wed, 10 Jun 2026 17:15:54 +0200 Subject: [PATCH 2/4] fix: address PR reviewer comments --- include/xrpl/tx/invariants/InvariantCheck.h | 2 +- src/libxrpl/tx/invariants/InvariantCheck.cpp | 45 ++++++++++++-------- src/test/app/Invariants_test.cpp | 17 ++++++++ 3 files changed, 46 insertions(+), 18 deletions(-) diff --git a/include/xrpl/tx/invariants/InvariantCheck.h b/include/xrpl/tx/invariants/InvariantCheck.h index 6438d7e5bfa..4523605f837 100644 --- a/include/xrpl/tx/invariants/InvariantCheck.h +++ b/include/xrpl/tx/invariants/InvariantCheck.h @@ -399,7 +399,7 @@ class ObjectHasPseudoAccount finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&) const; private: - SLE::const_pointer sle_; + std::vector sles_; }; // additional invariant checks can be declared above and then added to this // tuple diff --git a/src/libxrpl/tx/invariants/InvariantCheck.cpp b/src/libxrpl/tx/invariants/InvariantCheck.cpp index 94d59276030..1eb09ab3734 100644 --- a/src/libxrpl/tx/invariants/InvariantCheck.cpp +++ b/src/libxrpl/tx/invariants/InvariantCheck.cpp @@ -1078,14 +1078,20 @@ ObjectHasPseudoAccount::visitEntry(bool isDelete, SLE::const_ref before, SLE::co // After should never be null when isDelete = false, if it is, something went horribly wrong if (!after) - return; + { + XRPL_ASSERT( + after, + "xrpl::ObjectHasPseudoAccount::visitEntry : modified ledger entry missing after state"); + return; // LCOV_EXCL_LINE + } switch (after->getType()) { case ltAMM: case ltVAULT: case ltLOAN_BROKER: - sle_ = after; + sles_.push_back(after); + break; default: return; } @@ -1102,28 +1108,33 @@ ObjectHasPseudoAccount::finalize( if (!view.rules().enabled(fixCleanup3_3_0)) return true; - if (!sle_) + if (sles_.empty()) return true; - // For current ledger entry types, pseudo-account is identified by `sfAccount` field. - if (!sle_->isFieldPresent(sfAccount)) + bool failed = false; + for (auto const& sle : sles_) { - JLOG(j.fatal()) << "Invariant failed: ledger entry " << sle_->getSType() - << " is missing pseudo-account field"; - return false; - } + // For current ledger entry types, pseudo-account is identified by `sfAccount` field. + if (!sle->isFieldPresent(sfAccount)) + { + JLOG(j.fatal()) << "Invariant failed: ledger entry " << sle->getSType() + << " is missing pseudo-account field"; + failed = true; + continue; + } - bool const exists = view.read(keylet::account(sle_->getAccountID(sfAccount))) != nullptr; + bool const exists = view.read(keylet::account(sle->getAccountID(sfAccount))) != nullptr; - // The pseudo-account must exist on the ledger - if (!exists) - { - JLOG(j.fatal()) << "Invariant failed: ledger entry " << sle_->getSType() - << " pseudo-account does not exist"; - return false; + // The pseudo-account must exist on the ledger + if (!exists) + { + JLOG(j.fatal()) << "Invariant failed: ledger entry " << sle->getSType() + << " pseudo-account does not exist"; + failed = true; + } } - return true; + return !failed; } } // namespace xrpl diff --git a/src/test/app/Invariants_test.cpp b/src/test/app/Invariants_test.cpp index 9891bbf0542..95a4d79a9d5 100644 --- a/src/test/app/Invariants_test.cpp +++ b/src/test/app/Invariants_test.cpp @@ -4895,6 +4895,23 @@ class Invariants_test : public beast::unit_test::Suite // An account that is never funded, so it won't exist in any view. AccountID const ghostId = Account{"Ghost"}.id(); + doInvariantCheck( + Env{*this, amendments}, + {{"is missing pseudo-account field"}}, + [](Account const& a1, Account const&, ApplyContext& ac) { + auto const brokerKeylet = keylet::loanbroker(a1.id(), ac.view().seq()); + auto sle = std::make_shared(brokerKeylet); + sle->makeFieldAbsent(sfAccount); + auto const page = ac.view().dirInsert( + keylet::ownerDir(a1.id()), sle->key(), describeOwnerDir(a1.id())); + if (!page) + return false; + sle->setFieldU64(sfOwnerNode, *page); + sle->setFieldU32(sfOwnerCount, 1); + ac.view().insert(sle); + return true; + }); + // Vault: pseudo-account is referenced but does not exist on the ledger. doInvariantCheck( Env{*this, amendments}, From 0a8b12cd679c3a3af3e6e39e5794a98f3592b288 Mon Sep 17 00:00:00 2001 From: Vito <5780819+Tapanito@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:34:39 +0200 Subject: [PATCH 3/4] refactor: Invert ObjectHasPseudoAccount to check deletion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change ObjectHasPseudoAccount from checking "object exists → pseudo-account exists" to "object deleted → pseudo-account deleted". Together with AccountRootsDeletedClean (which enforces the reverse: pseudo-account deleted → object deleted), this pair of invariants guarantees that an object and its pseudo-account are always deleted as a unit. --- include/xrpl/tx/invariants/InvariantCheck.h | 9 +- src/libxrpl/tx/invariants/InvariantCheck.cpp | 37 ++-- src/test/app/Invariants_test.cpp | 187 ++++++++++++------- 3 files changed, 144 insertions(+), 89 deletions(-) diff --git a/include/xrpl/tx/invariants/InvariantCheck.h b/include/xrpl/tx/invariants/InvariantCheck.h index 4523605f837..3d2d35ee6dd 100644 --- a/include/xrpl/tx/invariants/InvariantCheck.h +++ b/include/xrpl/tx/invariants/InvariantCheck.h @@ -386,8 +386,11 @@ class ValidAmounts }; /* - * Verify that all objects that have associated pseudo-accounts, always have the said - * pseudo-accounts. + * Verify that when an object with an associated pseudo-account is deleted, + * its pseudo-account is also deleted. + * + * The reverse (pseudo-account deleted → object deleted) is enforced by + * AccountRootsDeletedClean via getPseudoAccountFields(). */ class ObjectHasPseudoAccount { @@ -399,7 +402,7 @@ class ObjectHasPseudoAccount finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&) const; private: - std::vector sles_; + std::vector deletedObjSles_; }; // additional invariant checks can be declared above and then added to this // tuple diff --git a/src/libxrpl/tx/invariants/InvariantCheck.cpp b/src/libxrpl/tx/invariants/InvariantCheck.cpp index 1eb09ab3734..53567758ac6 100644 --- a/src/libxrpl/tx/invariants/InvariantCheck.cpp +++ b/src/libxrpl/tx/invariants/InvariantCheck.cpp @@ -1072,25 +1072,24 @@ ValidAmounts::finalize( void ObjectHasPseudoAccount::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) { - // If an object is deleted, pseudo-account is also deleted - if (isDelete) + if (!isDelete) return; - // After should never be null when isDelete = false, if it is, something went horribly wrong - if (!after) + // Before should never be null when isDelete = true + if (!before) { XRPL_ASSERT( - after, - "xrpl::ObjectHasPseudoAccount::visitEntry : modified ledger entry missing after state"); + before, + "xrpl::ObjectHasPseudoAccount::visitEntry : deleted ledger entry missing before state"); return; // LCOV_EXCL_LINE } - switch (after->getType()) + switch (before->getType()) { case ltAMM: case ltVAULT: case ltLOAN_BROKER: - sles_.push_back(after); + deletedObjSles_.push_back(before); break; default: return; @@ -1108,28 +1107,32 @@ ObjectHasPseudoAccount::finalize( if (!view.rules().enabled(fixCleanup3_3_0)) return true; - if (sles_.empty()) + if (deletedObjSles_.empty()) return true; + auto const typeName = [](SLE const& sle) { + if (auto item = LedgerFormats::getInstance().findByType(sle.getType())) + return item->getName(); + return std::to_string(sle.getType()); + }; + bool failed = false; - for (auto const& sle : sles_) + for (auto const& sle : deletedObjSles_) { - // For current ledger entry types, pseudo-account is identified by `sfAccount` field. if (!sle->isFieldPresent(sfAccount)) { - JLOG(j.fatal()) << "Invariant failed: ledger entry " << sle->getSType() + JLOG(j.fatal()) << "Invariant failed: deleted " << typeName(*sle) << " is missing pseudo-account field"; failed = true; continue; } + // The pseudo-account must NOT exist on the ledger after the object is deleted. bool const exists = view.read(keylet::account(sle->getAccountID(sfAccount))) != nullptr; - - // The pseudo-account must exist on the ledger - if (!exists) + if (exists) { - JLOG(j.fatal()) << "Invariant failed: ledger entry " << sle->getSType() - << " pseudo-account does not exist"; + JLOG(j.fatal()) << "Invariant failed: deleted " << typeName(*sle) + << " without deleting its pseudo-account"; failed = true; } } diff --git a/src/test/app/Invariants_test.cpp b/src/test/app/Invariants_test.cpp index 95a4d79a9d5..ab5b67b4539 100644 --- a/src/test/app/Invariants_test.cpp +++ b/src/test/app/Invariants_test.cpp @@ -2741,7 +2741,8 @@ class Invariants_test : public beast::unit_test::Suite }); doInvariantCheck( - {"vault updated by a wrong transaction type"}, + {"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(), ac.view().seq()); auto sleVault = ac.view().peek(keylet); @@ -2752,7 +2753,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttPAYMENT, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, [&](Account const& a1, Account const& a2, Env& env) { Vault const vault{env}; auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); @@ -2798,7 +2799,8 @@ class Invariants_test : public beast::unit_test::Suite {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); doInvariantCheck( - {"vault deleted by a wrong transaction type"}, + {"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(), ac.view().seq()); auto sleVault = ac.view().peek(keylet); @@ -2809,7 +2811,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_SET, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, [&](Account const& a1, Account const& a2, Env& env) { Vault const vault{env}; auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); @@ -2818,7 +2820,8 @@ class Invariants_test : public beast::unit_test::Suite }); doInvariantCheck( - {"vault operation updated more than single vault"}, + {"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(), ac.view().seq()); @@ -2838,7 +2841,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_DELETE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, [&](Account const& a1, Account const& a2, Env& env) { Vault const vault{env}; { @@ -2874,7 +2877,8 @@ class Invariants_test : public beast::unit_test::Suite {tecINVARIANT_FAILED, tecINVARIANT_FAILED}); doInvariantCheck( - {"deleted vault must also delete shares"}, + {"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(), ac.view().seq()); auto sleVault = ac.view().peek(keylet); @@ -2885,7 +2889,7 @@ class Invariants_test : public beast::unit_test::Suite }, XRPAmount{}, STTx{ttVAULT_DELETE, [](STObject&) {}}, - {tecINVARIANT_FAILED, tecINVARIANT_FAILED}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, [&](Account const& a1, Account const& a2, Env& env) { Vault const vault{env}; auto [tx, _] = vault.create({.owner = a1, .asset = xrpIssue()}); @@ -4892,71 +4896,116 @@ class Invariants_test : public beast::unit_test::Suite using namespace jtx; auto const amendments = defaultAmendments() | fixCleanup3_3_0; - // An account that is never funded, so it won't exist in any view. - AccountID const ghostId = Account{"Ghost"}.id(); - doInvariantCheck( - Env{*this, amendments}, - {{"is missing pseudo-account field"}}, - [](Account const& a1, Account const&, ApplyContext& ac) { - auto const brokerKeylet = keylet::loanbroker(a1.id(), ac.view().seq()); - auto sle = std::make_shared(brokerKeylet); - sle->makeFieldAbsent(sfAccount); - auto const page = ac.view().dirInsert( - keylet::ownerDir(a1.id()), sle->key(), describeOwnerDir(a1.id())); - if (!page) - return false; - sle->setFieldU64(sfOwnerNode, *page); - sle->setFieldU32(sfOwnerCount, 1); - ac.view().insert(sle); - return true; - }); + // 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; + }); + } - // Vault: pseudo-account is referenced but does not exist on the ledger. - doInvariantCheck( - Env{*this, amendments}, - {{"pseudo-account does not exist"}}, - [ghostId](Account const& a1, Account const&, ApplyContext& ac) { - auto const vaultKeylet = keylet::vault(a1.id(), ac.view().seq()); - auto sle = std::make_shared(vaultKeylet); - sle->setAccountID(sfAccount, ghostId); - auto const page = ac.view().dirInsert( - keylet::ownerDir(a1.id()), sle->key(), describeOwnerDir(a1.id())); - if (!page) - return false; - sle->setFieldU64(sfOwnerNode, *page); - ac.view().insert(sle); - 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; + }); + } - // AMM: pseudo-account is referenced but does not exist on the ledger. - doInvariantCheck( - Env{*this, amendments}, - {{"pseudo-account does not exist"}}, - [ghostId](Account const&, Account const&, ApplyContext& ac) { - auto const ammKeylet = keylet::amm(uint256(1u)); - auto sle = std::make_shared(ammKeylet); - sle->setAccountID(sfAccount, ghostId); - ac.view().insert(sle); - 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)); + }); + } - // LoanBroker: pseudo-account is referenced but does not exist on the ledger. - doInvariantCheck( - Env{*this, amendments}, - {{"pseudo-account does not exist"}}, - [ghostId](Account const& a1, Account const&, ApplyContext& ac) { - auto const brokerKeylet = keylet::loanbroker(a1.id(), ac.view().seq()); - auto sle = std::make_shared(brokerKeylet); - sle->setAccountID(sfAccount, ghostId); - auto const page = ac.view().dirInsert( - keylet::ownerDir(a1.id()), sle->key(), describeOwnerDir(a1.id())); - if (!page) - return false; - sle->setFieldU64(sfOwnerNode, *page); - ac.view().insert(sle); - return true; - }); + // 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(), ov.seq()); + auto sleVault = std::make_shared(vaultKeylet); + sleVault->makeFieldAbsent(sfAccount); + ov.rawInsert(sleVault); + + STTx 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{}); + BEAST_EXPECT(result == tecINVARIANT_FAILED); + BEAST_EXPECT(sink.messages().str().contains("is missing pseudo-account field")); + } } public: From 78483ccdc9b92517822fd0f704cd83c7c19b8a26 Mon Sep 17 00:00:00 2001 From: Vito <5780819+Tapanito@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:39:28 +0200 Subject: [PATCH 4/4] clang-tidy --- src/test/app/Invariants_test.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/app/Invariants_test.cpp b/src/test/app/Invariants_test.cpp index ab5b67b4539..cb218b84645 100644 --- a/src/test/app/Invariants_test.cpp +++ b/src/test/app/Invariants_test.cpp @@ -4987,7 +4987,7 @@ class Invariants_test : public beast::unit_test::Suite sleVault->makeFieldAbsent(sfAccount); ov.rawInsert(sleVault); - STTx tx{ttVAULT_DELETE, [](STObject&) {}}; + STTx const tx{ttVAULT_DELETE, [](STObject&) {}}; test::StreamSink sink{beast::Severity::Warning}; beast::Journal const jlog{sink}; ApplyContext ac{