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/7] 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/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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 ;