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 1/2] 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 2/2] 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(); } };