diff --git a/include/xrpl/ledger/helpers/VaultHelpers.h b/include/xrpl/ledger/helpers/VaultHelpers.h index 58db571d25..5e4c9edb9f 100644 --- a/include/xrpl/ledger/helpers/VaultHelpers.h +++ b/include/xrpl/ledger/helpers/VaultHelpers.h @@ -153,6 +153,13 @@ getVaultScale(SLE::const_ref vault); * scale is appropriate for their own accounting (e.g. current vs. posterior * Vault scale); this helper does not perform any additional rounding. * + * Ordering: the Vault fields are mutated (and `view.update` called) before + * the underlying `accountSend`; on a non-tesSUCCESS return, the caller is + * responsible for discarding the ApplyView (transactors rely on the sandbox + * being thrown away on non-tesSUCCESS, which is the codebase convention). + * All in-tree callers are transactors that already return the propagated + * TER, so no additional rollback is required. + * * @param view The ledger view to apply changes to. * @param vault The vault SLE. Must not be null. * @param sender The account to transfer `amount` from. @@ -202,6 +209,12 @@ enum class FinalRemoval : bool { No = false, Yes = true }; * scale is appropriate for their own accounting; this helper does not * perform any additional rounding. * + * Ordering: same convention as addVaultAssets — the Vault fields are mutated + * before the transfer, and the ApplyView must be discarded by the caller on a + * non-tesSUCCESS return. The pre-transfer `amount > sfAssetsAvailable` guard + * still fires before any mutation, so a malformed clawback amount cannot + * modify the Vault at all. + * * @param view The ledger view to apply changes to. * @param vault The vault SLE. Must not be null. * @param recipient The account to transfer `amount` to. Must already be @@ -238,6 +251,17 @@ clawbackVaultAssets( * perform any additional rounding, except when `finalRemoval` is Yes (see * FinalRemoval). * + * Ordering: same convention as addVaultAssets — the Vault fields are mutated + * before the transfer, and the ApplyView must be discarded by the caller on a + * non-tesSUCCESS return. + * + * When `finalRemoval` is Yes, callers must pass `amount == + * *vault->at(sfAssetsAvailable)` (the pre-call value): the helper asserts + * this invariant and would otherwise leave dust on the Vault's pseudo-account + * despite hard-resetting the fields to zero. VaultWithdraw's final-removal + * path pins `amount = allAvailable` immediately before calling this helper + * to satisfy the contract. + * * @param ctx The apply-view context to apply changes to. * @param vault The vault SLE. Must not be null. * @param senderAcct The account that submitted the withdrawal transaction. @@ -245,9 +269,9 @@ clawbackVaultAssets( * @param priorBalance The XRP reserve base, passed through to doWithdraw for * creating a holding for `dstAcct` when required. * @param amount The amount to subtract from sfAssetsAvailable, and to - * transfer from the Vault's pseudo-account to `dstAcct`. - * Ignored (other than for the transfer) when `finalRemoval` is - * Yes. + * transfer from the Vault's pseudo-account to `dstAcct`. When + * `finalRemoval` is Yes, must equal the Vault's pre-call + * sfAssetsAvailable so the transfer drains the pseudo-account. * @param j Journal for logging. * @param finalRemoval Whether this is the Vault's final removal (see * FinalRemoval). @@ -284,6 +308,10 @@ removeVaultAssets( * addEmptyHolding and requireAuth performed by the caller beforehand); this * helper does not create holdings or check authorization. * + * Ordering: same convention as addVaultAssets — the Vault fields are mutated + * before the transfer, and the ApplyView must be discarded by the caller on + * a non-tesSUCCESS return. + * * sfAssetsAvailable is decreased by an STAmount built from the sum of the * recipients' Numbers, so for a very large recipient list whose sum exceeds * STAmount's ~16 significant digits, this could round differently than diff --git a/src/libxrpl/ledger/helpers/VaultHelpers.cpp b/src/libxrpl/ledger/helpers/VaultHelpers.cpp index b75fc9ebb5..52e4357beb 100644 --- a/src/libxrpl/ledger/helpers/VaultHelpers.cpp +++ b/src/libxrpl/ledger/helpers/VaultHelpers.cpp @@ -306,9 +306,31 @@ removeVaultAssets( [[maybe_unused]] Asset const asset = vault->at(sfAsset); XRPL_ASSERT(amount.asset() == asset, "xrpl::removeVaultAssets : amount matches vault asset"); XRPL_ASSERT(amount >= beast::kZero, "xrpl::removeVaultAssets : amount is non-negative"); + // On a final removal both fields are hard-reset to zero, so the amount + // being withdrawn must equal the Vault's pre-mutation sfAssetsAvailable + // — otherwise the caller would leave residual funds on the Vault's + // pseudo-account after the Vault-level bookkeeping says the position + // is fully unwound. VaultWithdraw enforces this by pinning + // `assetsWithdrawn = allAvailable` immediately before setting + // FinalRemoval::Yes. The equality is checked via STAmount so both + // sides go through the same asset-precision normalization used by + // VaultWithdraw when it built `allAvailable`. + // Constructed outside the assert macro because unprotected commas + // inside `{}` initializers are parsed as extra macro arguments. + [[maybe_unused]] STAmount const availableSnapshot{asset, Number(vault->at(sfAssetsAvailable))}; + XRPL_ASSERT( + finalRemoval == FinalRemoval::No || amount == availableSnapshot, + "xrpl::removeVaultAssets : final removal amount equals sfAssetsAvailable"); applyRemoveVaultAssets(ctx.view, vault, amount, finalRemoval); + // The amount==0 short-circuit is defensive: every in-tree caller + // (VaultWithdraw) computes `assetsWithdrawn` from a positive + // sharesRedeemed and errors out earlier on a zero-share withdrawal, so + // production traffic cannot reach this path with amount==0. + // doWithdraw would otherwise still succeed for a zero amount, but + // short-circuiting here makes the invariant explicit at the single + // mutation point. if (amount == beast::kZero) return tesSUCCESS; // LCOV_EXCL_LINE diff --git a/src/libxrpl/tx/transactors/lending/LoanManage.cpp b/src/libxrpl/tx/transactors/lending/LoanManage.cpp index a8ce81b134..373f687c03 100644 --- a/src/libxrpl/tx/transactors/lending/LoanManage.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanManage.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -327,6 +328,24 @@ LoanManage::defaultLoan( { STAmount const amount{vaultAsset, defaultCovered}; STAmount const writeOff{vaultAsset, totalDefaultAmount}; + + // Defense-in-depth mirror of the invariant check performed on the + // LoanSet.cpp/LoanPay.cpp addVaultAssets paths: project the raw + // post-mutation Vault fields (pre-rounding) and assert Available <= + // Total. Redundant with the tefBAD_LEDGER guard above (which is + // strictly stronger, since Total - Available >= totalDefaultAmount + // implies the post-write inequality), but kept for structural + // parity — if either of the addVaultAssets deltas ever drifts, this + // fires before the SLE mutation lands. + [[maybe_unused]] Number const assetsAvailableAfterRaw = + Number(vaultSle->at(sfAssetsAvailable)) + defaultCovered; + [[maybe_unused]] Number const assetsTotalAfterRaw = + Number(vaultSle->at(sfAssetsTotal)) + (defaultCovered - totalDefaultAmount); + XRPL_ASSERT_PARTS( + assetsAvailableAfterRaw <= assetsTotalAfterRaw, + "xrpl::LoanManage::defaultLoan", + "assets available must not be greater than assets outstanding"); + return addVaultAssets( view, vaultSle, brokerSle->at(sfAccount), amount, amount - writeOff, j); } diff --git a/src/test/app/VaultHelpers_test.cpp b/src/test/app/VaultHelpers_test.cpp index 19c41d9f87..25ed28f38f 100644 --- a/src/test/app/VaultHelpers_test.cpp +++ b/src/test/app/VaultHelpers_test.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include #include #include @@ -14,11 +15,14 @@ #include #include #include +#include #include +#include #include #include #include // IWYU pragma: keep #include +#include namespace xrpl::test { @@ -146,14 +150,25 @@ class VaultHelpers_test : public beast::unit_test::Suite // If the underlying accountSend fails (here: sender has no trust // line for the vault asset and is not its issuer, so cannot source // the IOU), the helper propagates the non-tes error rather than - // silently swallowing it. + // silently swallowing it. The Vault SLE fields have already been + // mutated by the time accountSend fails (the helper follows the + // "mutate-then-transfer" ordering documented in VaultHelpers.h); + // this test pins that observable so the ordering contract is + // visible and future changes have to knowingly break it. In + // production the transactor's ApplyView sandbox is discarded on a + // non-tesSUCCESS return, so the caller never observes the + // mutation. { Account const stranger{"stranger"}; env.fund(XRP(10'000), stranger); env.close(); + Number const totalBeforeFail = vault->at(sfAssetsTotal); + Number const availableBeforeFail = vault->at(sfAssetsAvailable); STAmount const ten{vaultAsset, 10}; auto const ter = addVaultAssets(view, vault, stranger, ten, ten, env.journal); BEAST_EXPECT(!isTesSuccess(ter)); + BEAST_EXPECT(Number(vault->at(sfAssetsTotal)) == totalBeforeFail + 10); + BEAST_EXPECT(Number(vault->at(sfAssetsAvailable)) == availableBeforeFail + 10); } } @@ -240,14 +255,22 @@ class VaultHelpers_test : public beast::unit_test::Suite // propagates the non-tes error rather than silently swallowing it. // In production the recipient is always the asset issuer, which // implicitly holds its own asset; this synthetic third-party - // recipient stands in only to exercise the failure branch. + // recipient stands in only to exercise the failure branch. As with + // addVaultAssets, the Vault SLE fields are mutated (decreased) + // before the transfer attempt, matching the documented mutate- + // then-transfer contract; the transactor sandbox is what makes the + // mutation invisible to callers on failure. { Account const stranger{"stranger"}; env.fund(XRP(10'000), stranger); env.close(); + Number const totalBeforeFail = vault->at(sfAssetsTotal); + Number const availableBeforeFail = vault->at(sfAssetsAvailable); STAmount const ten{vaultAsset, 10}; auto const failTer = clawbackVaultAssets(view, vault, stranger, ten, env.journal); BEAST_EXPECT(!isTesSuccess(failTer)); + BEAST_EXPECT(Number(vault->at(sfAssetsTotal)) == totalBeforeFail - 10); + BEAST_EXPECT(Number(vault->at(sfAssetsAvailable)) == availableBeforeFail - 10); } } @@ -261,12 +284,17 @@ class VaultHelpers_test : public beast::unit_test::Suite Account const issuer{"issuer"}; Account const owner{"owner"}; Account const depositor{"depositor"}; - env.fund(XRP(10'000), issuer, owner, depositor); + // Third-party withdrawal destination for the dstAcct != senderAcct + // sub-test below. Set up before the ApplyViewImpl snapshot so its + // account root and trust line are visible to the helper. + Account const charlie{"charlie"}; + env.fund(XRP(10'000), issuer, owner, depositor, charlie); env.close(); PrettyAsset const asset = issuer["USD"]; env(trust(owner, asset(1'000'000))); env(trust(depositor, asset(1'000'000))); + env(trust(charlie, asset(1'000'000))); env(pay(issuer, depositor, asset(10'000))); env.close(); @@ -276,10 +304,14 @@ class VaultHelpers_test : public beast::unit_test::Suite env(v.deposit({.depositor = depositor, .id = vaultKeylet.key, .amount = asset(1'000)})); env.close(); - // doWithdraw only reads ctx.tx on the third-party-destination path; - // for a self-withdrawal (senderAcct == dstAcct, exercised below) it - // is unused, so a trivial signed noop stands in for a real - // VaultWithdraw transaction. + // doWithdraw uses ctx.tx on the third-party-destination path (both + // verifyDepositPreauth and getEffectiveTxReserveSponsor read it); + // for a self-withdrawal (senderAcct == dstAcct) it is unused. A + // signed noop from `depositor` stands in for a real VaultWithdraw + // transaction here: verifyDepositPreauth passes for a destination + // without lsfDepositAuth, and getEffectiveTxReserveSponsor returns + // a null sponsor because the destination differs from tx.Account + // (see SponsorHelpers.cpp:getEffectiveTxReserveSponsor). auto const dummyTx = env.jt(noop(depositor)).stx; if (!BEAST_EXPECT(dummyTx)) return; @@ -330,25 +362,99 @@ class VaultHelpers_test : public beast::unit_test::Suite BEAST_EXPECT(depositorBalanceAfter == depositorBalanceBefore + hundred); } - // FinalRemoval::Yes hard-resets both fields to exactly zero, - // regardless of the passed-in amount (deliberately an understated - // amount here, to prove the fields aren't merely decremented by - // it). + // Third-party destination: senderAcct != dstAcct exercises + // doWithdraw's verifyDepositPreauth branch (a self-withdrawal + // instead hits addEmptyHolding). `charlie` already holds a trust + // line for the asset, so no holding creation is needed on the + // destination side. The Vault SLE moves by `amount`, and the + // funds land on `charlie` rather than on the transaction sender. + { + Number const totalBefore3P = vault->at(sfAssetsTotal); + Number const availableBefore3P = vault->at(sfAssetsAvailable); + auto const charlieBalanceBefore3P = accountHolds( + view, + charlie, + vaultAsset, + FreezeHandling::IgnoreFreeze, + AuthHandling::IgnoreAuth, + env.journal); + auto const depositorBalanceBefore3P = accountHolds( + view, + depositor, + vaultAsset, + FreezeHandling::IgnoreFreeze, + AuthHandling::IgnoreAuth, + env.journal); + + STAmount const fifty{vaultAsset, 50}; + auto const ter = removeVaultAssets( + ctx, vault, depositor, charlie, XRPAmount{0}, fifty, env.journal, FinalRemoval::No); + auto const charlieBalanceAfter3P = accountHolds( + view, + charlie, + vaultAsset, + FreezeHandling::IgnoreFreeze, + AuthHandling::IgnoreAuth, + env.journal); + auto const depositorBalanceAfter3P = accountHolds( + view, + depositor, + vaultAsset, + FreezeHandling::IgnoreFreeze, + AuthHandling::IgnoreAuth, + env.journal); + + BEAST_EXPECT(isTesSuccess(ter)); + BEAST_EXPECT(Number(vault->at(sfAssetsTotal)) == totalBefore3P - 50); + BEAST_EXPECT(Number(vault->at(sfAssetsAvailable)) == availableBefore3P - 50); + BEAST_EXPECT(charlieBalanceAfter3P == charlieBalanceBefore3P + fifty); + BEAST_EXPECT(depositorBalanceAfter3P == depositorBalanceBefore3P); + } + + // FinalRemoval::Yes hard-resets both fields to exactly zero and + // drains the Vault's pseudo-account: the caller must pass + // `amount == pre-call sfAssetsAvailable` (VaultWithdraw pins that + // via `assetsWithdrawn = allAvailable` before setting the flag), + // and the recipient's balance is expected to increase by exactly + // that amount. The passed-in STAmount is used only for the + // pseudo-account -> recipient transfer; the Vault fields are + // reset regardless of it (as long as it matches sfAssetsAvailable + // -- other values would trip the helper's precondition + // XRPL_ASSERT and are covered by the transactor contract, not + // this unit). { Number const totalBeforeFinal = vault->at(sfAssetsTotal); - STAmount const one{vaultAsset, 1}; + Number const availableBeforeFinal = vault->at(sfAssetsAvailable); + auto const depositorBalanceBeforeFinal = accountHolds( + view, + depositor, + vaultAsset, + FreezeHandling::IgnoreFreeze, + AuthHandling::IgnoreAuth, + env.journal); + + STAmount const allAvailable{vaultAsset, availableBeforeFinal}; auto const ter = removeVaultAssets( ctx, vault, depositor, depositor, XRPAmount{0}, - one, + allAvailable, env.journal, FinalRemoval::Yes); + auto const depositorBalanceAfterFinal = accountHolds( + view, + depositor, + vaultAsset, + FreezeHandling::IgnoreFreeze, + AuthHandling::IgnoreAuth, + env.journal); + BEAST_EXPECT(isTesSuccess(ter)); BEAST_EXPECT(Number(vault->at(sfAssetsTotal)) == Number{0}); BEAST_EXPECT(Number(vault->at(sfAssetsAvailable)) == Number{0}); + BEAST_EXPECT(depositorBalanceAfterFinal == depositorBalanceBeforeFinal + allAvailable); BEAST_EXPECTS( totalBeforeFinal != Number(0), "fixture sanity: total was nonzero before the final removal"); @@ -480,6 +586,220 @@ class VaultHelpers_test : public beast::unit_test::Suite } } + // Legacy-version Vault fixture: featureLendingProtocolV1_1 is off, so + // VaultCreate does not set sfLEVersion and getVaultVersion() resolves + // to Legacy. That is the only vault variant on which + // moveVaultAssets permits a nonzero valueDelta (see the + // "moveVaultAssets : nonzero valueDelta requires Legacy vault version" + // assertion in the helper). + void + testMoveVaultAssetsLegacy() + { + testcase("moveVaultAssets: Legacy vault, nonzero valueDelta"); + using namespace jtx; + + Env env(*this, testableAmendments() - featureLendingProtocolV1_1); + Account const issuer{"issuer"}; + Account const owner{"owner"}; + Account const depositor{"depositor"}; + Account const borrower{"borrower"}; + Account const feeRecipient{"feeRecipient"}; + env.fund(XRP(10'000), issuer, owner, depositor, borrower, feeRecipient); + env.close(); + + PrettyAsset const asset = issuer["USD"]; + env(trust(owner, asset(1'000'000))); + env(trust(depositor, asset(1'000'000))); + env(trust(borrower, asset(1'000'000))); + env(trust(feeRecipient, asset(1'000'000))); + env(pay(issuer, depositor, asset(10'000))); + env.close(); + + auto const vaultKeylet = setupVault(env, asset, owner); + + Vault const v{env}; + env(v.deposit({.depositor = depositor, .id = vaultKeylet.key, .amount = asset(1'000)})); + env.close(); + + auto const open = env.current(); + ApplyViewImpl view(&*open, TapNone); + auto const vault = view.peek(vaultKeylet); + if (!BEAST_EXPECT(vault)) + return; + // Confirm the fixture actually produced a Legacy vault (sfLEVersion + // absent). If this changes upstream the helper's precondition would + // reject the nonzero valueDelta below, which would only fire via an + // XRPL_ASSERT — checking it explicitly keeps the failure mode + // legible. + if (!BEAST_EXPECTS( + getVaultVersion(vault) == VaultVersion::Legacy, + "fixture: featureLendingProtocolV1_1 disabled produces Legacy vault")) + return; + + Asset const vaultAsset = vault->at(sfAsset); + + Number const totalBefore = vault->at(sfAssetsTotal); + Number const availableBefore = vault->at(sfAssetsAvailable); + auto const borrowerBalanceBefore = accountHolds( + view, + borrower, + vaultAsset, + FreezeHandling::IgnoreFreeze, + AuthHandling::IgnoreAuth, + env.journal); + auto const feeRecipientBalanceBefore = accountHolds( + view, + feeRecipient, + vaultAsset, + FreezeHandling::IgnoreFreeze, + AuthHandling::IgnoreAuth, + env.journal); + + // Nonzero valueDelta increases sfAssetsTotal independently of the + // funds actually moved from the Vault's pseudo-account to the + // recipients: this mirrors a Legacy-vault loan origination, where + // recognizing accrued interest into sfAssetsTotal is decoupled + // from the cash disbursement decrementing sfAssetsAvailable. + MultiplePaymentDestinations const recipients{ + {borrower, Number{80}}, + {feeRecipient, Number{20}}, + }; + STAmount const valueDelta{vaultAsset, 5}; + auto const ter = moveVaultAssets(view, vault, recipients, valueDelta, env.journal); + auto const borrowerBalanceAfter = accountHolds( + view, + borrower, + vaultAsset, + FreezeHandling::IgnoreFreeze, + AuthHandling::IgnoreAuth, + env.journal); + auto const feeRecipientBalanceAfter = accountHolds( + view, + feeRecipient, + vaultAsset, + FreezeHandling::IgnoreFreeze, + AuthHandling::IgnoreAuth, + env.journal); + STAmount const eighty{vaultAsset, 80}; + STAmount const twenty{vaultAsset, 20}; + + BEAST_EXPECT(isTesSuccess(ter)); + BEAST_EXPECT(Number(vault->at(sfAssetsTotal)) == totalBefore + 5); + BEAST_EXPECT(Number(vault->at(sfAssetsAvailable)) == availableBefore - 100); + BEAST_EXPECT(borrowerBalanceAfter == borrowerBalanceBefore + eighty); + BEAST_EXPECT(feeRecipientBalanceAfter == feeRecipientBalanceBefore + twenty); + } + + // MPT-backed Vault exercises add/remove helpers on an integral asset, + // where accountSend/doWithdraw take the MPT code paths instead of the + // IOU trust-line paths used by the primary testAddVaultAssets/ + // testRemoveVaultAssets fixtures. Field-mutation contracts are + // asset-agnostic and stay identical, but this confirms the helpers + // compose correctly with the MPT transfer implementations. + void + testHelpersMPT() + { + testcase("addVaultAssets / removeVaultAssets: MPT-backed vault"); + using namespace jtx; + + Env env(*this); + Account const issuer{"issuer"}; + Account const owner{"owner"}; + Account const depositor{"depositor"}; + env.fund(XRP(10'000), issuer, owner, depositor); + env.close(); + + MPTTester mptt{env, issuer, kMptInitNoFund}; + mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock}); + mptt.authorize({.account = owner}); + mptt.authorize({.account = depositor}); + PrettyAsset const asset = mptt.issuanceID(); + env(pay(issuer, depositor, asset(10'000))); + env.close(); + + auto const vaultKeylet = setupVault(env, asset, owner); + + Vault const v{env}; + env(v.deposit({.depositor = depositor, .id = vaultKeylet.key, .amount = asset(1'000)})); + env.close(); + + auto const dummyTx = env.jt(noop(depositor)).stx; + if (!BEAST_EXPECT(dummyTx)) + return; + + auto const open = env.current(); + ApplyViewImpl view(&*open, TapNone); + auto const vault = view.peek(vaultKeylet); + if (!BEAST_EXPECT(vault)) + return; + Asset const vaultAsset = vault->at(sfAsset); + ApplyViewContext const ctx{.view = view, .tx = *dummyTx}; + + Number const totalBefore = vault->at(sfAssetsTotal); + Number const availableBefore = vault->at(sfAssetsAvailable); + auto const depositorBalanceBefore = accountHolds( + view, + depositor, + vaultAsset, + FreezeHandling::IgnoreFreeze, + AuthHandling::IgnoreAuth, + env.journal); + + // addVaultAssets: same field-delta contract as the IOU fixture, + // but accountSend routes through the MPT transfer code path. + { + STAmount const fifty{vaultAsset, 50}; + auto const ter = addVaultAssets(view, vault, depositor, fifty, fifty, env.journal); + auto const depositorBalanceAfter = accountHolds( + view, + depositor, + vaultAsset, + FreezeHandling::IgnoreFreeze, + AuthHandling::IgnoreAuth, + env.journal); + BEAST_EXPECT(isTesSuccess(ter)); + BEAST_EXPECT(Number(vault->at(sfAssetsTotal)) == totalBefore + 50); + BEAST_EXPECT(Number(vault->at(sfAssetsAvailable)) == availableBefore + 50); + BEAST_EXPECT(depositorBalanceAfter == depositorBalanceBefore - fifty); + } + + // removeVaultAssets (non-final, self-withdrawal): fields decrease + // by amount; doWithdraw's MPT path routes the transfer back to + // the depositor. + { + Number const totalBeforeRm = vault->at(sfAssetsTotal); + Number const availableBeforeRm = vault->at(sfAssetsAvailable); + auto const depositorBalanceBeforeRm = accountHolds( + view, + depositor, + vaultAsset, + FreezeHandling::IgnoreFreeze, + AuthHandling::IgnoreAuth, + env.journal); + STAmount const thirty{vaultAsset, 30}; + auto const ter = removeVaultAssets( + ctx, + vault, + depositor, + depositor, + XRPAmount{0}, + thirty, + env.journal, + FinalRemoval::No); + auto const depositorBalanceAfterRm = accountHolds( + view, + depositor, + vaultAsset, + FreezeHandling::IgnoreFreeze, + AuthHandling::IgnoreAuth, + env.journal); + BEAST_EXPECT(isTesSuccess(ter)); + BEAST_EXPECT(Number(vault->at(sfAssetsTotal)) == totalBeforeRm - 30); + BEAST_EXPECT(Number(vault->at(sfAssetsAvailable)) == availableBeforeRm - 30); + BEAST_EXPECT(depositorBalanceAfterRm == depositorBalanceBeforeRm + thirty); + } + } + public: void run() override @@ -488,6 +808,8 @@ public: testClawbackVaultAssets(); testRemoveVaultAssets(); testMoveVaultAssets(); + testMoveVaultAssetsLegacy(); + testHelpersMPT(); } };